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

Slice'lar Arası Paylaşım: Duplication mı Abstraction mı, Domain Logic Nereye?

Mert Özen 7 Kas 2026 11 dk 3 görüntülenme
Slice'lar Arası Paylaşım: Duplication mı Abstraction mı, Domain Logic Nereye?

Slice'lar arasında domain model, cross-cutting işler ve altyapı paylaşılır; request/response tipleri, sorgular ve iş akışı paylaşılmaz, invariant'lar ise ikinci kopyayı beklemeden entity'ye taşınır. Bookstore'da request'e konmuş bir kuralın 7 adetlik siparişe izin verdiğini, bir slice'ın başka bir slice'ı çağırınca ödemesi reddedilmiş siparişi kaydettiğini gerçek çıktılarla görüyor ve bunu ArchUnitNET slice test'iyle engelliyoruz.

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

Önce Tahmin Et

Bookstore'da bir kural var: bir siparişte aynı kitaptan en fazla 5 adet olabilir. 02. yazıda bu kuralı PlaceOrder slice'ının request'ine bir attribute olarak koymuştuk. Şimdi ekip, verilmiş bir siparişe kitap eklemeyi sağlayan AddOrderItem slice'ını yazıyor ve aynı yaklaşımı izliyor:

public sealed record PlaceOrderLine(int BookId, [Range(1, 5)] int Quantity);
using System.ComponentModel.DataAnnotations;
using Bookstore.Api.Domain;
using Bookstore.Api.Infrastructure;
using Microsoft.EntityFrameworkCore;

namespace Bookstore.Api.Features.Orders.AddOrderItem;

public sealed record AddOrderItemRequest(int BookId, [Range(1, 5)] int Quantity);

public sealed class AddOrderItemHandler(BookstoreDbContext db)
{
    public async Task<bool> HandleAsync(int orderId, AddOrderItemRequest request, CancellationToken ct)
    {
        Order? order = await db.Orders.Include(o => o.Items).SingleOrDefaultAsync(o => o.Id == orderId, ct);
        Book? book = await db.Books.FindAsync([request.BookId], ct);
        if (order is null || book is null)
            return false;

        if (order.Status != "Placed")
            throw new DomainException($"Order {order.Id} is {order.Status}.");

        OrderItem? existing = order.Items.Find(i => i.BookId == book.Id);
        if (existing is null)
            order.Items.Add(new OrderItem { BookId = book.Id, Quantity = request.Quantity, UnitPrice = book.Price });
        else
            existing.Quantity += request.Quantity;
        await db.SaveChangesAsync(ct);
        return true;
    }
}

Her iki request de 1 ile 5 arasında adet kabul ediyor. "Yalnızca Placed durumundaki sipariş değiştirilebilir" kontrolü de CancelOrder'daki satırın kopyası. Tahmin et: Ada 3 adet Dune ile sipariş verip ardından aynı siparişe 4 adet Dune eklerse ne olur?

Kural Request'te mi, Order'da mı?

POST /api/orders {"customerId":7,"lines":[{"bookId":1,"quantity":3}]}
HTTP 201 {"orderId":1,"total":37.5}
POST /api/orders/1/items {"bookId":1,"quantity":4}
HTTP 204
GET /api/orders/1
HTTP 200 {"id":1,"status":"Placed","lines":[{"bookId":1,"quantity":7}]}

Siparişte 7 adet Dune var. Her request kendi başına geçerliydi; kural ise hiçbir request'in değil, siparişin bir özelliği. Aynı açık PlaceOrder'ın kendisinde de var: aynı kitabı iki satırda (3 ve 4) gönderince 201 dönüyor ve sipariş iki ayrı satırda toplam 7 adetle kaydediliyor.

