Vertical Slice Ne Zaman? Clean Architecture ve Modular Monolith ile Birlikte Kullanmak
Vertical slice'lar sık feature eklenen, karmaşıklığı use case'e göre değişen API'lerde iyi çalışır; refactoring disiplini olmayan ekiplerde ve zengin, paylaşılan domain'lerde tek başına yetmez, en iyi sonucu Clean bir domain çekirdeği ve modular monolith sınırlarıyla birlikte verir. Bookstore'u module'lere ayırıp sınırı internal ve ArchUnitNET ile koruyor, FastEndpoints ile REPR'ı değerlendiriyor ve layered bir projeyi slice'lara kademeli taşıma planını çıkarıyoruz.
Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir.
Önce Tahmin Et
Bookstore bugün üç developer'lık bir ekibin yönettiği, haftada bir deploy edilen katmanlı bir monolith. Mimariye Giriş serisinde koyduğumuz büyüme hedeflerine göre 12–18 ay içinde dört ayrı ekip olacak: Catalog ve arama, Ordering ve ödeme, Fulfillment, Customer Experience. Bu serinin beş yazısını okuyan ekip lideri bir öneriyle geliyor: "Bütün uygulamayı Vertical Slice Architecture'a çevirelim, Clean Architecture'daki Ordering yapısını da kaldıralım."
Tahmin et: Bookstore'un hangi bölgeleri slice'lara çok iyi uyar, hangisinde slice'lar tek başına yetmez? Dört ekip aynı kod tabanında çalışmaya başladığında slice'lar neyi çözer, neyi çözmez?
Ne Zaman Uygun
Serinin beş yazısında gördüklerimizi bir araya koyunca VSA'nın (Vertical Slice Architecture) en çok işe yaradığı durumlar netleşiyor:
- Sık feature eklenen API'ler (Application Programming Interface): 01. yazıdaki Ada ve Alan senaryosu gibi, birden çok developer'ın aynı anda farklı feature'lar yazdığı yerlerde ortak dosya sayısının azalması doğrudan merge maliyetini düşürüyor.
- Ağırlıkla CRUD (create, read, update, delete) ama büyüyen uygulamalar: okuma slice'ları projection ile ekranın tam ihtiyacını çekiyor (02), iş kuralı olmayan yazma slice'ları da transaction script olarak kalabiliyor.
- Use case'lerin karmaşıklığının birbirinden çok farklı olduğu kod tabanları: bir slice düz bir sorgu, öteki zengin bir domain model ile çalışabiliyor; uygulamanın tamamına tek bir yapı dayatılmıyor.
- Test'i davranış üzerinden yazmak isteyen ekipler: slice başına HTTP'den (Hypertext Transfer Protocol) veritabanına integration test (05), refactoring'de değişmeyen bir güvenlik ağı veriyor.
Bookstore'da Catalog yönetimi bu tanıma birebir uyuyor: kitap ekle, fiyat güncelle, ara, listele. Kuralı az, ekranı çok, değişimi sık.
Ne Zaman Uygun Değil
Üç durumda VSA ya zarar verir ya da kazancı küçük kalır. Birincisi, Bogard'ın kendi koyduğu şart: ekip code smell'leri tanımıyor ve refactoring yapmıyorsa slice'lar kopyala-yapıştır yığınına döner; 04. yazıdaki 7 adetlik sipariş, kuralın ikinci slice'ta unutulmasıyla ortaya çıkmıştı. İkincisi, domain'in çok zengin ve büyük ölçüde paylaşılan olduğu bölgeler: neredeyse her request aynı aggregate'in aynı kurallarından geçiyorsa slice'lar ince bir kabuk olarak kalır, asıl tasarım işi domain model'de yapılır ve orada Domain-Driven Design ile Clean Architecture'ın araçları belirleyici olur. Üçüncüsü, birkaç endpoint'lik ya da kısa ömürlü uygulamalar: hangi düzeni seçtiğin pek fark etmez, en basit olanı seç.
Bir de VSA'nın çözmediği bir problem var: ekipler arası sınır. Slice'lar bir feature'ın kodunu bir araya topluyor ama "Catalog ekibi Ordering'in tablosuna dokunabilir mi?" sorusuna cevap vermiyor. Bookstore'un dört ekipli geleceği için bu soru, slice'ın bir üst seviyesinde, module sınırında cevaplanmalı. Ekip lideri önerisinin iki yarısının da cevabı bu yüzden "kısmen": slice'lar evet, ama Ordering'in çekirdeğini kaldırmadan ve module sınırları çizilerek.
Hibrit 1: Clean Çekirdek ve Slice'lar
Clean, Onion ve Hexagonal Architecture serisinin "Clean Architecture Ne Zaman Fazla Gelir? Template Kültü, Interface Patlaması ve Pragmatik Yol" yazısı, kararın uygulama başına değil bölge başına verilebileceğini söyleyerek bitmişti. VSA ile Clean Architecture'ın birleşimi tam olarak bu: Domain çekirdeği ve Dependency Rule korunuyor, Application tarafındaki use case'ler ise service class'ları yerine slice'lar. Ordering'de Order ve OrderItem, 04. yazıdaki invariant'larıyla Domain namespace'inde duruyor; 04. yazıdaki PlaceOrder, CancelOrder ve Checkout slice'ları onu kullanıyor; aşağıdaki module örneğinde de PlaceOrder aynı Order'la çalışıyor. Dependency Rule'u derleyici değil bir architecture test'i koruyor; kural aşağıdaki test class'ının ilk method'unda.
Bu hibritte Clean Architecture'ın en pahalı parçaları kalkıyor: her use case için bir interface, ayrı bir Application project'i, use case başına request, command ve DTO (Data Transfer Object) üçlüsü. Kalan ise asıl değerli olan: veritabanı ve HTTP olmadan test edilebilen, framework'lere bağımlı olmayan bir domain çekirdeği.
Hibrit 2: Modular Monolith İçinde Slice'lar
Mimariye Giriş serisinin "Monolith'i Doğru Anlamak: Single-Process, Modular ve Distributed Monolith" yazısında modular monolith'i tek process ve tek deployment içinde, sınırları belirgin module'lerden oluşan bir yapı olarak tanımlamıştık. Slice'lar bu yapının en alt seviyesine oturuyor:

