Mert Özen Her Satırda Daha İleri
Backend .NET ile Mikroservis Uygulaması — #04

Ordering Service: Zengin Domain Model, Kendi Veritabanı ve Sipariş API'si

Mert Özen 14 Kas 2026 14 dk 6 görüntülenme
Ordering Service: Zengin Domain Model, Kendi Veritabanı ve Sipariş API'si

Ordering, Order aggregate'ini kendi veritabanında tutuyor, kitapların başlık ve fiyatını Catalog'a sormak yerine bir kez doldurulan yerel kopyasından okuyor ve müşterinin sepette gördüğü fiyat kopyayla tutmazsa siparişi 409 ile reddediyor. Catalog kapalıyken bile sipariş 201 ile oluşuyor; Guid v7'nin SQL Server'daki sırası, non-clustered primary key ve sipariş listesi için projection gerçek çıktılarla gösteriliyor.

Sürüm notu: Bu yazıdaki kodlar .NET 10, Aspire 13.6, Entity Framework Core 10, Asp.Versioning 10.2 ve xUnit.net v3 4.0 ile derlenip denenmiştir; veritabanı SQL Server 2025.

Önce Tahmin Et

Bu yazının sonunda Ordering service'i bir sipariş isteğini şu şekilde karşılıyor: kitapların başlığını ve fiyatını kendi veritabanındaki kopyadan okuyor, Order aggregate'ini kuruyor ve kaydediyor. İki soru. Birincisi: Catalog service'ini durdurup bir sipariş gönderirsen Ordering ne döner? İkincisi: müşteri sepette Dune'u 489 TL olarak gördü, ama o arada Catalog fiyatı 459 TL'ye indirdi ve Ordering'in kopyası güncellendi. İstek "unitPrice": 489.00 ile geliyor. Sipariş 489'dan mı oluşur, 459'dan mı, yoksa hiç oluşmaz mı? Cevapları yazının içinde gerçek HTTP cevaplarıyla görüyoruz.

Ordering'in Sınırı: Monolith'teki OrderService Neler Yapıyordu?

Sitedeki serilerin ortak örnek uygulaması Bookstore'un (bir online kitapçı) monolith'inde sipariş oluşturan OrderService.PlaceOrderAsync tek bir transaction'da beş iş yapıyordu: sepeti okumak, kitapları ICatalogService'ten almak, IInventoryService.ReserveAsync ile stok ayırmak, müşteriyi bulmak ya da oluşturmak, siparişi yazıp sepeti silmek. Tek veritabanında bu doğal; service'lere ayrıldığımızda her biri ayrı bir karar.

  • Sepet Ordering'de kalmıyor. 11. yazıda Angular sepeti istemci tarafında tutacak ve Ordering'e yalnız "şu kitaplar, şu adetler, şu fiyatlar" diye bir sipariş isteği gelecek.
  • Kitap bilgisi Catalog'a sorulmuyor; Ordering'in kendi kopyasından okunuyor. Bu yazıda kopyayı ilk kez dolduruyoruz, 06'da Catalog'un event'leriyle güncel tutacağız.
  • Stok rezervasyonu bu yazıda yok. 05'te Inventory'yi gRPC ile çağıracağız, 07'de bu adım checkout saga'sının parçası olacak.
  • Müşteri kaydı Ordering'in işi değil. Sipariş, alıcının adını ve e-postasını bir değer olarak taşıyor; 10. yazıda bu bilgi Keycloak'un verdiği token'dan gelecek.

Geriye kalan Ordering'in asıl işi: geçerli bir sipariş oluşturmak, kaydetmek ve listelemek. Sipariş bu yazının sonunda Pending durumunda kalıyor; ne stok ne ödeme kesinleşti. "Onaylandı" durumuna geçiş, 07'deki saga'nın stok ve ödeme sonuçlarını beklemesini gerektiriyor.

Order Aggregate'ini Service'e Almak

Domain-Driven Design (DDD) serisinin Bookstore Ordering Refactor yazısında, anemic bir CRUD (Create, Read, Update, Delete; yalnız kayıt ekleyip okuyan, güncelleyip silen) sipariş modelini adım adım zengin bir Order aggregate'ine dönüştürmüştük: factory method, korunan invariant'lar, value object'ler, domain event'ler. Burada aynı modeli service'e alıyoruz ve üç yerde service'in ihtiyacına göre değiştiriyoruz:

