Oyun Kitabı

Bir Vertical Slice İçeriden Nasıl Çalışır? (Bir Vertical Slice İceriden Nasil Calisir)

Bir Vertical Slice request'ten doğrulamaya, handler'dan aggregate'e, outbox event'inden read model'e ve bulut maliyetine kadar içeriden nasıl akar? CQRS…

Vertical Slice — Özellik Odaklı Mühendislik

Bolum 2 / 4

A vertical slice request pipeline from endpoint to handler, validation, persistence, event publication, and tests

30 saniyede özet

Bir HTTP isteği sisteme geldiğinde aslında tek bir metoda gitmez. Önce dış sınırdan içeri alınır, doğrulanır, yetki kontrolünden geçer, gerekirse transaction açılır, domain kuralı uygulanır, veri kaydedilir, event güvenceye alınır ve okuma tarafı güncellenir.

Vertical Slice bu yolculuğu tek bir kullanıcı niyeti etrafında organize eder.

HTTP Request
      |
      v
Endpoint / Controller
      |
      v
Mediator.Send(command)
      |
      v
Pipeline Behaviors
      |
      +--> Validation
      +--> Authorization
      +--> Logging / Metrics
      +--> Idempotency
      +--> Transaction
      v
Command Handler
      |
      v
Aggregate
      |
      v
Repository
      |
      v
Outbox
      |
      v
Database Commit

Bu akışın amacı kodu "dikey" göstermek değil; değişim, hata, test ve maliyet yüzeyini doğru feature sınırında tutmaktır.

Problem: küçük bir teslimat notu neden pahalı hale gelir?

Bir marketplace uygulamasında siparişe teslimat notu eklediğimizi düşünelim. İlk bakışta bu sadece bir string alandır. Fakat production'da aynı alan ödeme özetinde, satıcı panelinde, kargo entegrasyonunda, müşteri e-postasında ve denetim kaydında görünür.

AddDeliveryNote
  -> sipariş kuralı
  -> validasyon
  -> audit
  -> notification
  -> seller projection
  -> müşteri görünümü

Katmanlı yapıda bu değişiklik teknik rollere dağılır. Vertical Slice'ta soru değişir: Bu davranış hangi kullanıcı niyetine ait ve hangi tutarlılık sınırında tamamlanmalı?

Kavramlar ilk geçtiği yerde

Command
Sistemin durumunu değiştirmek isteyen niyettir. Örneğin AddDeliveryNote bir command'dir; başarılı olabilir, reddedilebilir veya hata alabilir.

Query
Sistemin durumunu değiştirmeden veri okuma isteğidir. Örneğin GetOrderSummary bir query'dir.

Handler
Bir command veya query'nin uygulama seviyesindeki akışını yöneten sınıftır. Handler orkestrasyon yapar; iş kuralının kalıcı sahibi olmamalıdır.

Pipeline Behavior
Handler'a ulaşmadan önce veya sonra çalışan ortak teknik adımdır. Validation, authorization, logging, idempotency ve transaction burada konumlanabilir.

Aggregate
Sistemde asla bozulmaması gereken iş kurallarını, yani invariant'ları, koruyan domain nesnesidir. Örneğin iptal edilmiş siparişe teslimat notu eklenememesi aggregate kuralıdır.

Read Model
Domain entity'nin kopyası değildir; bir ekranın veya API cevabının ihtiyaç duyduğu okuma şeklidir.

Projection
Event veya write model değişikliklerinden read model üreten süreçtir. Örneğin DeliveryNoteUpdated event'inden SellerOrderSummary read model'ini günceller.

Outbox
Veritabanı değişikliği ile event yayınını aynı local transaction içinde güvenceye alan kayıt desenidir.

CDC
Change Data Capture, veritabanı loglarından değişiklikleri okuyup dış sistemlere taşıma yaklaşımıdır. Domain event tasarımı yerine DB değişimini kaynak alır.

Bu parçalar birbirinin yerine geçmez. Handler domain kuralı değildir; domain davranışını çağırır. Pipeline business policy değildir; ortak uygulama güvenlik hattıdır. Outbox broker değildir; kaybolmaması gereken event'in transactional kaydıdır.

Gerçek hikaye: sipariş notu slice'ı

Bir AddDeliveryNote use case'i tasarlayalım. Kullanıcı siparişe teslimat notu eklemek ister. Sistem önce kullanıcının siparişi değiştirme hakkını kontrol eder, not uzunluğunu doğrular, aggregate üzerinde davranışı çalıştırır, değişikliği kaydeder ve diğer ekranların güncellenmesi için event üretir.

