Mert Özen Her Satırda Daha İleri
Yazılım Mimarisi Vertical Slice Architecture — #01

Vertical Slice Architecture Nedir? Layer'a Değil Feature'a Göre Kod Organize Etmek

Mert Özen 7 Kas 2026 10 dk 4 görüntülenme
Vertical Slice Architecture Nedir? Layer'a Değil Feature'a Göre Kod Organize Etmek

Vertical Slice Architecture, kodu teknik layer'lara göre değil her request'in uçtan uca ihtiyacına göre dilimler; slice'lar arası coupling azalır, slice içi coupling artar. Aynı iki feature'ın layered Bookstore'da beş dosyada, slice'lı Bookstore'da tek dosyada conflict çıkardığını git çıktılarıyla görüyor, slice'ın anatomisini ve üç yaygın yanlış okumayı inceliyoruz.

Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir.

Önce Tahmin Et

Bookstore'un sipariş tarafı, Mimariye Giriş serisinin "Layered Architecture: Presentation, Business ve Data Layer'larının Gerçek Sorumlulukları" yazısındaki gibi katmanlı duruyor: endpoint'ler bir IOrderService çağırıyor, service bir IOrderRepository çağırıyor, repository Entity Framework Core (EF Core) ile veritabanına gidiyor. Bugün iki feature var: sipariş vermek ve sipariş detayını görmek.

Data/BookstoreDbContext.cs
Domain/Order.cs
Dtos/OrderDtos.cs
Endpoints/OrderEndpoints.cs
Mapping/OrderMapper.cs
Program.cs
Repositories/IOrderRepository.cs
Repositories/OrderRepository.cs
Services/IOrderService.cs
Services/OrderService.cs

Pazartesi sabahı iki developer iki ayrı iş alıyor. Ada sipariş iptalini (CancelOrder), Alan müşterinin siparişlerini listelemeyi (GetCustomerOrders) yazacak. İkisi birbirinin işine hiç dokunmuyor; farklı endpoint, farklı sorgu, farklı iş kuralı. İkisi de kendi branch'inde çalışıp gün sonunda merge edecek.

Tahmin et: bu iki birbirinden bağımsız feature merge edilirken kaç dosyada conflict çıkar? Sıfır mı, bir mi, daha fazla mı?

Bağımsız İki Feature, Beş Conflict

Her iki branch'in değişikliği:

$ git diff --stat main cancel-order
 Endpoints/OrderEndpoints.cs      |  2 ++
 Repositories/IOrderRepository.cs |  1 +
 Repositories/OrderRepository.cs  |  2 ++
 Services/IOrderService.cs        |  1 +
 Services/OrderService.cs         | 12 ++++++++++++
 5 files changed, 18 insertions(+)
$ git diff --stat main customer-orders
 Endpoints/OrderEndpoints.cs      | 2 ++
 Repositories/IOrderRepository.cs | 1 +
 Repositories/OrderRepository.cs  | 3 +++
 Services/IOrderService.cs        | 1 +
 Services/OrderService.cs         | 3 +++
 5 files changed, 10 insertions(+)

Ve merge:

$ git checkout cancel-order
$ git merge customer-orders
Auto-merging Endpoints/OrderEndpoints.cs
CONFLICT (content): Merge conflict in Endpoints/OrderEndpoints.cs
Auto-merging Repositories/IOrderRepository.cs
CONFLICT (content): Merge conflict in Repositories/IOrderRepository.cs
Auto-merging Repositories/OrderRepository.cs
CONFLICT (content): Merge conflict in Repositories/OrderRepository.cs
Auto-merging Services/IOrderService.cs
CONFLICT (content): Merge conflict in Services/IOrderService.cs
Auto-merging Services/OrderService.cs
CONFLICT (content): Merge conflict in Services/OrderService.cs
Automatic merge failed; fix conflicts and then commit the result.

Beş dosyanın beşinde de conflict. Kodun mantığında çakışan hiçbir şey yok; ikisi de aynı interface'in sonuna bir method, aynı class'ın sonuna bir implementasyon ekledi. Conflict'leri çözmek beş dakika sürer ama asıl mesaj başka: bu kod tabanında her feature, her layer'daki aynı ortak dosyaya dokunmak zorunda. IOrderService, siparişle ilgili ne varsa hepsinin buluştuğu bir darboğaz.