Burada iki farklı kavramı karıştırmışız. Input validation, tek bir request'in kendi içinde anlamlı olup olmadığını sorar: adet pozitif mi, liste boş mu. Invariant ise bir object'in her zaman doğru kalması gereken kuralıdır: bir siparişte aynı kitaptan 5'ten fazla olamaz, iptal edilmiş sipariş değişmez. Invariant'ı request'lere dağıtırsan her yeni slice onu yeniden yazmak zorunda kalır, birinin unutması da yeterli. VSA'da (Vertical Slice Architecture) slice'ların birbirinden bağımsız olması bu yüzden "her slice kendi kurallarını yazsın" demek değil.

Kuralı Entity'ye Taşımak

01. yazıda Bogard'ın önerisini görmüştük: slice transaction script olarak başlar, code smell göründüğünde domain'e doğru refactor edilir. İki slice'ta kopyalanmış bir durum kontrolü ve bir slice'ta unutulmuş bir kural, tam o code smell. Kurallar Order'a taşınıyor:

namespace Bookstore.Api.Domain;

public sealed class DomainException(string message) : Exception(message);

public enum OrderStatus { Placed, Paid, Cancelled }

public sealed class Order
{
    public const int MaxCopiesPerBook = 5;
    private readonly List<OrderItem> _items = [];

    private Order() { }

    public Order(int customerId, DateTime placedAtUtc)
    {
        CustomerId = customerId;
        PlacedAtUtc = placedAtUtc;
    }

    public int Id { get; private set; }
    public int CustomerId { get; private set; }
    public OrderStatus Status { get; private set; } = OrderStatus.Placed;
    public DateTime PlacedAtUtc { get; private set; }
    public IReadOnlyList<OrderItem> Items => _items;
    public decimal Total => _items.Sum(i => i.UnitPrice * i.Quantity);

    // Kural request'te değil burada: hangi slice çağırırsa çağırsın geçerli
    public void AddItem(int bookId, int quantity, decimal unitPrice)
    {
        EnsureStatus(OrderStatus.Placed);
        OrderItem? existing = _items.Find(i => i.BookId == bookId);
        int total = (existing?.Quantity ?? 0) + quantity;
        if (total > MaxCopiesPerBook)
            throw new DomainException($"At most {MaxCopiesPerBook} copies of book {bookId} per order.");

        if (existing is null)
            _items.Add(new OrderItem(bookId, quantity, unitPrice));
        else
            existing.Quantity = total;
    }

    public void MarkPaid()
    {
        EnsureStatus(OrderStatus.Placed);
        Status = OrderStatus.Paid;
    }

    public void Cancel()
    {
        EnsureStatus(OrderStatus.Placed);
        Status = OrderStatus.Cancelled;
    }

    private void EnsureStatus(OrderStatus expected)
    {
        if (Status != expected)
            throw new DomainException($"Order {Id} is {Status}.");
    }
}

Setter'lar private, item listesi dışarıya yalnızca okunabilir açılıyor; Entity Framework Core (EF Core) _items backing field'ını convention ile buluyor, ek configuration gerekmedi. Handler'lar artık kural bilmiyor, yalnızca akışı yönetiyor:

public async Task<bool> HandleAsync(int orderId, AddOrderItemRequest request, CancellationToken ct)
{
    Order? order = await db.Orders.Include(o => o.Items).SingleOrDefaultAsync(o => o.Id == orderId, ct);
    Book? book = await db.Books.FindAsync([request.BookId], ct);
    if (order is null || book is null)
        return false;

    order.AddItem(book.Id, request.Quantity, book.Price);
    await db.SaveChangesAsync(ct);
    return true;
}

Domain kuralı ihlali her slice'ta aynı HTTP (Hypertext Transfer Protocol) cevabına dönüşmeli; bu da paylaşılan, slice'tan bağımsız bir parça:

using Bookstore.Api.Domain;
using Microsoft.AspNetCore.Diagnostics;

namespace Bookstore.Api.Infrastructure;