Her module ayrı bir project: kendi DbContext'i, kendi slice'ları ve dışarıya açtığı küçük bir contract. Catalog'un contract'ı tek bir interface:
namespace Bookstore.Catalog.Contracts;
// Catalog module'ünün dışarıya açtığı tek kapı
public interface IBookCatalog
{
Task<IReadOnlyDictionary<int, decimal>> GetPricesAsync(IReadOnlyCollection<int> bookIds, CancellationToken ct);
}
Module'ün geri kalanı internal; dışarıya yalnızca contract ve kayıt method'ları açık:
internal sealed class CatalogDbContext(DbContextOptions<CatalogDbContext> options) : DbContext(options)
{
public DbSet<Book> Books => Set<Book>();
}
internal sealed class BookCatalog(CatalogDbContext db) : IBookCatalog
{
public async Task<IReadOnlyDictionary<int, decimal>> GetPricesAsync(IReadOnlyCollection<int> bookIds, CancellationToken ct) =>
await db.Books.Where(b => bookIds.Contains(b.Id)).ToDictionaryAsync(b => b.Id, b => b.Price, ct);
}
public static class CatalogModule
{
public static IServiceCollection AddCatalogModule(this IServiceCollection services, string connectionString)
{
services.AddDbContext<CatalogDbContext>(o => o.UseSqlite(connectionString));
services.AddScoped<IBookCatalog, BookCatalog>();
return services.AddSlicesFrom(typeof(CatalogModule).Assembly);
}
public static void MapCatalogModule(this IEndpointRouteBuilder app) =>
app.MapSlicesFrom(typeof(CatalogModule).Assembly, "/api/catalog");
}
Ordering'in PlaceOrder slice'ı fiyatları Catalog'un tablosundan değil contract'ından alıyor:
// Slice, Catalog'un verisine kendi DbContext'iyle değil, Catalog'un contract'ıyla ulaşır
internal sealed class PlaceOrderHandler(OrderingDbContext db, IBookCatalog catalog)
{
public async Task<PlaceOrderResponse?> HandleAsync(PlaceOrderRequest request, CancellationToken ct)
{
int[] ids = [.. request.Lines.Select(l => l.BookId).Distinct()];
IReadOnlyDictionary<int, decimal> prices = await catalog.GetPricesAsync(ids, ct);
if (prices.Count != ids.Length)
return null;
Order order = new(request.CustomerId);
foreach (PlaceOrderLine line in request.Lines)
order.AddItem(line.BookId, line.Quantity, prices[line.BookId]);
db.Orders.Add(order);
await db.SaveChangesAsync(ct);
return new PlaceOrderResponse(order.Id, order.Total);
}
}
Host yalnızca module'leri birleştiriyor:
using Bookstore.Catalog;
using Bookstore.Ordering;
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
builder.Services.AddCatalogModule("Data Source=catalog.db");
builder.Services.AddOrderingModule("Data Source=ordering.db");
WebApplication app = builder.Build();
app.Services.SeedCatalog();
app.Services.CreateOrderingDatabase();
app.MapCatalogModule();
app.MapOrderingModule();
app.Run();
GET /api/catalog/books/1
HTTP 200 {"id":1,"title":"Dune","price":12.5}
POST /api/ordering/orders {"customerId":7,"lines":[{"bookId":1,"quantity":2},{"bookId":2,"quantity":1}]}
HTTP 201 {"orderId":1,"total":33.0}
Module sınırının ilk bekçisi derleyici. Ordering'de bir developer raporlama için "kısayoldan" Catalog'un tablosunu okumaya çalıştığında:
using Bookstore.Catalog;
using Microsoft.EntityFrameworkCore;
namespace Bookstore.Ordering.Features.BookSales;
// Kısayol denemesi: Catalog'un tablosunu doğrudan okumak
internal sealed class BookSalesHandler(CatalogDbContext catalogDb)
{
public Task<int> CountBooksAsync(CancellationToken ct) => catalogDb.Books.CountAsync(ct);
}
Bookstore.Ordering/Features/BookSales/BookSales.cs(7,40): error CS0122: 'CatalogDbContext' is inaccessible due to its protection level
Ama CatalogModule gibi public kalması gereken tipler için internal yetmiyor. İkinci bekçi, Hibrit 1'deki domain kuralıyla birlikte, architecture test'leri:
using ArchUnitNET.Domain;
using ArchUnitNET.Loader;
using ArchUnitNET.xUnitV3;
using Bookstore.Catalog;
using Bookstore.Ordering;
using static ArchUnitNET.Fluent.ArchRuleDefinition;
using static ArchUnitNET.Fluent.Slices.SliceRuleDefinition;
namespace Bookstore.ArchitectureTests;
public sealed class ModuleRules
{
private static readonly Architecture Architecture =
new ArchLoader().LoadAssemblies(typeof(CatalogModule).Assembly, typeof(OrderingModule).Assembly).Build();
// Clean çekirdeği: Ordering'in domain'i framework'leri ve slice'ları tanımaz
[Fact]
public void Ordering_domain_depends_on_nothing_outside_itself() =>
Types().That().ResideInNamespaceOrBelow("Bookstore.Ordering.Domain").Should()
.NotDependOnAny(Types(true).That()
.ResideInNamespaceOrBelow("Bookstore.Ordering.Features")
.Or().ResideInNamespaceOrBelow("Bookstore.Catalog")
.Or().ResideInNamespaceOrBelow("Microsoft.EntityFrameworkCore")
.Or().ResideInNamespaceOrBelow("Microsoft.AspNetCore"))
.Check(Architecture);
// Module'ler arası tek kapı: Ordering, Catalog'dan yalnızca Contracts'ı görür
[Fact]
public void Ordering_uses_only_catalog_contracts() =>
Types().That().ResideInNamespaceOrBelow("Bookstore.Ordering").Should()
.NotDependOnAny(Types().That().ResideInNamespaceOrBelow("Bookstore.Catalog")
.And().DoNotResideInNamespaceOrBelow("Bookstore.Catalog.Contracts"))
.Check(Architecture);
[Fact]
public void Slices_within_a_module_do_not_depend_on_each_other() =>
Slices().Matching("Bookstore.Ordering.Features.(**)").Should().NotDependOnEachOther().Check(Architecture);
}
Ordering'e Catalog'un SeedCatalog method'unu çağıran bir yönetim slice'ı eklediğimde derleme geçti, test kırıldı:
passed Bookstore.ArchitectureTests.ModuleRules.Ordering_domain_depends_on_nothing_outside_itself (81ms)
passed Bookstore.ArchitectureTests.ModuleRules.Slices_within_a_module_do_not_depend_on_each_other (1ms)
failed Bookstore.ArchitectureTests.ModuleRules.Ordering_uses_only_catalog_contracts (4ms)
ArchUnitNET.xUnitV3.FailedArchRuleException : "Types that reside in namespace with full name "Bookstore.Ordering" or below should not depend on any Types that reside in namespace with full name "Bookstore.Catalog" or below and do not reside in namespace with full name "Booksto..." failed:
Bookstore.Ordering.Features.Admin.ResetCatalogHandler does depend on "Bookstore.Catalog.CatalogModule"
Üç seviyenin her birinin kendi sorusu var. Module, "bu veri ve bu kurallar hangi ekibin?" sorusunu cevaplıyor; slice, "bu feature'ın kodu nerede?" sorusunu; domain çekirdeği ise "bu kural hiçbir koşulda bozulmasın" ihtiyacını. Module'ler arası event'ler, module başına şema ve module'ü ayrı bir servise çıkarma kararı ilerideki Modular Monolith serisinin konusu.
REPR ve FastEndpoints
Slice'ı endpoint seviyesinde düşünmenin bir adı da var: REPR (Request-Endpoint-Response). Steve "Ardalis" Smith'in adlandırdığı pattern, her endpoint'i bir request tipi, bir endpoint class'ı ve bir response tipi olarak ele alıyor; controller yok. Smith kendi ApiEndpoints library'sinin README'sinde bu yaklaşım için artık FastEndpoints'i öneriyor. FastEndpoints 8.3.0 (MIT) ile Bookstore'un sipariş slice'ı:
using FastEndpoints;
using FluentValidation;
using Microsoft.EntityFrameworkCore;
namespace Bookstore.Fe.Features.Orders.PlaceOrder;
public sealed record PlaceOrderRequest(int CustomerId, List<PlaceOrderLine> Lines);
public sealed record PlaceOrderLine(int BookId, int Quantity);
public sealed record PlaceOrderResponse(int OrderId, decimal Total);
public sealed class PlaceOrderValidator : Validator<PlaceOrderRequest>
{
public PlaceOrderValidator()
{
RuleFor(r => r.CustomerId).GreaterThan(0);
RuleFor(r => r.Lines).NotEmpty();
RuleForEach(r => r.Lines).ChildRules(line => line.RuleFor(l => l.Quantity).InclusiveBetween(1, 5));
}
}
// REPR: Request, Endpoint, Response; slice'ın tamamı tek class
public sealed class PlaceOrderEndpoint(BookstoreDbContext db) : Endpoint<PlaceOrderRequest, PlaceOrderResponse>
{
public override void Configure()
{
Post("/api/orders");
AllowAnonymous();
}
public override async Task HandleAsync(PlaceOrderRequest request, CancellationToken ct)
{
int[] ids = [.. request.Lines.Select(l => l.BookId).Distinct()];
Dictionary<int, decimal> prices = await db.Books.Where(b => ids.Contains(b.Id)).ToDictionaryAsync(b => b.Id, b => b.Price, ct);
if (prices.Count != ids.Length)
{
AddError(r => r.Lines, "Unknown book");
await Send.ErrorsAsync(cancellation: ct);
return;
}
Order order = new() { CustomerId = request.CustomerId, Total = request.Lines.Sum(l => prices[l.BookId] * l.Quantity) };
db.Orders.Add(order);
await db.SaveChangesAsync(ct);
await Send.OkAsync(new PlaceOrderResponse(order.Id, order.Total), ct);
}
}
POST /api/orders {"customerId":7,"lines":[{"bookId":1,"quantity":2},{"bookId":2,"quantity":1}]}
HTTP 200 {"orderId":1,"total":33.0}
POST /api/orders {"customerId":0,"lines":[{"bookId":1,"quantity":9}]}
HTTP 400 {"statusCode":400,"message":"One or more errors occurred!","errors":{"customerId":["'customer Id' must be greater than '0'."],"lines[0].quantity":["'quantity' must be between 1 and 5. You entered 9."]}}
POST /api/orders {"customerId":7,"lines":[{"bookId":42,"quantity":1}]}
HTTP 400 {"statusCode":400,"message":"One or more errors occurred!","errors":{"lines":["Unknown book"]}}
Kayıt Program.cs'te iki satır: AddFastEndpoints() ve UseFastEndpoints(); endpoint'leri ve validator'ları library kendisi buluyor, yani 02. yazıdaki IEndpoint taramasının hazır hâli. Kazandırdıkları: slice başına tek class, yerleşik FluentValidation entegrasyonu, Minimal API'ye yakın performans (projenin kendi iddiası). Bilmen gerekenler de var. Endpoint bir base class'tan türüyor ve Send.OkAsync, AddError gibi library'ye özgü bir dil getiriyor; vazgeçmek istediğinde her slice'a dokunursun. Varsayılan hata gövdesi RFC (Request for Comments) 9457 ProblemDetails değil, kendi ErrorResponse biçimi; paketin dokümanına göre UseFastEndpoints(c => c.Errors.UseProblemDetails()) ile değiştirilebiliyor. Bir de küçük sürpriz: validation mesajlarını ilk denemede Türkçe aldım, çünkü FluentValidation makinenin kültürünü (tr-TR) kullanıyor; yukarıdaki çıktı İngilizce kültürle tekrarlanmış hâli. Clean serisinde incelediğimiz ardalis/CleanArchitecture template'i de FastEndpoints kullanıyor; ekip REPR'ı sevdiyse makul bir varsayılan, ama Minimal API ile 02. yazıdaki 40 satırlık altyapı aynı işi bir library bağımlılığı olmadan yapıyor.
Layered Projeyi Kademeli Taşımak
Çoğu ekip VSA'ya sıfırdan değil, çalışan bir layered projeden geçiyor. Hepsini bir sprint'te taşımaya çalışmak yerine, yeni yapıyı eskisinin yanında büyütmek daha güvenli. Önce nereden başlayacağını ölç; 01. yazıdaki layered Bookstore'un git geçmişinde en çok değişen dosyalar:
$ git log --all --format= --name-only | grep . | sort | uniq -c | sort -rn | head -6
3 Services/OrderService.cs
3 Services/IOrderService.cs
3 Repositories/OrderRepository.cs
3 Repositories/IOrderRepository.cs
3 Endpoints/OrderEndpoints.cs
1 Program.cs
Gerçek bir projede bu liste uzun ve dağınık olur; tepesindeki dosyalar taşımanın başlayacağı yer. Plan:
- Altyapıyı kur:
Featuresklasörü,IEndpointtaraması ve slice test'leri için Testcontainers fixture'ı. Mevcut endpoint'ler olduğu gibi çalışmaya devam ediyor. - Bundan sonraki her yeni feature bir slice olarak yazılır; ortak service'e yeni method eklenmez.
- En sık değişen service'in method'larını tek tek slice'lara taşı; her taşımadan önce o davranış için slice test'i yaz, sonra service'ten method'u sil.
- Taşıma sırasında iki handler'da tekrarlanan kuralları entity'ye çıkar (04).
- Slice'ların birbirine bağımlı olmadığını ve eski service'lere yeni bağımlılık eklenmediğini architecture test'iyle koru; mevcut ihlalleri Clean serisinin son yazısındaki
FreezingArchRuleile dondur. - Bir service'in son method'u taşındığında service'i, interface'ini ve repository'sini sil; boşalan katman klasörlerini de.
Bu yaklaşımın güzelliği, hiçbir adımda uygulamanın durmaması: her sprint sonunda uygulama çalışıyor, eski ve yeni yapı yan yana ve hangi kodun hangi yapıda olduğu klasöründen anlaşılıyor. CRUD olarak kalan, kimsenin dokunmadığı eski kod ise taşınmak zorunda değil; değişmeyen kodun mimarisi kimseye maliyet çıkarmaz.
Sık Düşülen Tuzaklar
Mimariyi tek seferde değiştirmek. "Big bang" taşıma, aylar süren bir branch, yarım kalmış iki yapı ve donmuş bir feature akışı demek. Kademeli taşıma daha yavaş görünür ama her adımı geri alınabilir.
VSA'yı bölge ayrımı yapmadan dayatmak. Catalog'un CRUD slice'larıyla Ordering'in kurallı domain'i aynı muameleyi hak etmiyor. Bölge başına karar ver ve kararı yaz: ilerideki Architecture Decision'lar serisinde göreceğimiz ADR (Architecture Decision Record), "Ordering'de domain çekirdeği zorunlu, Catalog'da değil" gibi bir kararın doğal yeri.
Module sınırını yalnızca klasörle çizmek. Aynı project'te Modules/Catalog ve Modules/Ordering klasörleri açmak, bir developer'ın Catalog'un DbContext'ini Ordering'den kullanmasını engellemez. Ayrı project ve internal, ya da en azından bir architecture test'i gerekir.
REPR library'sini mimari sanmak. FastEndpoints ya da bir mediator, slice'ı yazmanın bir yolu; slice'lar arası bağımlılık, domain kuralları ve module sınırları yine senin kararın.
Seriyi Toparlamak
Altı yazıda tek bir fikri izledik: kodu teknik layer'lara göre değil, değişimin geldiği eksene, yani feature'lara göre gruplamak. 01'de aynı iki feature'ın layered kod tabanında beş, slice'lı kod tabanında bir dosyada çakıştığını gördük. 02'de slice'ı library'siz kurduk ve bir adlandırma convention'ının bütün endpoint'leri nasıl düşürebildiğini yakaladık. 03'te üç mediator'ı ölçtük: dispatch maliyetinin önemsiz, lisansın, Native AOT'nin (ahead-of-time derleme) ve kurulum tuzaklarının ise belirleyici olduğunu gördük. 04'te invariant'ları domain'e taşıdık ve slice'ın slice'ı çağırmasının ödemesi reddedilmiş bir siparişi nasıl kaydettiğini gördük. 05'te slice'ları gerçek SQL (Structured Query Language) Server veritabanına karşı test ettik ve InMemory provider'ın hem yanlış geçip hem yanlış kaldığını gördük. Bu yazıda da VSA'nın Clean Architecture'ın çekirdeği ve Modular Monolith'in sınırlarıyla nasıl birlikte çalıştığını kurduk. Akılda kalması gereken cümle: VSA bir klasör yapısı değil, coupling'in nerede yüksek nerede düşük olacağına dair bir karar; o kararı ayakta tutan şey de refactoring disiplini ve onu koruyan test'ler.
Küçük Bir Deneme
★☆☆ Kendi projende son altı ayın değişiklik sıcaklık haritasını git log ile çıkar. En çok değişen beş dosyanın kaçı birden fazla feature'ın ortak dosyası?
★★☆ Modular monolith örneğine bir Inventory module'ü ekle. PlaceOrder'ın stok kontrolü için Inventory'nin contract'ını kullanmasını sağla ve ModuleRules'a Inventory için aynı kuralı yaz.
★★★ FastEndpoints örneğinde hata gövdesini UseProblemDetails() ile ProblemDetails'e çevir ve 02. yazıdaki Minimal API slice'ının hata cevabıyla karşılaştır. İki API'yi aynı istemci kullanacak olsa hangi farklar sorun çıkarırdı?
Vertical Slice Architecture serisi burada bitiyor. Yazılım Mimarisi kategorisinde yol, bu yazıda kapısını araladığımız iki konuya uzanıyor: Order'ın aggregate olarak tasarlanması ve bounded context'ler için Domain-Driven Design, module sınırlarının ve module'ler arası iletişimin ayrıntısı için Modular Monolith.