Bu yazı ve seri, bu darboğazı kaldırmanın bir yolunu anlatıyor: Vertical Slice Architecture (VSA). Kodu teknik layer'lara göre değil, her request'in uçtan uca ihtiyacına göre dilimlemek.

Bogard'ın Fikri: Coupling'i Değişim Ekseni Boyunca Kurmak

Terimi Jimmy Bogard 19 Nisan 2018'de yazdığı kısa bir blog yazısıyla adlandırdı. Bogard'ın çıkış noktası, layered ve concentric (Onion, Clean) mimarilerin uygulamayı teknik sorumluluklara göre yatay olarak kesmesi. Oysa bir uygulamaya gelen değişiklik talepleri yatay değil, dikey: "sipariş iptal edilebilsin" talebi endpoint'ten veritabanına kadar her layer'dan bir parça istiyor. Bogard'ın önerisi bu yüzden coupling'i değişimin geldiği eksen boyunca kurmak, yani her request'i (command ya da query) kendi başına bir slice yapmak. Yazının özeti tek cümle: "Minimize coupling between slices, and maximize coupling in a slice."

Bu cümlenin iki yarısı da önemli. Slice'lar arası coupling'i azaltmak, CancelOrder'ı değiştirirken GetCustomerOrders'ı kırmama ihtimalini artırıyor. Slice içi coupling'i artırmak ise, bir feature'ın endpoint'ini, request'ini, iş akışını ve sorgusunu yan yana tutmak demek; o feature'ı anlamak için beş klasörde gezmek yerine tek bir klasörü açarsın. Bogard'ın vurguladığı sonuç, yeni feature'ların büyük ölçüde yeni kod eklemek olması: ortak kodu değiştirip yan etkisinden endişelenmek yerine yeni bir klasör açıyorsun.

Fikir 2018'de birden ortaya çıkmadı. Steve Smith, MSDN (Microsoft Developer Network) Magazine'in Eylül 2016 sayısında, büyük MVC (Model-View-Controller) uygulamalarında Controllers, Views ve Models klasörlerinin yerine feature klasörleri öneren "Feature Slices for ASP.NET Core MVC" makalesini yazmıştı. .NET'in kendisinde de bu düşüncenin bir örneği var: Razor Pages. Microsoft Learn'ün tarifiyle bir Razor Page, sayfanın markup'ını ve OnGet, OnPost gibi handler method'larını taşıyan PageModel'i yan yana koyuyor; request bir controller'dan geçmeden doğrudan sayfaya geliyor. Yani ASP.NET Core, "bir ekran = bir klasördeki iki dosya" düzenini 2017'den beri yerleşik olarak sunuyor. VSA aynı düşünceyi API (Application Programming Interface) endpoint'lerine ve onların arkasındaki iş akışına taşıyor.

Bir Slice'ın Anatomisi

Aynı Bookstore'un sipariş tarafını slice'larla yazalım. Klasör yapısı:

Domain/Order.cs
Features/Orders/GetOrderDetails/GetOrderDetails.cs
Features/Orders/PlaceOrder/PlaceOrderEndpoint.cs
Features/Orders/PlaceOrder/PlaceOrderHandler.cs
Features/Orders/PlaceOrder/PlaceOrderRequest.cs
Infrastructure/BookstoreDbContext.cs
Infrastructure/IEndpoint.cs
Program.cs

Services, Repositories, Dtos ve Mapping klasörleri yok. Onların yerine Features/Orders/PlaceOrder klasörü, sipariş vermenin ihtiyaç duyduğu her şeyi taşıyor. Önce request ve response:

namespace Bookstore.Slices.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);

Sonra işi yapan handler. DbContext'i doğrudan alıyor; arada bir repository ya da service interface'i yok:

using Bookstore.Slices.Domain;
using Bookstore.Slices.Infrastructure;
using Microsoft.EntityFrameworkCore;

namespace Bookstore.Slices.Features.Orders.PlaceOrder;

