Mert Özen Her Satırda Daha İleri
Yazılım Mimarisi Clean, Onion ve Hexagonal Architecture — #07

Persistence Ignorance ve Infrastructure Adapter'ları: EF Core, HttpClient ve Dış Servisler

Mert Özen 7 Kas 2026 14 dk 4 görüntülenme
Persistence Ignorance ve Infrastructure Adapter'ları: EF Core, HttpClient ve Dış Servisler

Persistence ignorance, domain class'larının hangi veritabanına ve ORM'e yazıldığından habersiz kalmasıdır; EF Core'da mapping'i IEntityTypeConfiguration ile Infrastructure'da tutarsın, ama constructor binding ve getter-only property'ler gibi kurallar domain'in şeklini yine de etkiler. Kargo firmasının API'sini typed HttpClient ile bir driven adapter'a sarmak, dış modeli içeri sızdırmamak ve generic repository ile IQueryable tuzağı.

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

Önce Tahmin Et

05. yazıda Domain'e koyduğumuz Book class'ı şöyle:

namespace Bookstore.Domain.Catalog;

public sealed class Book(int id, string title, decimal price, int stock)
{
    public int Id { get; } = id;
    public string Title { get; } = title;
    public decimal Price { get; } = price;
    public int Stock { get; private set; } = stock;

    public bool TryReserve(int quantity)
    {
        if (quantity <= 0 || quantity > Stock) return false;
        Stock -= quantity;
        return true;
    }
}

Ne parametresiz bir constructor var, ne [Key] attribute'u, ne de Title ve Price için bir setter. Domain project'inin .csproj'unda Entity Framework Core (EF Core) paketi de yok. Infrastructure'daki BookConfiguration'da ise ilk bakışta gereksiz görünen iki satır duruyor:

internal sealed class BookConfiguration : IEntityTypeConfiguration<Book>
{
    public void Configure(EntityTypeBuilder<Book> builder)
    {
        builder.HasKey(b => b.Id);
        builder.Property(b => b.Id).ValueGeneratedNever();
        builder.Property(b => b.Title).HasMaxLength(200);
        builder.Property(b => b.Price);
        builder.Property(b => b.Stock).IsConcurrencyToken(); // iki müşteri aynı son kopyayı alamasın
    }
}

builder.Property(b => b.Title) ve builder.Property(b => b.Price) hiçbir ayar yapmıyor gibi. İlk soru: bu iki satırı silersen ne olur?

İkinci soru. Ekipten biri, "her entity için ayrı repository yazmayalım" diyerek generic bir repository önermiş:

public interface IRepository<T> where T : class
{
    IQueryable<T> Query();
    void Add(T entity);
}

public sealed class EfRepository<T>(DbContext db) : IRepository<T> where T : class
{
    public IQueryable<T> Query() => db.Set<T>();
    public void Add(T entity) => db.Set<T>().Add(entity);
}

Raporlama ekranı için Application'da 100 TL'nin üzerindeki siparişleri çeken şu satır yazılmış:

// Application'da yazılmış, derlenen ama çalışmayan bir sorgu
List<Order> bigOrders = await repository.Query().Where(o => o.Total > 100m).ToListAsync();

Kod derleniyor. Çalışınca ne olur?

Persistence Ignorance Ne Demek?

İkinci sorunun cevabını sona saklayıp birinciyle başlayalım. Bir domain class'ının hangi veritabanına, hangi ORM'e (object-relational mapper) yazılacağından habersiz olmasına persistence ignorance diyoruz. Microsoft'un "Architect Modern Web Applications with ASP.NET Core and Azure" e-kitabının mimari prensipler bölümü terimi şöyle tanımlıyor: kalıcı hale getirilmesi gereken ama kodu persistence teknolojisinin seçiminden etkilenmeyen tipler. Aynı bölüm, bu prensibi ihlal eden tipik durumları da sıralıyor. Bookstore'un Domain'ini o listeye göre kontrol edelim:

İhlalBookstore Domain'inde
Zorunlu bir base classYok; Order ve Book hiçbir şeyden türemiyor
Zorunlu bir interface implementasyonuYok
Kendini kaydeden class (Active Record)Yok; kaydetmek IUnitOfWork'ün işi
Zorunlu parametresiz constructorYok; EF Core 05'teki constructor'ları kullanıyor
virtual olması gereken property'lerYok; lazy loading proxy kullanmıyoruz
Persistence'a özel attribute'larYok; mapping tamamen Infrastructure'da, Domain'in .csproj'u paket referansını build'de reddediyor

Altı maddenin altısından da temiz çıkıyoruz. Ama bu, EF Core'un domain'in şekline hiç karışmadığı anlamına gelmiyor. Bu yazının konusu o görünmeyen anlaşma: Domain'in EF Core'dan habersiz kalabilmesi için Infrastructure'ın neleri bilmesi gerektiği ve anlaşmanın nerede bittiği.

Mapping Infrastructure'da: IEntityTypeConfiguration

Domain'in bilmediği her şey Infrastructure'daki configuration class'larında, EF Core'un Fluent API'siyle (Application Programming Interface) yazılmış halde. EF Core serisinin "EF Core'da Data Annotation mı Fluent API mı? OnModelCreating ve IEntityTypeConfiguration" yazısında attribute'lar yerine Fluent API'yi seçmenin gerekçesini konuşmuştuk; Clean Architecture'da bu bir tercih olmaktan çıkıyor, zorunluluk oluyor. 05'teki OrderConfiguration:

using Bookstore.Domain.Ordering;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;

namespace Bookstore.Infrastructure.Persistence.Configurations;

// Mapping Infrastructure'da; Domain'deki Order hiçbir EF Core attribute'u taşımıyor
internal sealed class OrderConfiguration : IEntityTypeConfiguration<Order>
{
    public void Configure(EntityTypeBuilder<Order> builder)
    {
        builder.HasKey(o => o.Id);
        builder.Property(o => o.CustomerEmail).HasMaxLength(256);
        builder.Property(o => o.PostalCode).HasMaxLength(10);
        builder.HasIndex(o => o.CustomerEmail);
        builder.Ignore(o => o.Subtotal);
        builder.Ignore(o => o.Total);
        builder.OwnsMany(o => o.Items, items =>
        {
            items.ToTable("OrderItems");
            items.WithOwner().HasForeignKey("OrderId");
            items.Property<int>("Id");
            items.HasKey("Id");
        });
        builder.Navigation(o => o.Items).HasField("_items");
    }
}

Burada domain'e dokunmadan çözülen üç problem var. Birincisi kalemlerin kimliği. OrderItem bir value object gibi davranıyor; domain açısından "1 adet Dune, 250 TL" bilgisinin bir Id'ye ihtiyacı yok. Veritabanının ise bir primary key'e ihtiyacı var. items.Property<int>("Id") ve HasForeignKey("OrderId") iki shadow property tanımlıyor: tabloda kolon olarak var, C# class'ında yok.

İkincisi private liste. HasField("_items"), EF Core'a kalemleri doldururken Items property'sini değil _items field'ını kullanmasını söylüyor. Böylece domain dışarıya salt okunur bir liste verirken EF Core yine de içini doldurabiliyor. EF Core aslında _items adındaki field'ı convention gereği bu satır olmadan da buluyor; satır kuralı açık hale getiriyor ve field'ın adı bir gün değişirse EF Core model'i oluştururken açık bir hatayla duruyor.

Üçüncüsü hesaplanan değerler. Subtotal ve Total domain'in hesapladığı değerler; Ignore ile tabloya girmiyorlar. Enum'ları metin olarak saklamak, strongly typed Id ve kendi value converter'larını yazmak EF Core serisinin "EF Core'da Owned Type ve Value Converter: Adres, Enum ve Strongly Typed Id" yazısında; burada önemli olan, dönüşümün nerede durduğu. EF Core'un SQLite için ürettiği şema:

CREATE TABLE "Books" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_Books" PRIMARY KEY,
    "Title" TEXT NOT NULL,
    "Price" TEXT NOT NULL,
    "Stock" INTEGER NOT NULL
);
CREATE TABLE "Orders" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_Orders" PRIMARY KEY AUTOINCREMENT,
    "CustomerEmail" TEXT NOT NULL,
    "PostalCode" TEXT NOT NULL,
    "ShippingFee" TEXT NOT NULL
);
CREATE TABLE "OrderItems" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_OrderItems" PRIMARY KEY AUTOINCREMENT,
    "BookId" INTEGER NOT NULL,
    "UnitPrice" TEXT NOT NULL,
    "Quantity" INTEGER NOT NULL,
    "OrderId" INTEGER NOT NULL,
    CONSTRAINT "FK_OrderItems_Orders_OrderId" FOREIGN KEY ("OrderId") REFERENCES "Orders" ("Id") ON DELETE CASCADE
);

OrderItems tablosunda Id ve OrderId kolonları var, C#'ta yok. Subtotal ve Total hiçbir yerde. Bir ayrıntı daha: SQLite'ın decimal tipi olmadığı için Price ve ShippingFee TEXT olarak saklanıyor. Domain bundan da habersiz; bunun neden önemli olduğuna 08. yazıda döneceğiz.

Bu yazıda BookConfiguration'a da tek bir satır ekledik: Stock'u concurrency token yapan satır. İki müşteri aynı son kopyayı aynı anda isterse ikincisinin UPDATE'i hiçbir satırı etkilemez ve EF Core DbUpdateConcurrencyException fırlatır (EF Core serisinin "EF Core'da Concurrency: Token, rowversion ve DbUpdateConcurrencyException" yazısı). Bu bir iş kuralı gibi görünse de nasıl uygulandığı tamamen bir persistence kararı; Domain'de tek satır değişmedi.

Persistence Ignorance'ın Bittiği Yer

Şimdi ilk sorunun cevabı. EF Core bir satırı object'e çevirirken bir constructor çağırmak zorunda. Parametresiz constructor yoksa parametreli olanı kullanıyor, ama yalnızca parametre adları map edilmiş property'lerle eşleşiyorsa. Title ve Price yalnızca getter'ı olan property'ler; EF Core convention gereği setter'ı ya da backing field'ı olmayan property'leri map etmiyor. O iki satır bu property'leri açıkça map ediyor. Satırları sildiğim bir DbContext'te model oluşturulurken EF Core şunu söylüyor:

No suitable constructor was found for the type 'Book'. The following constructors had parameters that could not be bound to properties of the type: 
    Cannot bind 'title', 'price' in 'Book(int id, string title, decimal price, int stock)'
Note that only mapped properties can be bound to constructor parameters. Navigations to related entities, including references to owned types, cannot be bound.

Mesajın son cümlesi kuralın kendisi: constructor'a yalnızca map edilmiş property'ler bağlanabiliyor, navigation'lar ve owned type referansları hiç bağlanamıyor. Order'ın constructor'ının kalemleri değil yalnızca customerEmail ve postalCode'u alması da bu yüzden işe yarıyor; kalemleri constructor'dan alan bir Order aynı hatayı verirdi.

Yani anlaşmanın şartları kısa ama gerçek: constructor parametreleri property adlarıyla eşleşmeli, getter-only property'ler Infrastructure'da açıkça map edilmeli, collection'lar EF Core'un doldurabileceği bir field'da durmalı, Id bir property olmalı. Bunlar domain'in şekline konmuş hafif sınırlar. Kodunda hiçbir EF Core izi yok, ama EF Core'un yapabildikleri domain'in nasıl yazılabileceğini etkiliyor.