// Veritabanı değil Ordering üretir: id, sipariş kaydedilmeden önce de var
public readonly record struct OrderId(Guid Value)
{
    public static OrderId New() => new(Guid.CreateVersion7());

    public override string ToString() => Value.ToString();
}

public sealed record Money(decimal Amount, string Currency)
{
    public static Money Zero(string currency) => new(0m, currency);

    // Her satır iki ondalığa yuvarlanır, yarım değerler sıfırdan uzağa (Requirements Analysis serisi)
    public Money Times(int quantity) => new(Math.Round(Amount * quantity, 2, MidpointRounding.AwayFromZero), Currency);

    public static Money operator +(Money left, Money right) =>
        left.Currency == right.Currency
            ? new Money(left.Amount + right.Amount, left.Currency)
            : throw new DomainException("money.currency-mismatch", $"Cannot add {right.Currency} to {left.Currency}.");
}

// 10. yazıya kadar alıcı istekten gelir; sonra access token'dan
public sealed record Buyer(string Name, string Email);

Birinci değişiklik kimlik. Kitap artık BookId ile değil ISBN (International Standard Book Number; kitabın uluslararası kimlik numarası) ile tanınıyor, çünkü 01'de kararlaştırdığımız gibi service'ler arasında Catalog'un identity kolonu dolaşmıyor. Siparişin kimliği Guid.CreateVersion7(): zaman sıralı ve Ordering'in kendisi üretiyor. 05'te Inventory'ye rezervasyon isteği, 07'de saga mesajları bu kimlikle gidecek; siparişin veritabanına yazılmasını beklemeye gerek yok.

İkincisi durum. DDD serisindeki model Placed ile başlıyordu; burada Pending. İsim farkı küçük ama anlamı büyük: monolith'te sipariş yazıldığında stok da aynı transaction'da ayrılmış oluyordu. Burada henüz hiçbir şey kesin değil.

public static Order Place(Buyer buyer, IReadOnlyCollection<OrderLine> lines, TimeProvider clock)
{
    if (lines.Count == 0)
    {
        throw new DomainException("order.empty", "An order needs at least one item.");
    }

    DateTimeOffset now = clock.GetUtcNow();
    Order order = new()
    {
        Id = OrderId.New(),
        Buyer = buyer,
        PlacedAtUtc = now.UtcDateTime,
        Status = OrderStatus.Pending
    };
    foreach (OrderLine line in lines)
    {
        order.Add(line);
    }
    order.Total = order._items.Select(i => i.LineTotal).Aggregate((sum, lineTotal) => sum + lineTotal);

    // Onaylı değil Pending: stok ve ödeme henüz kesinleşmedi (checkout saga'sı, 07. yazı)
    order._domainEvents.Add(new OrderPlaced(order.Id, order.Total, order._items.Sum(i => i.Quantity), now));
    return order;
}

Üçüncüsü satır başına fiyat. OrderLine başlığı ve Money olarak birim fiyatı taşıyor; OrderItem bunları kendi alanlarına kopyalıyor ve satır toplamını hesaplıyor. Sipariş oluştuktan sonra Catalog'da fiyat ne olursa olsun siparişin satırı değişmiyor. Add method'u aynı kitap iki kez gelirse tek satırda birleştiriyor, kitap başına 10 adet sınırını ve tek para birimi kuralını koruyor. Invariant'ları bu yazıda yeniden anlatmıyoruz; DDD serisinin Always-Valid Domain Model yazısındaki yaklaşımın aynısı.

OrderPlaced şimdilik bir domain event: aggregate'in içinde üretiliyor, service'in dışına çıkmıyor. 06'da bir integration event'e çevrilip outbox ile RabbitMQ'ya gidecek. İkisinin farkı, domain event'in Ordering'in iç dilinde (örneğin OrderId ve Money tipleriyle), integration event'in ise Bookstore.Contracts'taki sade ve versiyonlanmış bir sözleşmeyle konuşması.