public sealed class PlaceOrderHandler(BookstoreDbContext db)
{
    public async Task<PlaceOrderResponse> HandleAsync(PlaceOrderRequest request, CancellationToken ct)
    {
        List<int> ids = [.. request.Lines.Select(l => l.BookId)];
        Dictionary<int, decimal> prices = await db.Books.Where(b => ids.Contains(b.Id)).ToDictionaryAsync(b => b.Id, b => b.Price, ct);
        Order order = new()
        {
            CustomerId = request.CustomerId,
            Items = [.. request.Lines.Select(l => new OrderItem { BookId = l.BookId, Quantity = l.Quantity, UnitPrice = prices[l.BookId] })]
        };
        db.Orders.Add(order);
        await db.SaveChangesAsync(ct);
        return new PlaceOrderResponse(order.Id, order.Items.Sum(i => i.UnitPrice * i.Quantity));
    }
}

Ve HTTP (Hypertext Transfer Protocol) tarafı. Endpoint'i kendi class'ı tanımlıyor, Program.cs hangi route'ların olduğunu bilmiyor:

using Bookstore.Slices.Infrastructure;

namespace Bookstore.Slices.Features.Orders.PlaceOrder;

public sealed class PlaceOrderEndpoint : IEndpoint
{
    public void Map(IEndpointRouteBuilder app) =>
        app.MapPost("/orders", async (PlaceOrderRequest request, PlaceOrderHandler handler, CancellationToken ct) =>
        {
            PlaceOrderResponse response = await handler.HandleAsync(request, ct);
            return TypedResults.Created($"/orders/{response.OrderId}", response);
        });
}

IEndpoint tek method'luk bir interface; Program.cs açılışta assembly'deki bütün implementasyonları bulup Map'i çağırıyor. Ayrıntısını ve daha iyi bir hâlini bir sonraki yazıda kuracağız. Sipariş detayı ise bambaşka bir slice ve bambaşka bir şekle sahip:

using Bookstore.Slices.Infrastructure;
using Microsoft.EntityFrameworkCore;

namespace Bookstore.Slices.Features.Orders.GetOrderDetails;

public sealed record OrderDetails(int Id, string Status, decimal Total, int ItemCount);

// Sadece okuyan bir slice: handler class'ı yok, sorgu endpoint'in içinde
public sealed class GetOrderDetailsEndpoint : IEndpoint
{
    public void Map(IEndpointRouteBuilder app) =>
        app.MapGet("/orders/{id:int}", async (int id, BookstoreDbContext db, CancellationToken ct) =>
            await db.Orders
                .Where(o => o.Id == id)
                .Select(o => new OrderDetails(o.Id, o.Status, o.Items.Sum(i => i.UnitPrice * i.Quantity), o.Items.Count))
                .SingleOrDefaultAsync(ct) is { } details
                ? Results.Ok(details)
                : Results.NotFound());
}

Bu slice'ın handler'ı yok, mapper'ı yok, kendi response tipi var ve sorgusu EF Core projection ile yalnızca ihtiyaç duyduğu dört değeri çekiyor. Layered versiyondaki OrderDto ise sipariş vermenin de detay göstermenin de ortak dönüş tipiydi; birine bir alan eklemek ötekini de değiştiriyordu. Çalışırken:

POST /orders {"customerId":7,"lines":[{"bookId":1,"quantity":2},{"bookId":2,"quantity":1}]}
HTTP/1.1 201 Created
Location: /orders/1
{"orderId":1,"total":33.0}

GET /orders/1
HTTP/1.1 200 OK
{"id":1,"status":"Placed","total":33.0,"itemCount":2}

01-layers-vs-slices-tr.png

Aynı İki Feature, Slice'larla

Ada ve Alan aynı işleri bu sefer slice'lı kod tabanında yapıyor. Ada Features/Orders/CancelOrder klasörünü, Alan Features/Customers/GetCustomerOrders klasörünü açıyor:

$ git diff --stat main cancel-order
 Features/Orders/CancelOrder/CancelOrderEndpoint.cs | 10 ++++++++++
 Features/Orders/CancelOrder/CancelOrderHandler.cs  | 21 +++++++++++++++++++++
 Program.cs                                         |  2 ++
 3 files changed, 33 insertions(+)
$ git diff --stat main customer-orders
 .../GetCustomerOrders/GetCustomerOrdersEndpoint.cs        | 10 ++++++++++
 .../GetCustomerOrders/GetCustomerOrdersHandler.cs         | 15 +++++++++++++++
 Program.cs                                                |  2 ++
 3 files changed, 27 insertions(+)