Bu noktada iki seçeneğin var. Birincisi bugünkü yaklaşım: domain class'larını doğrudan EF Core ile map etmek ve bu sınırları kabul etmek. Kod az, tek model var; bedeli, domain'in şeklinin EF Core'un yapabildikleriyle sınırlanması. İkincisi ayrı bir persistence model: Infrastructure'da OrderRecord gibi EF Core'a uygun class'lar yazmak ve her okuma/yazmada domain ile bunlar arasında map etmek. Domain tamamen özgür kalır; bedeli, her entity için ikinci bir class, iki yönlü mapping kodu ve change tracking'in artık domain object'ini değil kaydı izlemesi. Bookstore gibi domain'i EF Core'un modelleyebildiği sınırlar içinde kalan bir sistemde birinci seçenek neredeyse her zaman daha ucuz. İkincisini, domain'in EF Core'un map edemeyeceği bir yapıya gerçekten ihtiyaç duyduğu ya da aynı model başka bir store'a da yazıldığı durumlara sakla.

Driven Adapter: Kargo Firmasının API'si

Persistence tek driven adapter değil. 05'te IShippingRates port'unun arkasına sabit ücretli bir yer tutucu koymuştuk. Bookstore'un gerçekte anlaştığı kargo firmasının bir HTTP (Hypertext Transfer Protocol) API'si var ve ücret orada hesaplanıyor. Port'a bu yazıda tek bir değişiklik yaptık:

public interface IShippingRates
{
    // null: bu posta koduna teslimat yok
    Task<decimal?> QuoteAsync(string postalCode, int weightGrams, CancellationToken ct);
}

Port'un adında "kargo firması", "HTTP" ya da "rate request" yok. Application bir posta koduna kaç gramlık paket gideceğini söylüyor ve bir ücret bekliyor. null dönüşü de bir anlam taşıyor: bu adrese teslimat yok. Handler bunu 06'da ayırdığımız 422'ye çeviriyor:

decimal? fee = await rates.QuoteAsync(command.PostalCode, command.Quantity * GramsPerBook, ct);
if (fee is null)
    return new Error("shipping.unsupported_destination", $"No delivery to {command.PostalCode}.", ErrorKind.Validation);

order.ApplyShipping(fee.Value);

.NET'in kendisi bu şekli her yerde kullanıyor. ILogger bir port, ILoggerProvider implementasyonları (console, debug, EventSource) adapter'lar. IDistributedCache da öyle: Redis ve SQL (Structured Query Language) Server implementasyonları ayrı paketlerde, uygulama kodu hangisinin kayıtlı olduğunu bilmiyor.

Typed HttpClient ile Adapter

Kargo firmasının API'si kendi dünyasında yaşıyor: alan adları snake_case, tutar kuruş cinsinden tam sayı, para birimi ayrı bir alanda, teslimat süresi ve servis kodu gibi bizim kullanmadığımız alanlar da var, desteklemediği posta kodu için 422 dönüyor. Adapter'ın işi bu dünyayı port'un diline çevirmek:

using System.Net;
using System.Net.Http.Json;
using System.Text.Json.Serialization;
using Bookstore.Application.Ordering;

namespace Bookstore.Infrastructure.Shipping;

internal sealed class CarrierApiOptions
{
    public const string Section = "Shipping:Carrier";
    public required Uri BaseAddress { get; init; }
    public required string ApiKey { get; init; }
}

// Driven adapter: Application'ın port'unu kargo firmasının HTTP API'sine çevirir
internal sealed class CarrierShippingRates(HttpClient http) : IShippingRates
{
    public async Task<decimal?> QuoteAsync(string postalCode, int weightGrams, CancellationToken ct)
    {
        CarrierRateRequest body = new(postalCode, weightGrams, ServiceLevel: "STANDARD");
        using HttpResponseMessage response = await http.PostAsJsonAsync("v2/rates", body, ct);

        if (response.StatusCode == HttpStatusCode.UnprocessableEntity)
            return null; // firma bu posta koduna teslimat yapmıyor
        response.EnsureSuccessStatusCode();

        CarrierRateResponse rate = await response.Content.ReadFromJsonAsync<CarrierRateResponse>(ct)
            ?? throw new InvalidOperationException("Carrier returned an empty body.");
        if (rate.Currency != "TRY")
            throw new InvalidOperationException($"Unexpected carrier currency {rate.Currency}.");

        // dış modelin dili burada biter: kuruş → decimal, firmanın diğer alanları içeri girmez
        return rate.AmountMinor / 100m;
    }