// Paylaşılan cross-cutting iş: domain kuralı ihlali her slice'ta aynı cevaba dönüşür
public sealed class DomainExceptionHandler(IProblemDetailsService problemDetails) : IExceptionHandler
{
    public async ValueTask<bool> TryHandleAsync(HttpContext context, Exception exception, CancellationToken ct)
    {
        if (exception is not DomainException)
            return false;
        context.Response.StatusCode = StatusCodes.Status422UnprocessableEntity;
        return await problemDetails.TryWriteAsync(new ProblemDetailsContext
        {
            HttpContext = context,
            ProblemDetails = { Title = "Business rule violated", Detail = exception.Message, Status = 422 }
        });
    }
}

Aynı istekler şimdi:

POST /api/orders {"customerId":7,"lines":[{"bookId":1,"quantity":3}]}
HTTP 201 {"orderId":1,"total":37.5}
POST /api/orders/1/items {"bookId":1,"quantity":4}
HTTP 422 {"type":"https://tools.ietf.org/html/rfc4918#section-11.2","title":"Business rule violated","status":422,"detail":"At most 5 copies of book 1 per order."}
POST /api/orders/1/cancel
HTTP 204
POST /api/orders/1/items {"bookId":2,"quantity":1}
HTTP 422 {"type":"https://tools.ietf.org/html/rfc4918#section-11.2","title":"Business rule violated","status":422,"detail":"Order 1 is Cancelled."}
POST /api/orders {"customerId":7,"lines":[{"bookId":1,"quantity":3},{"bookId":1,"quantity":4}]}
HTTP 422 {"type":"https://tools.ietf.org/html/rfc4918#section-11.2","title":"Business rule violated","status":422,"detail":"At most 5 copies of book 1 per order."}

Request'lerdeki [Range(1, 5)] yerinde kaldı: o hâlâ geçerli bir input validation, yalnızca invariant'ın yerini tutmuyor. Bu küçük model, Martin Fowler'ın PoEAA (Patterns of Enterprise Application Architecture, 2002) kitabındaki domain model'e doğru atılmış ilk adım. Order'ın kendi tutarlılığından sorumlu bir aggregate olarak tasarlanması, value object'ler ve sınırlarının nasıl çizildiği ilerideki Domain-Driven Design serisinin konusu.

Neyi Paylaş, Neyi Paylaşma

Yukarıdaki refactoring, slice'lar arasında neyin ortak olması gerektiğine dair genel bir kuralın örneği. Paylaşılması gerekenler:

  • Domain model: entity'ler, value object'ler ve onların invariant'ları. Bir kural bir kez, ait olduğu object'te yazılır.
  • Cross-cutting işler: exception handling, ProblemDetails biçimi, validation altyapısı, logging, authorization policy'leri. Bunlar slice'ın iş akışından bağımsız.
  • Altyapı: tek bir DbContext ve dış sistem adapter'ları (IPaymentGateway gibi).

Listede bilerek olmayan iki şey var: repository'ler ve service class'ları. Bogard'ın 2018 tarihli yazısı, layered mimarinin bu paylaşılan abstraction'larının slice'larda büyük ölçüde gereksizleştiğini söylüyor; her slice DbContext'i doğrudan kullanıp kendi sorgusunu yazdığında, "sipariş verilerine erişim" diye ortak bir katmana ihtiyaç kalmıyor. Bir sorgu gerçekten birçok slice'ta aynı anlamla kullanılıyorsa ("aktif müşterinin açık siparişleri" gibi), onu IQueryable<Order> üzerinde küçük bir extension method olarak paylaşmak, tam bir repository katmanı kurmaktan çok daha hafif.

Paylaşılmaması gerekenler ise slice'ın kendi şekli: request ve response tipleri, sorgular, iş akışı. İki okuma slice'ının sorgusu bugün birbirine benziyor olabilir; biri sipariş listesi ekranı, öteki müşteri hizmetleri raporu için değişecek. Onları tek bir GetOrdersQuery'de birleştirmek iki farklı değişim sebebini tek bir yere bağlamak demek. Aynı şey "generic handler"lar için de geçerli: CrudHandler<TEntity> gibi bir abstraction, ilk farklı ihtiyaca kadar zaman kazandırır, sonra her slice'ın bir if eklediği yeni bir ortak dosyaya döner.