Features/Orders/AddDeliveryNote
  AddDeliveryNoteEndpoint.cs
  AddDeliveryNoteCommand.cs
  AddDeliveryNoteValidator.cs
  AddDeliveryNoteHandler.cs
  AddDeliveryNoteResponse.cs
  AddDeliveryNoteTests.cs

Bu dizin bir "mini katmanlar" mezarlığı olmamalıdır. Her dosyanın tek sorumluluğu vardır: endpoint HTTP sözleşmesini taşır, command niyeti ifade eder, validator giriş sınırını korur, handler akışı yönetir, aggregate iş kuralını uygular, test davranışı kanıtlar.

Algoritma tasarımı

Pseudocode seviyesinde slice şu şekilde çalışır:

algorithm AddDeliveryNote(command):
  input: orderId, customerId, note, requestId
  output: AddDeliveryNoteResult

  validate command
  reject if requestId was processed before

  order = orderRepository.load(orderId)
  reject if order does not exist
  reject if order.customerId != customerId

  order.addDeliveryNote(note)
  outbox.add(DeliveryNoteUpdated(orderId, note, occurredAt))

  transaction.commit()
  return success(orderId, note)

Zaman karmaşıklığı normal akışta O(1)'dir; çünkü tek aggregate kimlik üzerinden yüklenir, sabit sayıda kural çalışır ve sabit sayıda event yazılır. Eğer idempotency kontrolü unique index ile yapılırsa arama maliyeti pratikte O(log n), hash tabanlı bir cache veya key-value store ile ortalama O(1) olur. Bellek karmaşıklığı O(1)'dir; handler tüm sipariş geçmişini değil yalnızca gerekli aggregate durumunu ve command verisini taşır.

Neden problem oldu?

Sorun genellikle kodun uzunluğu değildir. Sorun, aynı davranışın birden fazla teknik katmanda yarım bilgiyle temsil edilmesidir.

Controller doğrular gibi yapar
Service iş kuralı gibi yapar
Repository veri kuralı gibi yapar
Frontend tekrar kontrol eder
Test mock sırasını korur

Bu modelde Single Responsibility görünürde vardır ama davranış seviyesinde yoktur. Her sınıf teknik olarak küçük olabilir; fakat iş kararı parçalanmıştır. Vertical Slice, SOLID'i sınıf isimlerinden davranış sahipliğine taşır. Interface Segregation burada IOrderService gibi şişkin bir sözleşme yerine IAddDeliveryNoteAuthorization, IOrderRepository ve IRequestDeduplicationStore gibi dar portlar kullanmak anlamına gelir.

CQRS ve Mediator pipeline'ı

Command ve query ayrımı slice'ın niyetini açık hale getirir.

public sealed record AddDeliveryNoteCommand(
    Guid OrderId,
    Guid CustomerId,
    string Note,
    string RequestId) : ICommand<AddDeliveryNoteResult>;

public sealed class AddDeliveryNoteHandler(
    IOrderRepository orders,
    IRequestDeduplicationStore deduplication,
    IOutboxWriter outbox,
    IUnitOfWork unitOfWork)
    : ICommandHandler<AddDeliveryNoteCommand, AddDeliveryNoteResult>
{
    public async Task<AddDeliveryNoteResult> Handle(
        AddDeliveryNoteCommand command,
        CancellationToken cancellationToken)
    {
        if (await deduplication.ExistsAsync(command.RequestId, cancellationToken))
        {
            return AddDeliveryNoteResult.AlreadyProcessed(command.OrderId);
        }

        var order = await orders.GetByIdAsync(command.OrderId, cancellationToken)
            ?? throw new OrderNotFoundException(command.OrderId);

        order.EnsureOwnedBy(command.CustomerId);
        order.AddDeliveryNote(command.Note);

        await outbox.AddAsync(
            DeliveryNoteUpdated.From(order),
            cancellationToken);

        await unitOfWork.CommitAsync(cancellationToken);
        return AddDeliveryNoteResult.Updated(order.Id, order.DeliveryNote);
    }
}