    private sealed record CarrierRateRequest(
        [property: JsonPropertyName("destination_zip")] string DestinationZip,
        [property: JsonPropertyName("weight_grams")] int WeightGrams,
        [property: JsonPropertyName("service_level")] string ServiceLevel);

    private sealed record CarrierRateResponse(
        [property: JsonPropertyName("amount_minor")] int AmountMinor,
        [property: JsonPropertyName("currency")] string Currency,
        [property: JsonPropertyName("transit_days")] int TransitDays,
        [property: JsonPropertyName("service_code")] string ServiceCode);
}

Dış modelin record'ları (CarrierRateRequest, CarrierRateResponse) adapter'ın içinde private. Firmanın JSON (JavaScript Object Notation) şeması değişirse yalnızca bu dosya değişiyor; TransitDays ve ServiceCode gibi alanlar Application'a hiç ulaşmıyor. Eric Evans'ın DDD (Domain-Driven Design, 2003) kitabında bu fikrin büyüğüne anti-corruption layer deniyor: başka bir sistemin modelinin kendi modelini bozmasına izin vermeyen çeviri katmanı. Burada tek bir class kadar küçük; tam haliyle ilerideki Domain-Driven Design serisinde. Firma bize HTTP yerine bir SDK (software development kit) verseydi de değişen bir şey olmazdı. Clean Code serisinin "Boundaries: Third-Party Kodu Sarmak ve Learning Test'ler" yazısı ve Design Patterns serisinin "Adapter Pattern: Third-Party SDK'yı Kendi Interface'ine Uydurmak" yazısı aynı sınırın kod seviyesindeki karşılığı.

Adapter HttpClient'ı constructor'dan alıyor; onu kimin, hangi ayarlarla oluşturduğu composition root'un işi. 05'teki AddInfrastructure artık iki adapter'dan birini seçiyor:

public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration configuration)
{
    services.AddDbContext<BookstoreDbContext>(o =>
        o.UseSqlite(configuration.GetConnectionString("Bookstore")));
    services.AddScoped<IUnitOfWork>(sp => sp.GetRequiredService<BookstoreDbContext>());
    services.AddScoped<IBookRepository, BookRepository>();
    services.AddScoped<IOrderRepository, OrderRepository>();
    if (configuration["Shipping:Mode"] == "Carrier")
    {
        services.AddOptions<CarrierApiOptions>().BindConfiguration(CarrierApiOptions.Section).ValidateOnStart();
        services.AddHttpClient<IShippingRates, CarrierShippingRates>((sp, http) =>
        {
            CarrierApiOptions options = sp.GetRequiredService<IOptions<CarrierApiOptions>>().Value;
            http.BaseAddress = options.BaseAddress;
            http.DefaultRequestHeaders.Add("X-Api-Key", options.ApiKey);
            http.Timeout = TimeSpan.FromSeconds(5);
        });
    }
    else
    {
        decimal flatRate = decimal.Parse(configuration["Shipping:FlatRate"] ?? "0", CultureInfo.InvariantCulture);
        services.AddSingleton<IShippingRates>(new FlatRateShipping(flatRate));
    }
    return services;
}

AddHttpClient<IShippingRates, CarrierShippingRates> bir typed client kaydı: IHttpClientFactory handler'ları havuzdan yönetiyor, sen her çağrıda hazır bir HttpClient alıyorsun. Typed client transient olarak kaydediliyor. Onu bir singleton'a inject edersen handler yenilenmez ve DNS (Domain Name System) değişikliklerini kaçırırsın; Web API serisinin "ASP.NET Core'da Dependency Injection: Lifetime'lar ve Captive Dependency Tuzağı" yazısındaki tuzağın aynısı. Adapter internal; 05'teki gibi Web onu hiç göremiyor. Ayarlar appsettings.json'dan geliyor:

"Shipping": {
  "Mode": "FlatRate",
  "FlatRate": 40,
  "Carrier": {
    "BaseAddress": "http://localhost:5307/",
    "ApiKey": "dev-only"
  }
},

Gerçek API anahtarı tabii ki burada durmaz; secret yönetimini Web API serisinin "ASP.NET Core'da Configuration ve Options Pattern: Provider Sırası, User Secrets ve Validation" yazısında konuşmuştuk. Firmanın sandbox'ını taklit eden küçük bir stub sunucuya karşı, Shipping__Mode=Carrier ile:

POST /orders {"customerEmail":"ada@example.com","postalCode":"34000","bookId":1,"quantity":1}
HTTP 201
{"orderId":1,"subtotal":250.0,"shippingFee":39.9,"total":289.9}
POST /orders {"customerEmail":"ada@example.com","postalCode":"99000","bookId":1,"quantity":1}
HTTP 422
{"type":"https://tools.ietf.org/html/rfc4918#section-11.2","title":"shipping.unsupported_destination","status":422,"detail":"No delivery to 99000."}

201'de firmanın 3990 kuruşu adapter'da 39,9 TL'ye dönüştü. 422'de firmanın HTTP 422'si adapter'da null'a, handler'da bizim shipping.unsupported_destination hatamıza dönüştü; client firmanın hata formatını hiç görmedi.

Fake Adapter ve Adapter Değiştirmek

Her geliştiricinin bilgisayarında kargo firmasının sandbox'ına erişim olmayabilir. 05'teki FlatRateShipping port'un ikinci implementasyonu olarak kalıyor ve ağa hiç çıkmıyor. Shipping:Mode ayarı varsayılan değeriyle bırakılınca aynı istek:

POST /orders {"customerEmail":"ada@example.com","postalCode":"34000","bookId":1,"quantity":1}
HTTP 201
{"orderId":1,"subtotal":250.0,"shippingFee":40,"total":290.0}

Ne handler ne endpoint değişti. Hexagonal Architecture'ın 2005'teki vaadi tam olarak bu: aynı uygulamayı farklı adapter'larla sürebilmek. 08. yazıda use case test'leri de bu tür in-memory adapter'larla çalışacak.

Gerçek adapter'ın bir sorunu daha var: firma erişilemezse ne olur? 06'nın açılışındaki senaryonun ta kendisi. Stub'ı kapatıp aynı isteği gönderdim (Development ortamındaki exception ayrıntılarını kısalttım):

POST /orders {"customerEmail":"ada@example.com","postalCode":"34000","bookId":1,"quantity":1}
HTTP 500
{"type":"https://tools.ietf.org/html/rfc9110#section-15.6.1","title":"System.Net.Http.HttpRequestException","status":500,"detail":"Connection refused (localhost:5307)"}

Ardından veritabanına baktım:

$ sqlite3 bookstore.db "SELECT Id, Title, Stock FROM Books; SELECT COUNT(*) FROM Orders;"
1|Dune|2
2|Emma|5
1

Dune'un stoğu 3'ten 2'ye, yalnızca başarılı siparişte düştü; 422 ve 500 ile biten iki istek hiçbir şey yazmadı. 06'daki tek SaveChanges kuralı burada kendini ödüyor: kargo ücreti commit'ten önce sorulduğu için arıza yarım bir sipariş bırakmıyor. "Firma şu an cevap vermiyor" bir iş sonucu değil, altyapı arızası; 500 bu yüzden bilinçli. Ama kısa süreli kesintilerde her isteği 500'e çevirmek iyi bir deneyim değil. Retry, timeout ve circuit breaker'ı adapter'ın koduna yazmak yerine HttpClient pipeline'ına eklemek daha doğru; Microsoft.Extensions.Http.Resilience paketinin AddStandardResilienceHandler() method'u bunu tek satırda yapıyor ve adapter'ın tek satırı bile değişmiyor.

Sık Düşülen Tuzaklar

Generic repository ve IQueryable sızıntısı. Açılıştaki ikinci sorunun cevabı, sorgu çalıştığı anda gelen bu exception:

The LINQ expression 'DbSet<Order>()
    .Where(o => o.Total > 100)' could not be translated. Additional information: Translation of member 'Total' on entity type 'Order' failed. This commonly occurs when the specified member is unmapped. Either rewrite the query in a form that can be translated, or switch to client evaluation explicitly by inserting a call to 'AsEnumerable', 'AsAsyncEnumerable', 'ToList', or 'ToListAsync'. See https://go.microsoft.com/fwlink/?linkid=2101038 for more information.

Total domain'de hesaplanan bir property, tabloda kolonu yok. IQueryable Application'a açıldığı an Application'da yazılan her LINQ (Language Integrated Query) lambda'sı, aslında EF Core'un SQL'e çevirmesi gereken bir ifade oluyor; derleyici bunu kontrol edemiyor, hata ancak runtime'da Infrastructure'ın içinden çıkıyor. Ayrıca Application artık ToListAsync için Microsoft.EntityFrameworkCore namespace'ine muhtaç. EF Core serisinin "EF Core ile LINQ Sorguları: SQL Çevirisi, Client Evaluation ve IQueryable Tuzağı" yazısında anlattığımız tuzak, mimari bir sınırı da delmiş oluyor. Repository'yi use case'in ihtiyacına göre, FindAsync gibi niyet anlatan method'larla tasarla. Raporlama gibi sorgu ağırlıklı ihtiyaçlarda ise repository'yi zorlamak yerine okuma tarafını ayrı ele al; bu ayrım CQRS'in (Command Query Responsibility Segregation) çıkış noktası. EF Core serisinin "EF Core ve Mimari: Repository, CQRS'in Okuma Tarafı ve Dapper'a Geçiş" yazısı bunun ilk adımları.

Her tablo için repository. OrderItem için bir IOrderItemRepository yazmak, kalemleri siparişten bağımsız değiştirilebilir hale getirir ve Order.TryAddItem'daki stok kuralını bypass etmenin kapısını açar. Repository aggregate başınadır; kalemler siparişin içinden yönetilir.

Mapping cehennemi. Request → command → entity → persistence model → entity → response DTO (Data Transfer Object) zincirinde her ok bir mapping class'ı demek. Ayrı persistence model'e gerçekten ihtiyacın yoksa o halkayı ekleme; 09. yazıda bu maliyete tekrar döneceğiz.

Dış modeli içeri almak. Adapter'ın CarrierRateResponse'u doğrudan döndürmesi en kolay yol gibi görünür; bir yıl sonra handler'ların yarısı amount_minor / 100 hesabını kendisi yapıyor olur. Çeviri adapter'da biter.

Her çağrıda new HttpClient(). Socket'leri tüketir ve DNS değişikliklerini yakalamaz. Typed client ya da named client kullan, adapter'a HttpClient'ı dışarıdan ver.

Küçük Bir Deneme

★☆☆ Order'ın constructor'ına bir IEnumerable<OrderItem> items parametresi ekle ve uygulamayı başlat. EF Core'un mesajı yukarıdakinin hangi cümlesine uyuyor?

★★☆ Stub'ın cevabına "currency":"EUR" yazdır. İstek hangi status code ile bitiyor ve stok değişiyor mu? Bu durumda adapter'ın exception fırlatması mı, null dönmesi mi doğru?

★★★ Kargo firmasının API'si her isteğe bir X-Request-Id header'ı istiyor. Bunu adapter'ın içine yazmadan, bir DelegatingHandler ve AddHttpMessageHandler ile ekle. Bu handler Infrastructure'da mı, Web'de mi durmalı?

Elimizde artık bir Domain, port'larını tanımlayan bir Application, EF Core ve HTTP adapter'ları olan bir Infrastructure ve ince bir Web var. Bir sonraki yazıda bunları test ediyoruz: core'u veritabanı olmadan, adapter'ları üretimdeki motor olan SQL Server'a karşı Testcontainers ile, ve Dependency Rule'un kendisini ArchUnitNET ile.