Aggregate'in kuralları veritabanına ihtiyaç duymadığı için onları birim testleriyle koruyoruz. Testlerden ikisi:

[Fact]
public void The_same_book_twice_becomes_one_line()
{
    Order order = Order.Place(Ada, [Dune(2), Dune(3)], Clock);

    OrderItem item = Assert.Single(order.Items);
    Assert.Equal(5, item.Quantity);
    Assert.Equal(new Money(2295.00m, "TRY"), item.LineTotal);
}

[Theory]
[InlineData(11)]
[InlineData(6, 5)]
public void More_than_ten_copies_of_a_book_are_rejected(params int[] quantities)
{
    DomainException error = Assert.Throws<DomainException>(
        () => Order.Place(Ada, [.. quantities.Select(q => Dune(q))], Clock));

    Assert.Equal("order.too-many-copies", error.Code);
}
dotnet test --project tests/Bookstore.Ordering.Tests

Test run summary: Passed!
  total: 8
  failed: 0
  succeeded: 8
  skipped: 0
  duration: 235ms

EF Core Mapping'i ve Guid v7'nin SQL Server'daki Sırası

Aggregate'i orderingdb'ye eşleyen Entity Framework Core (EF Core) kodu, DDD serisinin Aggregate'leri EF Core 10 ile Persist Etmek yazısındaki tekniklerle yazıldı: strongly typed id için value converter, Money ve Buyer için complex property, satırlar için backing field. Ayrıntılarını tekrar anlatmıyoruz; dikkat çeken iki satır var:

modelBuilder.Entity<Order>(order =>
{
    // Guid v7 zaman sıralı, ama SQL Server uniqueidentifier'ı önce son byte'larına göre sıralar:
    // üzerindeki clustered index yine de parçalanır. Clustered index'i sipariş zamanına koyuyoruz
    order.HasKey(o => o.Id).IsClustered(false);
    order.HasIndex(o => o.PlacedAtUtc).IsClustered();
    order.Property(o => o.Id).HasConversion(id => id.Value, value => new OrderId(value));
    order.Property(o => o.Status).HasConversion<string>().HasMaxLength(20);
    order.Property(o => o.CancellationReason).HasMaxLength(200);
    order.ComplexProperty(o => o.Buyer, buyer =>
    {
        buyer.Property(b => b.Name).HasMaxLength(200);
        buyer.Property(b => b.Email).HasMaxLength(256);
    });
    order.ComplexProperty(o => o.Total, MapMoney);
    order.Property<byte[]>("RowVersion").IsRowVersion();
    order.HasMany(o => o.Items).WithOne().HasForeignKey("OrderId").IsRequired().OnDelete(DeleteBehavior.Cascade);
    order.Navigation(o => o.Items).HasField("_items").AutoInclude();
    order.Ignore(o => o.DomainEvents);
});

Guid.CreateVersion7() zamanı GUID'in (Globally Unique Identifier; dünya genelinde benzersiz olması beklenen 128 bitlik kimlik) başına yazıyor; .NET'te ve string karşılaştırmasında art arda üretilen değerler sıralı. SQL Server ise uniqueidentifier'ları karşılaştırırken son altı byte'tan başlıyor ve v7'de bu kısım rastgele. Bunu SQL Server'da denedik: zaman damgaları sırayla 0, 1, 2, 3, 250 ve 1000 ms artan altı v7 değerini ORDER BY ile sıraladık:

CreatedOrder Id
------------ --
6 01A1DA2F-DF02-7442-B54A-211A9EA8DAC5
5 01A1DA2F-DC14-755D-9D84-5EDE13DD3884
3 01A1DA2F-DB1C-7011-8648-61B62C59078C
4 01A1DA2F-DB1D-7EB0-8101-67DDD7EB421C
1 01A1DA2F-DB1A-7FF0-896B-7014F772134B
2 01A1DA2F-DB1B-79D1-9F87-84E768C0A72D