Mediator kullanımı burada amaç değil araçtır. Asıl değer, pipeline'ın doğrulama, authorization, transaction, logging ve idempotency gibi tekrar eden akışları handler'ın dışına taşımasıdır. Ancak kütüphane seçimi teknik performans kadar lisans, AOT uyumu, reflection maliyeti ve kurumsal onay süreciyle birlikte değerlendirilmelidir. Bir framework kararını kalıcı hale getirmeden önce lisans metni ve organizasyon politikası doğrulanmalıdır.

Query tarafı nasıl akar?

Command tarafı iş kuralı ve transaction ağırlıklıdır. Query tarafı ise okuma verimliliği ve kullanıcı deneyimi ağırlıklıdır. Bir sipariş listesini göstermek için çoğu zaman aggregate yüklemek istemeyiz; ekrana uygun dar bir read model yeterlidir.

GET /orders
      |
      v
Authorization
      |
      v
Cache
      |
      v
Read Database / Search Index
      |
      v
DTO Mapping
      |
      v
Response

Cache bu zincirde yalnızca performans aracı değildir; aynı zamanda maliyet kararıdır. Çok okunan, nadir değişen ve birkaç saniyelik gecikmeyi tolere eden veriler cache için uygundur. Ödeme durumu gibi güçlü tutarlılık bekleyen verilerde cache politikası çok daha dikkatli tasarlanmalıdır.

Alternatifler

Alternatif Güçlü yanı Kabul edilen bedel
Basit CRUD service En düşük başlangıç maliyeti Davranış büyüdükçe ortak service şişer
Vertical Slice, CQRS yok Feature yerelliği sağlar Okuma/yazma niyeti daha az görünür olabilir
Vertical Slice + CQRS Niyet, test ve pipeline ayrımı netleşir Daha fazla sınıf ve sözleşme disiplini gerekir
Fiziksel ayrılmış read/write model Okuma performansı ve ölçek bağımsızlığı Eventual consistency, outbox, projection ve operasyon maliyeti ekler
Mikroservis Bağımsız deploy ve ölçek Network, veri tutarlılığı, observability ve ekip maliyeti artar

Küçük CRUD yüzeyinde en doğru karar çoğu zaman Vertical Slice + basit handler'dır. Fiziksel CQRS veya mikroservis, ancak okuma/yazma yükü, ekip sahipliği ve hata izolasyonu bu maliyeti haklı çıkarıyorsa seçilmelidir.

Karar: önce mantıksal sınır, sonra fiziksel ayrım

Benim varsayılan kararım şudur: önce slice'ı uygulama içinde mantıksal olarak ayır; sonra ölçüm fiziksel ayrımı zorlarsa read model, broker, cache veya ayrı servis ekle.

Aşama 1: Modular monolith içinde slice
Aşama 2: Command/query ayrımı
Aşama 3: Outbox ve projection
Aşama 4: Ayrı read store veya cache
Aşama 5: Gerekirse ayrı deploy birimi

Bu sıra maliyeti erteler ama tasarımı ertelemez. Kod sınırı doğru çizildiyse, fiziksel ayrım bir yeniden yazım değil kontrollü çıkarma işlemi olur.

Veri ayrımı: logical ile physical aynı şey değildir

Read replica, CQRS değildir. Replica aynı şemayı farklı node'dan okutur; CQRS ise okuma modelini kullanım desenine göre yeniden şekillendirir.

Replica
  → aynı tablo
  → aynı join ihtiyacı
  → daha fazla okuma kapasitesi

Projection
  → ekran için hazırlanmış veri
  → daha az join
  → daha düşük latency ve I/O

Örneğin sipariş listesinde CityId üzerinden her seferinde join yapmak yerine, projection içine CityName yazmak bir denormalizasyon kararıdır. Bu karar yazma tarafında ek senkronizasyon maliyeti yaratır; okuma tarafında ise tek okuma ile ekranı besleyebilir.

Dual write, Outbox ve CDC

En tehlikeli akış şudur:

1. Siparişi veritabanına yaz
2. Broker'a event gönder
3. İkinci adımda sistem çöksün

Bu durumda sipariş vardır ama event yoktur. Kargo, bildirim veya projection geride kalır. Transactional Outbox bu boşluğu kapatır: iş verisi ve yayınlanacak event aynı local transaction içinde yazılır. Daha sonra publisher veya CDC tabanlı bir süreç outbox kaydını broker'a taşır.

Kriter Outbox CDC
Domain event kontrolü Uygulama belirler DB değişikliği belirler
Event niyeti Açık Dolaylı
DB şemasına bağımlılık Daha düşük Yüksek
Event içeriği İş diliyle tasarlanır Tablo/log yapısından etkilenir
Mikroservis uyumu Çok yüksek Duruma bağlı
Kurulum maliyeti Daha fazla uygulama kodu Daha fazla altyapı bilgisi