Ayrımı yapmanın pratik bir yolu, Don Roberts'a atfedilen ve Fowler'ın "Refactoring" kitabıyla yaygınlaşan rule of three: benzer kodu ilk seferde yaz, ikinci seferde tekrarı fark et, üçüncü seferde abstraction'a çevir. Bu kural tesadüfi benzerlikler (iki sorgunun aynı görünmesi) için iyi bir sabır ölçüsü. Ama invariant'lar için beklemek gerekmiyor: DRY (Don't Repeat Yourself) ilkesinin Hunt ve Thomas'taki asıl anlamı kod satırları değil bilgi tekrarı; "siparişte bir kitaptan en fazla 5 adet" bir bilgi ve ikinci kopyası ilk günden itibaren bir hata kaynağı. Bogard'ın uyarısı da burada önem kazanıyor: VSA, ekibin bu ayrımı görüp refactoring'i gerçekten yaptığını varsayıyor.

Tuzak: Slice'ın Slice'ı Çağırması

Ödeme alan bir Checkout slice'ı gerekiyor. "Sipariş vermeyi zaten bilen bir slice var" düşüncesiyle ilk versiyon onu çağırıyor:

using Bookstore.Api.Features.Orders.PlaceOrder;
using Bookstore.Api.Infrastructure;

namespace Bookstore.Api.Features.Checkout;

public sealed record CheckoutRequest(int CustomerId, List<PlaceOrderLine> Lines, string CardToken);

// "Tekrar etmeyelim": sipariş vermeyi zaten bilen slice'ı çağır
public sealed class CheckoutHandler(PlaceOrderHandler placeOrder, IPaymentGateway payments)
{
    public async Task<PlaceOrderResponse?> HandleAsync(CheckoutRequest request, CancellationToken ct)
    {
        PlaceOrderResponse? placed = await placeOrder.HandleAsync(new PlaceOrderRequest(request.CustomerId, request.Lines), ct);
        if (placed is null)
            return null;

        if (!await payments.ChargeAsync(request.CustomerId, placed.Total, request.CardToken, ct))
            return null;
        return placed;
    }
}

Ödeme sağlayıcısının yerine "declined" token'ını reddeden sahte bir adapter var. Reddedilen bir ödeme:

POST /api/checkout {"customerId":8,"lines":[{"bookId":2,"quantity":2}],"cardToken":"declined"}
HTTP 402 {"title":"Payment declined","status":402}
GET /api/orders/2
HTTP 200 {"id":2,"status":"Placed","lines":[{"bookId":2,"quantity":2}]}

Müşteriye "ödeme reddedildi" dedik ama sipariş veritabanında duruyor. Sebebi, PlaceOrderHandler'ın kendi SaveChangesAsync'ini çağırması: o slice kendi transaction sınırını çiziyor ve Checkout o sınırın dışında kalıyor. Başarılı ödemede de sipariş Placed olarak kalıyor, çünkü PlaceOrderHandler ödemeden haberdar değil. Ayrıca Checkout'un request'i PlaceOrderLine'a, response'u PlaceOrderResponse'a bağlandı; PlaceOrder'ın API'sine (Application Programming Interface) bir alan eklemek artık Checkout'u da değiştiriyor. Slice'ları bağımsız tutmanın bütün kazancı tek bir constructor parametresiyle geri verildi.

Paylaşılması gereken PlaceOrderHandler değil, onun kullandığı kurallardı. Düzeltilmiş Checkout, siparişi domain model ile kendisi kuruyor, ödemeyi alıyor ve tek bir SaveChangesAsync ile kaydediyor:

public async Task<CheckoutResponse?> HandleAsync(CheckoutRequest 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);

    // Paylaşılan şey PlaceOrderHandler değil, Order'ın kuralları
    Order order = new(request.CustomerId, clock.GetUtcNow().UtcDateTime);
    foreach (CheckoutLine line in request.Lines)
        order.AddItem(line.BookId, line.Quantity, prices[line.BookId]);

    if (!await payments.ChargeAsync(request.CustomerId, order.Total, request.CardToken, ct))
        return null;

    order.MarkPaid();
    db.Orders.Add(order);
    await db.SaveChangesAsync(ct);
    return new CheckoutResponse(order.Id, order.Total);
}
POST /api/checkout {"customerId":8,"lines":[{"bookId":2,"quantity":2}],"cardToken":"declined"}
HTTP 402 {"title":"Payment declined","status":402}
GET /api/orders/2
HTTP 404
POST /api/checkout {"customerId":8,"lines":[{"bookId":2,"quantity":2}],"cardToken":"tok_ok"}
HTTP 201 {"orderId":2,"total":16.0}
GET /api/orders/2
HTTP 200 {"id":2,"status":"Paid","lines":[{"bookId":2,"quantity":2}]}