Üretim sırası 1'den 6'ya, SQL Server'ın sırası 6, 5, 3, 4, 1, 2. Bu GUID'ler clustered index'in anahtarı olsaydı her yeni sipariş tablonun ortasına bir yere yazılırdı ve sayfa bölünmeleri artardı. Primary key'i non-clustered yapıp clustered index'i PlacedAtUtc'ye koyduk; yeni siparişler tablonun sonuna ekleniyor ve "son siparişler" sorgusu da aynı index'ten okuyor. GUID'i yine de istersen SQL Server'a uygun sıralı üretmenin yolları var (EF Core'un kendi sıralı GUID üreticisi gibi), ama o durumda id'yi aggregate'in içinde üretme kararından vazgeçmiş olursun.

RowVersion ise SQL Server'ın rowversion kolonu; siparişin aynı anda iki yerden değiştirilmesine karşı concurrency token. Bu yazıda sipariş yalnız oluşturuluyor; 07'de saga aynı siparişin durumunu değiştirirken bu kolon devreye girecek.

Kitapların Yerel Kopyası: İlk Doldurma

Ordering'in kitaptan ihtiyaç duyduğu dört şey var: ISBN, başlık, fiyat ve para birimi. Bir de 06'da işe yarayacak beşincisi: Catalog'daki satırın versiyonu.

// Ordering'in ihtiyaç duyduğu kitap verisinin kendi kopyası. Gerçeğin sahibi Catalog; bu kopya bir kez
// Catalog'un API'sinden doldurulur, sonra Catalog'un event'leriyle güncel tutulur (06. yazı)
public sealed class CatalogBook
{
    public required string Isbn { get; set; }
    public required string Title { get; set; }
    public decimal Price { get; set; }
    public required string Currency { get; set; }
    public int Version { get; set; }
    public DateTime UpdatedAtUtc { get; set; }
}

Event'ler yalnız değişiklikleri taşır; Ordering ilk açıldığında 40.000 kitabın hiçbirini bilmez. Microservices serisinin Data Ownership yazısındaki "Ordering'in kitap kopyası" bölümünde konuştuğumuz bu boşluğu bir ilk doldurma (backfill) kapatıyor. Doldurmayı 02'de kurduğumuz migrator'a koyduk: migrator migration'ları uyguladıktan sonra tablo boşsa Catalog'un sayfalı listesini okuyor. Catalog'a giden HttpClient typed client olarak kayıtlı ve adresi service discovery ile çözülüyor:

// Ordering yalnız ihtiyaç duyduğu alanları okur; Catalog'un cevabındaki diğer alanlar yok sayılır (tolerant reader)
public sealed record CatalogBookDto(string Isbn, string Title, decimal Price, string Currency, int Version);

public sealed record CatalogPage(IReadOnlyList<CatalogBookDto> Items, int Page, int PageSize, int TotalCount);