CDC güçlü bir araçtır; özellikle mevcut sistemlerden değişiklik akıtmakta değerlidir. Ancak domain event'in ne anlama geldiğini uygulama belirliyorsa Outbox daha açık bir sözleşme üretir.

At-least-once delivery tekrar teslimat üretebilir. Bu yüzden consumer idempotent olmalıdır.

DeliveryNoteUpdated event'i iki kez geldi
  → projection aynı version'ı gördü
  → ikinci işlem no-op oldu

Idempotency burada süs değildir; distributed sistemde veri tutarlılığının pratik sigortasıdır.

Bulut maliyeti: slice neden faturayı etkiler?

Vertical Slice yalnızca kod organizasyonu değildir; ölçek birimini de görünür kılar. Eğer ödeme yazma tarafı CPU yoğun, katalog okuma tarafı bellek ve cache yoğun ise ikisini aynı kaynak profiline zorlamak israf üretir.

Checkout command side
  → düşük concurrency
  → yüksek tutarlılık
  → transaction ve fraud kontrolü

Catalog query side
  → yüksek concurrency
  → cache ve projection
  → düşük latency beklentisi

Asimetrik yükler asimetrik kaynak ister. Ancak her cache, NAT, load balancer, queue, read store ve ayrı deploy birimi faturaya yeni kalem ekler. Bu yüzden mimari karar gerçek metriklerle verilmelidir: p95 latency, read/write oranı, connection saturation, egress, retry hacmi ve incident blast radius.

Performans ve memory analizi

Bir slice'ın sıcak yolu gereksiz allocation üretmemelidir. Request modelini domain entity'ye doğrudan çevirmek yerine, handler yalnızca ihtiyaç duyduğu alanları taşır. Query tarafında aggregate yüklemek yerine projection okumak CPU, bellek ve I/O maliyetini düşürür.

Command path
  → tek aggregate
  → kısa transaction
  → sabit event sayısı

Query path
  → ihtiyaca göre projection
  → minimum column set
  → pagination veya cursor

Listeleme query'si için karmaşıklık sayfa boyutu p ise O(p)'dir. Cursor pagination ile bellek O(p) kalır; tüm listeyi belleğe almak O(n) bellek tüketir ve production'da gereksiz risk oluşturur.

Test stratejisi

Vertical Slice testleri mock sayısını değil davranış güvenini optimize etmelidir.

  1. Validator testleri sınır değerlerini kanıtlar: boş not, maksimum uzunluk, yasak karakterler.
  2. Handler testleri başarılı akışı ve hata akışlarını doğrular: sipariş yok, sahiplik hatası, duplicate request.
  3. Domain testleri aggregate invariant'larını korur: iptal edilmiş siparişe not eklenemez.
  4. Integration testleri transaction ve outbox kaydının birlikte oluştuğunu kanıtlar.
  5. Architecture testleri bir slice'ın başka slice'ın internal sınıflarına bağımlı olmadığını denetler.
  6. Projection testleri event tekrar geldiğinde sonucun idempotent kaldığını gösterir.

Test piramidinin amacı her sınıfı izole etmek değildir; kararın bozulduğu noktayı hızlı bulmaktır.

Trade-off

Vertical Slice daha fazla küçük dosya üretir. Bu bilinçli bir bedeldir. Karşılığında değişim nedeni, test kapsamı ve rollback yüzeyi daha görünür hale gelir.

Yanlış uygulandığında ise iki risk doğar. Birincisi her slice'ın kendi mini-framework'ünü üretmesidir. İkincisi Shared klasörünün sınırsız büyüyerek eski katmanlı monolith'in yeni adı haline gelmesidir. Çözüm, dar interface'ler, açık portlar, mimari testler ve ADR ile karar kaydıdır.

Sık karıştırılanlar

❌ Vertical Slice = controller'dan repository'ye kadar her şeyi aynı klasöre koymak
✓ Kullanıcı niyetinin davranış, veri ve test sınırını birlikte tasarlamaktır.

❌ CQRS = mutlaka ayrı veritabanı
✓ CQRS önce niyet ayrımıdır; fiziksel ayrım ayrı bir maliyet kararıdır.