Fiyatları çeken iki satır PlaceOrder'dakinin aynısı ve bu bilinçli bir tekrar: iki slice'ın sorgusu bugün aynı, yarın Checkout kampanya fiyatlarını da hesaba katmak isteyebilir. Bir slice'ın işi başka bir slice'ın sonucuna gerçekten tepki vermekse (sipariş verilince e-posta gönder gibi), doğru araç doğrudan çağrı değil bir domain event'tir; ilerideki Messaging ve Event-Driven Architecture serisi bunu outbox ile birlikte ele alacak.

Kuralı Test'le Korumak

"Slice'lar birbirine bağımlı olmaz" kuralını code review'a bırakmak yerine bir test'e çevirebilirsin. Clean, Onion ve Hexagonal Architecture serisinin "Clean Architecture'ı Test Etmek ve Korumak: Core Test'leri, Adapter Test'leri ve Architecture Test'leri" yazısında kullandığımız ArchUnitNET'in slice kuralları tam bu iş için:

using ArchUnitNET.Domain;
using ArchUnitNET.Loader;
using ArchUnitNET.xUnitV3;
using static ArchUnitNET.Fluent.Slices.SliceRuleDefinition;

namespace Bookstore.Api.ArchitectureTests;

public sealed class SliceTests
{
    private static readonly Architecture Architecture =
        new ArchLoader().LoadAssemblies(typeof(Program).Assembly).Build();

    // Features.<Alan>.<Slice> ya da Features.<Slice>: her slice yalnızca Domain ve Infrastructure'ı görür
    [Fact]
    public void Slices_do_not_depend_on_each_other() =>
        Slices().Matching("Bookstore.Api.Features.(**)").Should().NotDependOnEachOther().Check(Architecture);
}

(**) pattern'i Features'ın altındaki tam namespace'i slice kimliği yapıyor: Orders.PlaceOrder, Checkout gibi. Hatalı Checkout ile:

failed Bookstore.Api.ArchitectureTests.SliceTests.Slices_do_not_depend_on_each_other (88ms)
  ArchUnitNET.xUnitV3.FailedArchRuleException : "Slices matching "Bookstore.Api.Features.(**)" should not depend on each other" failed:
  	Checkout does depend on other slices:
  Checkout -> Orders.PlaceOrder
  	Bookstore.Api.Features.Checkout.CheckoutRequest -> Bookstore.Api.Features.Orders.PlaceOrder.PlaceOrderLine
  	Bookstore.Api.Features.Checkout.CheckoutHandler -> Bookstore.Api.Features.Orders.PlaceOrder.PlaceOrderHandler
  	Bookstore.Api.Features.Checkout.CheckoutHandler -> Bookstore.Api.Features.Orders.PlaceOrder.PlaceOrderRequest
  	Bookstore.Api.Features.Checkout.CheckoutHandler -> Bookstore.Api.Features.Orders.PlaceOrder.PlaceOrderResponse
  	Bookstore.Api.Features.Checkout.CheckoutEndpoint -> Bookstore.Api.Features.Orders.PlaceOrder.PlaceOrderResponse