public sealed class CatalogClient(HttpClient http)
{
    public async IAsyncEnumerable<CatalogBookDto> GetAllBooksAsync(
        [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct)
    {
        const int pageSize = 50;
        for (int page = 1; ; page++)
        {
            CatalogPage? result = await http.GetFromJsonAsync<CatalogPage>(
                $"/api/v1/books?page={page}&pageSize={pageSize}", ct);
            if (result is null || result.Items.Count == 0)
            {
                yield break;
            }
            foreach (CatalogBookDto book in result.Items)
            {
                yield return book;
            }
            if (page * pageSize >= result.TotalCount)
            {
                yield break;
            }
        }
    }
}
// Yalnız migrator'ın ilk doldurması kullanır; çalışan service Catalog'u hiç çağırmaz
builder.Services.AddHttpClient<CatalogClient>(client => client.BaseAddress = new Uri("https+http://catalog"));

CatalogBookDto Catalog'un BookSummaryResponse'unun kopyası değil; Ordering'in ihtiyaç duyduğu alt küme. Yazar ve kategori gelse de okunmuyor, Catalog yarın yeni bir alan eklese de Ordering etkilenmiyor. Bir eksik vardı: 03'teki liste cevabında Version yoktu, yalnız detayda vardı. 40.000 kitap için 40.000 detay isteği atmak yerine Catalog'un liste cevabına Version alanını ekledik. Bir consumer'ın ihtiyacı contract'ı genişletti; 03'te kurduğumuz kontrol bunun kırıcı olmadığını söylüyor:

oasdiff breaking catalog-03.json src/Services/Catalog/openapi/catalog.json --fail-on ERR
No breaking changes to report, but the specs are different.

oasdiff changelog catalog-03.json src/Services/Catalog/openapi/catalog.json --level INFO
1 changes: 0 error, 0 warning, 1 info
info	[response-required-property-added] at src/Services/Catalog/openapi/catalog.json
	in API GET /api/v1/books
		added the required property `items/items/version` to the response with the `200` status

AppHost'ta Ordering için de bir migrator var; Catalog'a referansı yalnız migrator alıyor:

// Ordering'in migrator'ı yerel kitap kopyasını da bir kez, Catalog'un API'sinden doldurur
IResourceBuilder<ProjectResource> orderingMigrator = builder
    .AddProject<Projects.Bookstore_Ordering_Api>("ordering-migrator", options => options.ExcludeLaunchProfile = true)
    .WithArgs("--migrate")
    .WithEnvironment("CatalogCopy__ImportWhenEmpty", "true")
    .WithReference(orderingDb)
    .WithReference(catalog)
    .WaitFor(orderingDb)
    .WaitFor(catalog);

builder.AddProject<Projects.Bookstore_Ordering_Api>("ordering")
    .WithReference(orderingDb)
    .WaitForCompletion(orderingMigrator)
    .WithHttpHealthCheck("/health");

Çalışan ordering resource'unun Catalog'a referansı yok; çalışma zamanında Catalog'u bulamaz bile. Bağımlılık AppHost'ta açıkça görünüyor ve yalnız ilk açılışa ait. Migrator'ın log'u:

[ordering-migrator]       Applied 1 migration(s): 20261114131117_InitialCreate
[ordering-migrator]       Start processing HTTP request GET https+http://catalog/api/v1/books?*
[ordering-migrator]       Sending HTTP request GET http://localhost:5101/api/v1/books?*
[ordering-migrator]       Received HTTP response headers after 175.4248ms - 200
[ordering-migrator]       End processing HTTP request after 197.2066ms - 200
[ordering-migrator]       Imported 20 books from Catalog into the local copy
[ordering-migrator]       orderingdb is up to date in 962 ms

https+http://catalog adresi service discovery'de "önce https, yoksa http" anlamına geliyor; Aspire Catalog'un http endpoint'ini verdiği için istek http://localhost:5101'e gitti. Kopyadaki Dune'un fiyatı 459: 03'te değiştirdiğimiz fiyat, ilk doldurmada olduğu gibi geldi.

Command: Sipariş Oluşturmak ve Fiyatı Dondurmak

Yazma tarafı tek bir handler. Aggregate'in ihtiyaç duyduğu veriyi yüklüyor, kararı aggregate'e bırakıyor ve kaydediyor; başka bir service'e çağrı yok:

public async Task<PlaceOrderOutcome> HandleAsync(PlaceOrderRequest request, CancellationToken ct)
{
    string[] isbns = [.. request.Items.Select(i => i.Isbn).Distinct()];
    Dictionary<string, CatalogBook> books = await db.CatalogBooks.AsNoTracking()
        .Where(b => isbns.Contains(b.Isbn))
        .ToDictionaryAsync(b => b.Isbn, ct);

    string[] unknown = [.. isbns.Where(isbn => !books.ContainsKey(isbn))];
    if (unknown.Length > 0)
    {
        return new PlaceOrderOutcome.UnknownBooks(unknown);
    }

    // Fiyat burada donar: sipariş ya müşterinin onayladığı fiyatı alır ya da hiç oluşmaz
    PriceChange[] changes =
    [
        .. request.Items
            .Where(i => books[i.Isbn].Price != i.UnitPrice)
            .Select(i => new PriceChange(i.Isbn, i.UnitPrice, books[i.Isbn].Price))
    ];
    if (changes.Length > 0)
    {
        return new PlaceOrderOutcome.PricesChanged(changes);
    }

    Order order = Order.Place(
        new Buyer(request.CustomerName.Trim(), request.CustomerEmail.Trim().ToLowerInvariant()),
        [.. request.Items.Select(i => new OrderLine(
            i.Isbn, books[i.Isbn].Title, new Money(books[i.Isbn].Price, books[i.Isbn].Currency), i.Quantity))],
        clock);

    db.Orders.Add(order);
    await db.SaveChangesAsync(ct);

    // 06'da OrderPlaced outbox üzerinden bir integration event'e dönüşecek
    foreach (object domainEvent in order.DomainEvents)
    {
        logger.LogInformation("Domain event raised: {EventType} {@Event}", domainEvent.GetType().Name, domainEvent);
    }
    order.ClearDomainEvents();

    return new PlaceOrderOutcome.Placed(order);
}

İstekteki UnitPrice, müşterinin sepette gördüğü fiyat. Microservices serisinin İletişim Stilleri yazısında Bookstore için bir kural koymuştuk: siparişe yazılan fiyat, müşterinin gördüğü ve onayladığı fiyat. Kopya birkaç saniye eski olabilir, sepetteki fiyat dakikalarca eski olabilir; ikisi tutmuyorsa sipariş oluşmuyor ve müşteriye yeni fiyat gösteriliyor. Ne eski fiyattan sessizce satış yapıyoruz ne de müşteriye sormadan fiyatı değiştiriyoruz. Önce Tahmin Et'teki ikinci sorunun cevabı:

POST /api/v1/orders
{"customerName":"Ada Lovelace","customerEmail":"ada@example.com","items":[{"isbn":"9780441172719","quantity":1,"unitPrice":489.00}]}

HTTP/1.1 409 Conflict
Content-Type: application/problem+json
Date: Sat, 14 Nov 2026 13:12:11 GMT
Server: Kestrel
Transfer-Encoding: chunked
api-supported-versions: 1.0

{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.10","title":"Prices changed","status":409,"detail":"Some prices changed since the cart was shown. Confirm the new prices.","changes":[{"isbn":"9780441172719","cartPrice":489.00,"currentPrice":459.00}],"traceId":"00-13e059566ff9b43598302510d0471df6-3ee217c4ad6c69ac-03"}

Fiyat düşmüş olsa bile sipariş oluşmadı. Bunu bilinçli seçtik: fiyat artışını müşteriye sormamak açıkça yanlış, düşüşü sormamak ise "sepette gördüğüm fiyat başkaydı" şikâyetine yol açıyor. changes listesi Angular'ın yeni fiyatı gösterip tek tıkla onay almasına yetiyor. Doğru fiyatla gelen istek:

POST /api/v1/orders
{"customerName":"Ada Lovelace","customerEmail":"ada@example.com","items":[{"isbn":"9780441172719","quantity":1,"unitPrice":459.00},{"isbn":"9780141439587","quantity":2,"unitPrice":249.90}]}

HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Date: Sat, 14 Nov 2026 13:12:11 GMT
Server: Kestrel
Location: http://localhost:5102/api/v1/orders/01a1da2f-db1a-7113-afbf-ea42e9c1b208
Transfer-Encoding: chunked
api-supported-versions: 1.0

{"id":"01a1da2f-db1a-7113-afbf-ea42e9c1b208","status":"Pending","placedAtUtc":"2026-11-14T13:12:11.802652","customerName":"Ada Lovelace","customerEmail":"ada@example.com","items":[{"isbn":"9780441172719","title":"Dune","unitPrice":459.00,"quantity":1,"lineTotal":459.00},{"isbn":"9780141439587","title":"Emma","unitPrice":249.90,"quantity":2,"lineTotal":499.80}],"total":958.80,"currency":"TRY"}

Sipariş Pending; id'nin başındaki 01a1da2f-db1a, v7'nin milisaniye cinsinden zaman damgası. Katalogda olmayan bir ISBN ve aggregate'in reddettiği bir adet ise iki ayrı 400 dönüyor; ikincisinde domain'in hata kodu da ProblemDetails'in içinde:

{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"Unknown books","status":400,"detail":"Not in the catalog: 9780000000000.","traceId":"00-420631719b93f5f40344ef1e22b2bfdd-636691b255d50436-03"}
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"At most 10 copies of 9780441172719 per order.","status":400,"code":"order.too-many-copies","traceId":"00-e1139fbd85509c615471c5d1a30bc518-523ae287c80e8906-03"}

Catalog Kapalıyken Sipariş

Önce Tahmin Et'teki birinci soruyu Aspire CLI ile denedik: Catalog resource'unu durdurduk, Catalog'un artık cevap vermediğini gördük ve aynı anda bir sipariş gönderdik.

aspire resource catalog stop
Stopping resource 'catalog'...
✅ Resource 'catalog' stopped successfully.

curl -s -m 5 -o /dev/null -w "catalog: %{http_code}\n" http://localhost:5101/api/v1/books/9780441172719
catalog: 000

curl -s -w "\n%{http_code}\n" -X POST http://localhost:5102/api/v1/orders -H "Content-Type: application/json" -d '{"customerName":"Alan Turing","customerEmail":"alan@example.com","items":[{"isbn":"9780261102217","quantity":1,"unitPrice":403.00}]}'

{"id":"01a1da32-0868-7ade-a383-fc289c8c3b45","status":"Pending","placedAtUtc":"2026-11-14T13:14:34.472968","customerName":"Alan Turing","customerEmail":"alan@example.com","items":[{"isbn":"9780261102217","title":"The Hobbit","unitPrice":403.00,"quantity":1,"lineTotal":403.00}],"total":403.00,"currency":"TRY"}
201

Catalog'a giden istek 5 saniyede cevap alamadı (000), sipariş ise 201 ile oluştu. Monolith'te Catalog module'ü olmadan sipariş verilemezdi; burada Catalog'un deploy'u ya da arızası checkout'u etkilemiyor. Microservices serisinin İletişim Stilleri yazısında temporal coupling dediğimiz bağı bu kenarda kopardık. Bedeli, kopyanın Catalog kapalıyken güncellenmemesi; Catalog açıldığında 06'daki event'ler aradaki değişiklikleri getirecek.

Query: Sipariş Listesi İçin Projection

"Siparişlerim" sayfası aggregate'e ihtiyaç duymuyor: kurallar yok, değişiklik yok, yalnız birkaç kolon. Okuma tarafı ayrı bir sınıf ve aggregate'i hiç yüklemiyor:

// Okuma tarafı: aggregate yok, tracking yok, yalnız ekranın ihtiyaç duyduğu kolonlar
public async Task<IReadOnlyList<OrderSummaryResponse>> ListByCustomerAsync(string email, int page, int pageSize, CancellationToken ct)
{
    string normalized = email.Trim().ToLowerInvariant();
    var rows = await db.Orders.AsNoTracking()
        .Where(o => o.Buyer.Email == normalized)
        .OrderByDescending(o => o.PlacedAtUtc)
        .Skip((page - 1) * pageSize)
        .Take(pageSize)
        .Select(o => new
        {
            o.Id,
            o.Status,
            o.PlacedAtUtc,
            ItemCount = o.Items.Sum(i => i.Quantity),
            o.Total.Amount,
            o.Total.Currency
        })
        .ToListAsync(ct);

    return [.. rows.Select(r => new OrderSummaryResponse(r.Id.Value, r.Status.ToString(), r.PlacedAtUtc, r.ItemCount, r.Amount, r.Currency))];
}

Ara adımdaki anonymous type'ın bir sebebi var: OrderId bir value converter'la eşlendiği için o.Id.Value'yu SQL'e çeviremiyoruz; id'yi olduğu gibi okuyup Guid'e dönüşümü bellekte yapıyoruz. Complex property'ler (o.Buyer.Email, o.Total.Amount) ise doğrudan SQL'e çevriliyor. Tek veritabanında yazma tarafı için aggregate, okuma tarafı için projection: CQRS (Command Query Responsibility Segregation; yazma ve okuma modellerini ayırma) ve Event Sourcing serisinin ikinci yazısında anlattığımız ayrımın en basit hâli. Ayrı bir read model'e (örneğin sipariş ve ödeme durumunu birleştiren bir tabloya) ihtiyaç 07'den sonra doğarsa, aynı serinin Ayrı Read Model yazısındaki yol açık.

GET /api/v1/orders?customerEmail=ada@example.com

[{"id":"01a1da2f-db1a-7113-afbf-ea42e9c1b208","status":"Pending","placedAtUtc":"2026-11-14T13:12:11.802652","itemCount":3,"total":958.80,"currency":"TRY"}]

Müşteriyi query string'den almak yalnız bu yazıya özel; bir başkasının e-postasını yazan herkes onun siparişlerini görebiliyor. 10. yazıda müşteri kimliği token'dan gelecek ve bu parametre kalkacak.

Üretimde Dikkat

İlk doldurmayı tekrar çalıştırılabilir yap. Bizim migrator tablo boşsa dolduruyor. 40.000 kitapta sayfalama sırasında Catalog'da bir fiyat değişirse, o değişiklik kopyaya eski hâliyle girebilir; 06'daki event'ler bu farkı kapatacak, çünkü event'in versiyonu kopyadakinden büyük olacak. Yine de "kopyayı yeniden kur" komutu, kopyanın bozulduğundan şüphelendiğin gün işine yarar.

Kopyanın tazeliğini ölç. UpdatedAtUtc kolonu bunun için var. "En eski güncelleme kaç dakika önce?" sorusu bir metric olarak dashboard'a girmeli; Catalog'un event'leri durduğunda ilk bunu fark edersin.

Fiyat uyuşmazlığı oranını izle. 409 "Prices changed" cevaplarının sipariş isteklerine oranı, kopyanın ne kadar geride kaldığının dolaylı bir göstergesi. Kampanya günlerinde fiyatlar sık değişirken bu oranın artması normal; sakin bir günde artıyorsa event akışında bir sorun var demektir.

Sık Düşülen Tuzaklar

Her siparişte Catalog'a sormak. En kolay yol bu ve Catalog'u checkout'un ön koşulu yapıyor. Yukarıda Catalog kapalıyken sipariş verebildik; HTTP çağrısıyla bu mümkün olmazdı.

Fiyatı siparişe yazmamak. Siparişin satırında yalnız ISBN tutup fiyatı her gösterimde Catalog'dan okumak, geçmiş siparişlerin toplamını Catalog'daki bugünkü fiyata bağlar. Sipariş, fiyatın o anki fotoğrafını taşımalı.

Kopyayı Catalog'un tablosunun aynısı yapmak. Yazar, kategori, açıklama, yayın yılı... Ordering bunları kullanmıyor. Kopyaya giren her alan, Catalog'la Ordering arasında bir bağ daha demek.

Sipariş isteğini idempotent yapmamak. Ağ kesildiğinde istemcinin aynı isteği tekrar göndermesi bu yazıdaki API'de iki sipariş üretir. İstemcinin bir Idempotency-Key header'ı göndermesi ve Ordering'in aynı anahtarla gelen ikinci isteğe ilk siparişi dönmesi gerekiyor; mekanizmayı External Service Integration ve Resilience serisinin Idempotent Dış Çağrılar yazısında anlattık.

Küçük Bir Deneme

★☆☆ Catalog'da The Hobbit'in fiyatını değiştir, sonra Ordering'e eski fiyatla bir sipariş gönder. Ordering'in kopyası henüz güncellenmediği için ne oluyor? Bu davranış 06'dan sonra nasıl değişecek?

★★☆ Order'a bir RemoveItem(string isbn) method'u ekle: yalnız Pending siparişte çalışsın, son satırın silinmesine izin vermesin ve toplamı yeniden hesaplasın. Önce testlerini yaz.

★★★ POST /api/v1/orders'a Idempotency-Key desteği ekle: anahtarı ve sonucu ayrı bir tabloda sakla, aynı anahtarla gelen ikinci istekte ilk siparişi aynı 201 ve Location ile dön. Aynı isteği paralel iki kez gönderdiğinde tek sipariş oluştuğunu doğrula.

Ordering kendi verisiyle çalışıyor ve Catalog kapalıyken bile sipariş alıyor, ama stoğu hiç sormuyor: son kitabı iki kişiye birden satabilir. Bir sonraki yazıda Inventory service'ini gRPC ile kuruyoruz: inventory.proto contract'ı, Ordering'den deadline'lı rezervasyon çağrısı, aynı son kitaba gelen iki siparişte optimistic concurrency, rezervasyonun süresi ve Inventory yavaşladığında checkout'un ne yaptığı.