❌ Mediator = mimari
✓ Mediator yalnızca dispatch ve pipeline aracıdır; mimari sınırı feature sahipliği belirler.

❌ Outbox = exactly-once garantisi
✓ Outbox event kaybını önler; consumer yine idempotent olmalıdır.

❌ Read replica = projection
✓ Replica aynı modeli çoğaltır; projection okuma ihtiyacına göre yeni model üretir.

Karar kontrol listesi

  1. Slice tek bir kullanıcı niyetini mi temsil ediyor?
  2. Handler yalnızca application orchestration mı yapıyor, yoksa domain kuralı mı saklıyor?
  3. Command ve query aynı response modelini paylaşmak zorunda mı, yoksa ayrılmaları daha mı açık?
  4. Transaction sınırı tek aggregate veya bilinçli bir consistency boundary içinde mi kalıyor?
  5. Event yayını outbox ile veri kaydıyla aynı local transaction'a bağlandı mı?
  6. Consumer duplicate event aldığında idempotent kalıyor mu?
  7. Projection gerçekten ekran ihtiyacını azaltıyor mu, yoksa gereksiz operasyon maliyeti mi ekliyor?
  8. Cache, broker, read store ve ayrı deploy kararının p95 latency, maliyet veya blast radius kanıtı var mı?
  9. Interface'ler dar mı, yoksa IOrderService gibi her use case'i taşıyan bir torbaya mı dönüştü?
  10. Mimari testler feature sınırlarını derleme veya CI aşamasında koruyor mu?

Bu soruların cevabı net değilse daha fazla altyapı eklemek yerine slice sınırını sadeleştirmek genellikle daha ucuzdur.

Bu yazıdan aklında ne kalmalı

  1. Bir Vertical Slice request'ten teste kadar tek kullanıcı niyetinin davranış sınırıdır.
  2. CQRS, Mediator, Outbox ve Projection aynı karar değildir; her biri farklı maliyet ve güvenlik problemi çözer.
  3. Önce mantıksal feature sınırını doğrulamak, fiziksel ayrım maliyetini kontrollü hale getirir.
  4. Distributed sistemde outbox event kaybını azaltır; idempotency ise tekrar teslimatın zarar vermesini engeller.
  5. Bulut maliyeti mimarinin dışında değildir; her sınır compute, I/O, network ve operasyon kalemine dönüşür.

Uçtan uca akış

POST /orders
        |
        v
Endpoint / Controller
        |
        v
Mediator.Send()
        |
        v
Validation
        |
        v
Authorization
        |
        v
Transaction
        |
        v
Command Handler
        |
        v
Aggregate
        |
        v
Repository
        |
        v
Outbox
        |
        v
Commit
        |
        v
Broker / Kafka
        |
        v
Projection
        |
        v
Read Database
        |
        v
GET /orders
        |
        v
Query Handler
        |
        v
Frontend

Vertical Slice'in olgun hali, kodu düzenlemekten çok değişimin ekonomiğini yönetir.

Bir sonraki bölümde Vertical Slice, DDD aggregate ve modular monolith sınırlarının production'da nasıl birlikte korunacağını inceleyeceğiz.

SSS

Sık sorulan sorular

"Vertical Slice = controller'dan repository'ye kadar her şeyi aynı klasöre koymak" doğru mu?

Kullanıcı niyetinin davranış, veri ve test sınırını birlikte tasarlamaktır.

"CQRS = mutlaka ayrı veritabanı" doğru mu?

CQRS önce niyet ayrımıdır; fiziksel ayrım ayrı bir maliyet kararıdır.

"Bir Vertical Slice İçeriden Nasıl Çalışır?" ne anlatıyor?

Bir Vertical Slice request'ten doğrulamaya, handler'dan aggregate'e, outbox event'inden read model'e ve bulut maliyetine kadar içeriden nasıl akar? CQRS…

Ogrenilen Muhendislik Prensipleri

  • Bir slice'ın gerçek sınırı klasör değil, aynı nedenle değişen kullanıcı niyeti ve tutarlılık kararıdır.
  • CQRS ve outbox fiziksel ayrım zorunluluğu değil, niyet ve güvenilirlik maliyetini açık hale getiren araçlardır.
  • Production mimarisi kod organizasyonu kadar p95 latency, I/O, egress, retry ve rollback maliyetiyle de ölçülmelidir.

Okumaya devam et

Okumaya devam et

Seride sonraki yazi

Ilgili yazilar

Ilgili yazilar

Paylaş