Değişen satır sayısı daha fazla, çünkü her slice kendi sorgusunu ve kendi response'unu yazıyor. Ama değişen ortak dosya sayısı beşten bire indi. Merge:

$ git merge customer-orders
Auto-merging Program.cs
CONFLICT (content): Merge conflict in Program.cs
Automatic merge failed; fix conflicts and then commit the result.

Tek bir conflict kaldı ve sebebi öğretici:

  builder.Services.AddScoped<PlaceOrderHandler>();
++<<<<<<< HEAD
 +builder.Services.AddScoped<CancelOrderHandler>();
++=======
+ builder.Services.AddScoped<GetCustomerOrdersHandler>();
++>>>>>>> customer-orders

Her slice handler'ını Program.cs'e elle kaydettiği için orası yeni bir ortak nokta olmuş. Slice'ları bağımsız yapmak klasörleri ayırmakla bitmiyor; her slice'ın dokunmak zorunda olduğu her ortak dosya, bir sonraki conflict'in adresi. Bu satırları 02. yazıda assembly scan ile kaldırıyoruz, o zaman iki feature'ın merge'ü tamamen sessiz geçiyor.

Yeni gelen bir feature'ın ne kadar ortak koda dokunduğu, VSA'nın en kolay ölçülen faydası. Kendi projende bunu ölçmek için son birkaç feature branch'inin git diff --stat çıktısında kaç dosyanın birden fazla feature tarafından değiştirildiğine bakman yeterli.

Her Slice Kendi Yolunu Seçer

Bogard'ın yazısındaki ikinci fikir daha az konuşuluyor ama en az birincisi kadar önemli: her slice, request'i nasıl karşılayacağına kendisi karar verir. Martin Fowler'ın PoEAA (Patterns of Enterprise Application Architecture, 2002) kitabındaki dilde, bir slice transaction script olarak başlayabilir: request'i baştan sona karşılayan tek bir prosedür. GetOrderDetails tam olarak bu, hatta daha da azı: bir sorgu. PlaceOrderHandler da bugün bir transaction script; fiyatları çekiyor, siparişi kuruyor, kaydediyor.

Bogard'ın önerisi, uygulamanın tamamı için tek bir pattern seçmek yerine her slice'a basit başlayıp code smell gördükçe refactoring yapmak. CancelOrder'daki "yalnızca Placed durumundaki sipariş iptal edilebilir" kuralı bir gün ChangeShippingAddress slice'ında da gerekirse, o kural handler'lardan Order entity'sine taşınır ve PoEAA'nın domain model pattern'ine doğru bir adım atılmış olur. Aynı uygulamada bir slice düz bir SQL (Structured Query Language) sorgusu, öteki zengin bir domain model ile çalışabilir. Layered mimaride bu karar genellikle uygulama genelinde verilir: ya her şey service ve repository'den geçer, ya hiçbiri.

Bu esnekliğin bedeli de Bogard'ın yazısında açıkça var: yaklaşım, ekibin code smell'leri tanıyıp refactoring yapabildiğini varsayıyor. Bu beceri yoksa Bogard'a göre VSA muhtemelen senin için değil. Slice'lar arasında neyin paylaşılacağı ve domain logic'in nereye gideceği 04. yazının konusu.

Üç Yanlış Okuma

"VSA = MediatR." .NET dünyasında VSA örneklerinin çoğu MediatR ile yazıldığı için ikisi aynı şey sanılıyor. Bogard MediatR'ın yazarı olsa da 2018 tarihli yazıda MediatR geçmiyor; yukarıdaki slice'ların hiçbirinde de mediator yok. Mediator bir slice'ı uygulamanın yollarından biri; ne kazandırıp ne kaybettirdiği 03. yazıda.

"VSA = kod tekrarına serbestlik." Slice'ların birbirinden bağımsız olması, aynı iş kuralını on slice'a kopyalamak için izin değil. Bağımsız olması gereken şey request ve response'lar, sorgular ve akışlar; iş kuralları ise tekrarlandığı anda domain'e çıkar. Sorun, bu refactoring'i yapmayan ekiplerde VSA'nın hızla bir kopyala-yapıştır yığınına dönmesi.