Test yalnızca handler çağrısını değil, request ve response tiplerinin paylaşılmasını da yakaladı. Düzeltilmiş versiyonla test geçiyor. 03. yazıdaki mediator'lardan biriyle sender.Send(new PlaceOrderCommand(...)) yazmak da aynı ihlal: dependency handler'a değil command tipine taşınır ama test onu da görür.

Shared Klasörü Yeni Utils Olmasın

VSA projelerinin çoğunda zamanla bir Shared ya da Common klasörü çıkar ve kontrol edilmezse layered mimarideki Helpers ile Utils'in yerini alır: her slice'ın bir şey eklediği, kimsenin sahiplenmediği, her değişikliğin beklenmedik bir slice'ı kırdığı bir yer. Birkaç kural bunu önlüyor:

  • Ortak koda iş kuralı koyma; iş kuralı Domain'e, entity'nin kendisine gider.
  • Bir parçayı en az iki gerçek kullanıcısı olduğunda ortak yap; "ileride lazım olur" diye değil.
  • Klasörü teknik kategoriye değil kavrama göre adlandır: Shared/Extensions değil Infrastructure/Payments, Infrastructure/ProblemDetails.
  • Ortak bir parçayı değiştirmek, onu kullanan bütün slice'ların test'lerini koşmayı gerektirir; bu maliyeti bilerek öde.

Sık Düşülen Tuzaklar

Ortak response DTO'su (Data Transfer Object). Bütün sipariş slice'larının döndüğü tek bir OrderResponse, 01. yazıdaki layered OrderDto'nun geri dönüşü. En çok alan isteyen slice herkesin cevabını şişirir, bir alanın adını değiştirmek bütün istemcileri etkiler.

Refactoring'i ertelemek. "Şimdilik iki slice'a kopyalayalım" kararı, invariant'lar için genellikle bir sonraki bug'a kadar sürer. Yukarıdaki 7 adetlik sipariş, ikinci slice yazıldığı gün ortaya çıktı.

Her şeyi entity'ye taşımak. Domain model invariant'lar içindir; bir ekranın sorgusu, bir raporun gruplama mantığı ya da bir e-postanın metni Order'a ait değil. Okuma slice'ları çoğu zaman entity'yi hiç yüklemeden projection ile çalışır ve öyle kalmalı.

Slice'lar arası tepkiyi doğrudan çağrıyla kurmak. "Sipariş iptal edilince stok geri eklensin" gerekiyorsa, CancelOrder'ın stok slice'ının handler'ını çağırması yukarıdaki Checkout hatasının aynısı. Ya iki iş aynı transaction'da tek bir slice'ın sorumluluğundadır ya da biri ötekine bir event ile haber verir.

Küçük Bir Deneme

★☆☆ Transaction script versiyonunda aynı kitabı iki satırda gönderen bir PlaceOrder isteği at ve siparişin kaç adetle kaydedildiğine bak. Sonra domain model versiyonunda aynı isteği tekrarla.

★★☆ Order'a "sipariş toplamı 1000'i geçemez" kuralını ekle. Kaç dosyaya dokundun? Aynı kuralı transaction script versiyonunda eklemek için kaç handler'ı değiştirmen gerekirdi?

★★★ CancelOrder sonrasında stokların geri eklenmesi gereksin. Önce CancelOrder'dan bir RestockBooks handler'ını doğrudan çağıran versiyonu yaz ve ArchUnitNET test'inin ne dediğini oku. Sonra aynı işi Order'ın topladığı bir domain event ve SaveChangesAsync sonrasında onu işleyen bir handler ile yap.

Bu yazıda kuralları domain'e taşıdık ve slice'ları bir test'le birbirinden ayırdık. Bir sonraki yazıda slice'ların kendisini nasıl test edeceğimizi konuşuyoruz: slice başına integration test, gerçek bir SQL (Structured Query Language) Server veritabanıyla Testcontainers, test'ler arası izolasyon ve domain kuralları için saf unit test'ler.