"VSA, Clean Architecture'ın rakibi." İkisi farklı eksenlerde karar veriyor. Clean Architecture, dependency'lerin yönünü belirliyor: kaynak kod bağımlılıkları içe, domain'e bakar. VSA ise kodu hangi eksende gruplayacağını belirliyor: teknik layer yerine feature. Clean, Onion ve Hexagonal Architecture serisinin son yazısı "Clean Architecture Ne Zaman Fazla Gelir? Template Kültü, Interface Patlaması ve Pragmatik Yol" tam da bu birleşimle bitmişti: Domain çekirdeği ve Dependency Rule korunurken Application tarafının slice'lara bölünmesi. Bu hibriti 06. yazıda ayrıntılı kuracağız.

Sık Düşülen Tuzaklar

Klasörlerin adını değiştirip mimariyi değiştirdiğini sanmak. Features/Orders diye bir klasör açıp içine eski OrderService'i, IOrderRepository'yi ve OrderDto'yu taşımak bir feature folder'dır ama slice değildir. Test basit: yeni bir feature eklerken hâlâ ortak bir service class'ına method ekliyorsan, dosyalar nerede durursa dursun layered mimaridesin.

Slice'ı entity'ye göre kesmek. "Orders slice'ı", "Books slice'ı" diye düşünmek, slice'ı bir CRUD (create, read, update, delete) controller'ının klasörü yapar. Slice'ın birimi entity değil, kullanıcının bir isteği: PlaceOrder, CancelOrder, GetCustomerOrders. Bir entity üzerinde on farklı slice olabilir.

Gizli ortak noktaları gözden kaçırmak. Yukarıdaki Program.cs conflict'i küçük bir örnekti. Aynı rolü bir Constants.cs, tüm slice'ların kullandığı tek bir OrderResponse ya da her slice'ın bir satır eklediği bir switch ifadesi de oynayabilir. Slice'lar ancak dokunmak zorunda oldukları ortak dosyalar kadar bağımsızdır.

Her slice'ı sıfırdan, farklı bir üslupla yazmak. "Her slice kendi yolunu seçer" ilkesi, aynı işi her slice'ta farklı yapmaya izin değil. DbContext'e erişim, hata dönüşü, validation gibi ortak işlerin tek bir yolu olmalı; slice'ların serbest olduğu yer iş akışının karmaşıklığı. Bunun somut kurallarını bir sonraki yazıda koyacağız.

Ne Zaman Kullanma

Ekip refactoring'e ve code smell'lere yabancıysa, VSA'nın serbestliği kısa sürede tutarsızlığa dönüşür; önce bu beceri gelmeli. Domain'in çok zengin olduğu ve hemen her request'in aynı karmaşık kurallar kümesine dokunduğu bir uygulamada, slice'lar ince bir kabuk olarak kalır ve asıl iş domain modelde döner; VSA zararlı değildir ama kazancı da küçüktür. Birkaç endpoint'lik bir uygulamada ise hangi düzeni seçtiğin pek fark etmez. Ayrıntılı karar rehberi 06. yazıda.

Küçük Bir Deneme

★☆☆ Kendi projende son beş feature'ın pull request'lerini aç. Her birinde kaç dosya değişti, bunların kaçı başka feature'ların da değiştirdiği ortak dosyalardı? En çok dokunulan üç dosyayı yaz.

★★☆ Bu yazıdaki slice'lı Bookstore'a ChangeShippingAddress slice'ını ekle. "Yalnızca Placed durumundaki sipariş değiştirilebilir" kuralını yazarken CancelOrder'daki kuralı kopyalamak zorunda kaldın mı? Bu tekrarı nereye taşırdın?

★★★ Ada ve Alan senaryosunu kendi makinende iki branch'le tekrarla. Önce layered versiyonda, sonra slice'lı versiyonda merge et ve conflict sayılarını karşılaştır. Ardından handler kayıtlarını Program.cs'ten kaldırmanın bir yolunu bul ve slice'lı merge'ün tamamen sessiz geçtiğini gör.

Bir sonraki yazıda bu iskeleti gerçek bir slice'a çeviriyoruz: MediatR olmadan, Minimal API, handler class'ı ve otomatik endpoint kaydıyla. IEndpoint'in assembly scan'i, .NET 10'un yerleşik validation'ı, TypedResults ve handler'ı endpoint lambda'sına gömmenin neden kötü bir fikir olduğu orada.