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

Clean Architecture: Dependency Rule, Use Case'ler ve Sınırı Geçen Veri

Mert Özen 7 Kas 2026 12 dk 3 görüntülenme
Clean Architecture: Dependency Rule, Use Case'ler ve Sınırı Geçen Veri

Clean Architecture'ın (Martin, 2012) tek kuralı Dependency Rule'dur: kaynak kod dependency'leri yalnız içe bakar ve sınırı entity ya da veritabanı satırı değil, iç çemberin ihtiyacına göre sade bir veri yapısı geçer. Bookstore'da dört çemberin karşılıkları, EF Core satırını döndürmenin yarattığı JSON döngü hatası, output port ve presenter ile ASP.NET Core'daki Result döndüren pragmatik karşılığı ve Hexagonal, Onion, Clean karşılaştırması.

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

Önce Tahmin Et

Bookstore'un sipariş detay ekranı için biri hızlıca bir endpoint yazmış. Entity Framework Core (EF Core) zaten siparişi ve kalemlerini getiriyor; DTO (Data Transfer Object) yazmaya, mapping yapmaya ne gerek var?

// Yanlış yol: veritabanı satırını olduğu gibi dışarı vermek
app.MapGet("/orders/{id:int}/row", async (int id, BookstoreDbContext db, CancellationToken ct) =>
    await db.Orders.Include(o => o.Items).SingleAsync(o => o.Id == id, ct));

OrderRow'un bir Items listesi var, her OrderItemRow'un da EF Core ilişkisi için kendi Order navigation property'si. Sipariş veritabanında duruyor ve sorgu sorunsuz çalışıyor. Bu endpoint'e istek attığında ne dönmesini beklersin: siparişin JSON (JavaScript Object Notation) hâli mi, bir hata mı? Ve asıl soru: bu endpoint'te Clean Architecture'ın hangi kuralı çiğneniyor?

Martin 2012: Mimarileri Birleştirmek

Robert C. Martin 13 Ağustos 2012'de "The Clean Architecture" başlıklı kısa bir blog yazısı yayımladı ve bu fikri 2017'de aynı adlı bir kitaba genişletti. Yazının çıkış noktası yeni bir mimari önermek değil, var olanları tek bir kurala indirmek. Martin son yıllarda konuşulan birkaç mimariyi sayıyor: Hexagonal Architecture (Cockburn), Onion Architecture (Palermo), kendi Screaming Architecture yazısı, Coplien ve Reenskaug'un DCI'ı (Data, Context and Interaction) ve Jacobson'ın BCE'si (Boundary-Control-Entity). Ayrıntıda farklılar ama amaçları aynı: yazılımı layer'lara bölerek concern'leri ayırmak.

Martin'e göre bu ailenin ürettiği sistemlerin beş ortak özelliği var. Framework'ten bağımsızdır; framework'ü bir araç olarak kullanır, sistemi onun kısıtlarına sıkıştırmaz. Test edilebilirdir; iş kuralları UI (user interface), veritabanı ve web sunucusu olmadan test edilir. UI'dan bağımsızdır; web arayüzü iş kurallarına dokunmadan bir console arayüzüyle değiştirilebilir. Veritabanından bağımsızdır; iş kurallarına dokunmadan bir veritabanı motorundan diğerine geçilebilir. Dış servislerden bağımsızdır; iş kuralları dış dünyayı tanımaz. Serinin ilk üç yazısında Bookstore'da adım adım kurduğumuz şey tam olarak bu listeydi.

Dependency Rule

Martin bütün bu mimarileri tek bir kurala bağlıyor ve ona Dependency Rule diyor: "source code dependencies can only point inwards." İç çemberdeki hiçbir şey dış çemberdeki bir şeyin adını bilemez; buna fonksiyonlar, class'lar ve değişkenler dahil. Martin kuralı bir adım daha ileri götürüyor: dış çemberde tanımlanmış veri formatları, özellikle bir framework'ün ürettikleri, iç çembere girmemeli.

04-clean-circles-tr.png

Kuralın ikinci yarısı genelde gözden kaçıyor. Interface'leri doğru yere koymak ilk yarıyı sağlıyor; ama use case bir OrderRow alıyor ya da bir HttpContext döndürüyorsa, interface'ler ne kadar doğru yerde olursa olsun iç çember dış çemberin şekline bağlanmış olur.

Dört Çember ve Bookstore'daki Karşılıkları

Yazıdaki çizimde dört çember var. En içte Entities: kurumun geneline ait iş kuralları. Bunlar method'ları olan object'ler de olabilir, veri yapıları ve fonksiyonlar da; önemli olan, sayfa düzeni ya da güvenlik gibi dışarıdaki bir değişiklikten en son etkilenecek şeyler olmaları. Martin, ortada bir kurum değil tek bir uygulama varsa entity'lerin o uygulamanın iş object'leri olduğunu da ekliyor. Bookstore'da bedava kargo eşiği bu çemberde:

public sealed class Order(string customerEmail, string postalCode)
{
    public const decimal FreeShippingThreshold = 500m;
    private readonly List<OrderItem> _items = [];

    public string CustomerEmail { get; } = customerEmail;
    public string PostalCode { get; } = postalCode;
    public IReadOnlyList<OrderItem> Items => _items;
    public decimal ShippingFee { get; private set; }
    public decimal Subtotal => _items.Sum(i => i.UnitPrice * i.Quantity);
    public decimal Total => Subtotal + ShippingFee;

    public bool TryAddItem(Book book, int quantity)
    {
        if (!book.TryReserve(quantity)) return false;
        _items.Add(new OrderItem(book.Id, book.Price, quantity));
        return true;
    }

    // Kurumsal kural: eşiği geçen siparişte kargo bedava
    public void ApplyShipping(decimal quotedFee) =>
        ShippingFee = Subtotal >= FreeShippingThreshold ? 0m : quotedFee;
}

Bir dış çemberde Use Cases var: uygulamaya özgü iş kuralları. Use case'ler entity'lere giden ve onlardan gelen veri akışını yönetir, entity'lere kendi kurallarını uygulatır. Uygulamanın nasıl çalıştığı değişince bu çember değişir; veritabanı, UI ya da framework değişince değişmez. Bookstore'da PlaceOrderHandler:

public sealed class PlaceOrderHandler(IBookGateway books, IOrderGateway orders, IShippingRates rates)
{
    private const int GramsPerBook = 400;

    public async Task<Result<PlaceOrderResponse>> HandleAsync(PlaceOrderRequest request, CancellationToken ct)
    {
        Book? book = await books.FindAsync(request.BookId, ct);
        if (book is null)
            return new Error("book.not_found", $"Book {request.BookId} not found.");

        Order order = new(request.CustomerEmail, request.PostalCode);
        if (!order.TryAddItem(book, request.Quantity))
            return new Error("book.out_of_stock", $"Only {book.Stock} copies of '{book.Title}' left.");

        order.ApplyShipping(await rates.QuoteAsync(request.PostalCode, request.Quantity * GramsPerBook, ct));
        int id = await orders.AddAsync(order, book, ct);
        return new PlaceOrderResponse(id, order.Subtotal, order.ShippingFee, order.Total);
    }
}

Üçüncü çember Interface Adapters: veriyi use case'lerin ve entity'lerin işine yarayan biçim ile dış dünyanın (veritabanı, web) işine yarayan biçim arasında çeviren adapter'lar. MVC'nin (Model-View-Controller) controller'ları, presenter'lar ve view'lar burada; Martin bütün SQL'in (Structured Query Language) de bu çemberde kalması gerektiğini söylüyor. Bookstore'da EfOrderGateway (domain'i OrderRow'a çeviren gateway), Minimal API (Application Programming Interface) endpoint'leri ve birazdan göreceğimiz presenter bu çemberde.

En dışta Frameworks and Drivers: veritabanı, web framework'ü ve benzeri araçlar. Bu çembere genelde bir iç çembere bağlanan yapıştırıcı koddan fazlası yazılmaz. Bookstore'da ASP.NET Core'un kendisi, EF Core ve SQLite burada; bizim koddaki karşılığı Program.cs'teki kayıtlar.

Martin dört sayısının kutsal olmadığını da açıkça yazıyor: çizim bir şema, gerekirse daha fazla çember olabilir; değişmeyen tek şey Dependency Rule. İçeri doğru gidildikçe soyutlama seviyesi artıyor: en dışta somut detaylar, en içte en genel politikalar. Bu yüzden "Clean Architecture = dört project" eşitliği yazının söylediği bir şey değil.

Sınırı Geçen Veri

Açılıştaki endpoint'e istek attığımda gelen cevap:

HTTP/1.1 500 Internal Server Error
Content-Length: 0

Sunucunun log'unda da sebebi:

System.Text.Json.JsonException: A possible object cycle was detected. This can either be due to a cycle or if the object depth is larger than the maximum allowed depth of 64. Consider using ReferenceHandler.Preserve on JsonSerializerOptions to support cycles. Path: $.Items.Order.Items.Order.Items.Order.Items.Order.Items.Order.Items.Order.Items.Order.Items.Order.Items.Order.Items.Order.Items.Order.Items.Order.Items.Order.Items.Order.Items.Order.Items.Order.Items.Order.Items.Order.Items.Order.Items.Order.Items.Order.Id.

OrderItemRow.Order navigation'ı EF Core için gerekli ama JSON serializer için sonsuz bir döngü. Log mesajındaki öneriyi uygulayıp ReferenceHandler.Preserve açmak hatayı susturur; asıl sorunu çözmez. Asıl sorun, Frameworks and Drivers çemberinin bir veri yapısının (EF Core'un ilişki grafiği) hiçbir çeviriden geçmeden dışarıdaki bir sözleşmeye, API cevabına dönüşmesi. Tablo yapısını değiştirdiğin gün API sözleşmen de değişir.

Martin'in "sınırı ne geçmeli" sorusuna cevabı net: basit, yalıtılmış veri yapıları. Basit struct'lar, DTO'lar, fonksiyon argümanları ya da hashmap'ler olabilir; entity'ler ya da veritabanı satırları olmamalı. Yazıdaki örnek tam olarak bizim durumumuz: veritabanı framework'leri sorgu cevabı olarak satır biçiminde bir yapı döndürür ve bu yapıyı içeri taşımak, iç çemberi dış çembere bağımlı kılar. Sınırı geçen veri her zaman iç çember için en uygun biçimde olmalı.

Bookstore'da use case'e giren ve çıkan veri iki sade record:

// Sınırı geçen veri: iç çemberin işine yarayan sade record'lar
public sealed record PlaceOrderRequest(string CustomerEmail, string PostalCode, int BookId, int Quantity);
public sealed record PlaceOrderResponse(int OrderId, decimal Subtotal, decimal ShippingFee, decimal Total);

PlaceOrderRequest ne HttpContext biliyor ne JSON attribute'u taşıyor; aynı record'u bir console komutu, bir queue tüketicisi ya da test kurabilir. PlaceOrderResponse ise Order entity'sini dışarı sızdırmadan yalnız ekranın ihtiyaç duyduğu sayıları taşıyor. Entity'nin içinde TryAddItem gibi davranışlar ve ileride eklenecek iç alanlar var; bunların bir API sözleşmesinin parçası olmasını istemezsin.

Sınırı Geçen Control Flow: Output Port ve Presenter

Martin'in yazısındaki en ince fikir, control flow ile dependency'nin zıt yönlere gidebilmesi. Bir controller use case'i çağırır, use case işini bitirince sonucu bir presenter'a verir. Control flow dışarıdan içeri, sonra içeriden dışarı akar; ama use case presenter'ın adını bilemez, çünkü presenter dış çemberde. Çözüm DIP (Dependency Inversion Principle): use case, kendi çemberinde tanımlı bir interface'i (use case output port) çağırır, dış çemberdeki presenter da onu implement eder. Böylece her sınırda dependency control flow'a karşı yönde durur:

// Martin'in tarifi: use case, iç çemberde tanımlı output port'u çağırır
public interface IPlaceOrderOutputPort
{
    void Placed(PlaceOrderResponse response);
    void Rejected(string reason);
}

public sealed class PlaceOrderInteractor(IBookGateway books, IOrderGateway orders, IShippingRates rates)
{
    private const int GramsPerBook = 400;

    public async Task ExecuteAsync(PlaceOrderRequest request, IPlaceOrderOutputPort output, CancellationToken ct)
    {
        Book? book = await books.FindAsync(request.BookId, ct);
        if (book is null) { output.Rejected($"Book {request.BookId} not found."); return; }

        Order order = new(request.CustomerEmail, request.PostalCode);
        if (!order.TryAddItem(book, request.Quantity))
        {
            output.Rejected($"Only {book.Stock} copies of '{book.Title}' left.");
            return;
        }

        order.ApplyShipping(await rates.QuoteAsync(request.PostalCode, request.Quantity * GramsPerBook, ct));
        int id = await orders.AddAsync(order, book, ct);
        output.Placed(new PlaceOrderResponse(id, order.Subtotal, order.ShippingFee, order.Total));
    }
}

Presenter, Interface Adapters çemberinde, Web project'inde duruyor ve output port'a gelen çağrıyı bir HTTP (Hypertext Transfer Protocol) cevabına çeviriyor:

// Interface Adapters çemberi: output port'u HTTP cevabına çeviren presenter
public sealed class HttpPlaceOrderPresenter : IPlaceOrderOutputPort
{
    public IResult Result { get; private set; } = Results.StatusCode(500);

    public void Placed(PlaceOrderResponse response) =>
        Result = Results.Created($"/orders/{response.OrderId}", response);

    public void Rejected(string reason) =>
        Result = Results.Problem(reason, statusCode: StatusCodes.Status409Conflict);
}

app.MapPost("/orders/presenter", async (PlaceOrderRequest request, PlaceOrderInteractor interactor, CancellationToken ct) =>
{
    HttpPlaceOrderPresenter presenter = new();
    await interactor.ExecuteAsync(request, presenter, ct);
    return presenter.Result;
});

Alan için 1 adet Dune sipariş ettiğimde gelen cevap (sipariş numarası 2, çünkü ilk sipariş aşağıda göreceğimiz /orders endpoint'inden verilmişti):

HTTP/1.1 201 Created
{"orderId":2,"subtotal":250.0,"shippingFee":40,"total":290.0}

Testte presenter'ın yerine çağrıları kaydeden küçük bir sahte presenter koyuyoruz; use case'in "neyi söylediği" böylece HTTP'den bağımsız test ediliyor:

// Test presenter: output port'a gelen çağrıları kaydeder
private sealed class RecordingPresenter : IPlaceOrderOutputPort
{
    public List<string> Calls { get; } = [];
    public void Placed(PlaceOrderResponse response) => Calls.Add($"Placed {response}");
    public void Rejected(string reason) => Calls.Add($"Rejected {reason}");
}

[Fact]
public async Task Interactor_talks_to_the_output_port()
{
    PlaceOrderInteractor interactor = new(new Books(new Book(1, "Dune", 250m, 3)), new Orders(), new Rates(40m));
    RecordingPresenter presenter = new();

    await interactor.ExecuteAsync(new PlaceOrderRequest("ada@example.com", "06100", 1, 2), presenter, CancellationToken.None);

    Assert.Equal(["Placed PlaceOrderResponse { OrderId = 1, Subtotal = 500, ShippingFee = 0, Total = 500 }"], presenter.Calls);
}

ASP.NET Core'daki Pragmatik Karşılığı: Result Döndüren Handler

Presenter'lı tarif, Martin'in yazdığı dönemin MVC dünyasında anlamlıydı: view'ı hazırlayan ayrı bir object, controller'ı sade tutuyordu. ASP.NET Core'un Minimal API'sinde endpoint lambda'sı zaten küçük bir presenter: use case'in cevabını IResult'a çeviriyor. Bu yüzden .NET dünyasında yaygın olan, use case'in sonucu bir değer olarak döndürmesi ve endpoint'in onu HTTP'ye map etmesi. Bunun için kendi küçük Result<T> tipimizi yazdık; bir library'ye bağımlılık eklemiyor ve ne yaptığı tek bakışta görülüyor:

public sealed record Error(string Code, string Message);

public sealed class Result<T>
{
    private Result(T? value, Error? error) => (Value, Error) = (value, error);

    public T? Value { get; }
    public Error? Error { get; }
    public bool IsSuccess => Error is null;

    public static implicit operator Result<T>(T value) => new(value, null);
    public static implicit operator Result<T>(Error error) => new(default, error);
}

// Pragmatik yol: handler Result döndürür, endpoint onu HTTP'ye map eder
app.MapPost("/orders", async (PlaceOrderRequest request, PlaceOrderHandler handler, CancellationToken ct) =>
{
    Result<PlaceOrderResponse> result = await handler.HandleAsync(request, ct);
    return result.IsSuccess
        ? Results.Created($"/orders/{result.Value!.OrderId}", result.Value)
        : Results.Problem(result.Error!.Message, statusCode: StatusCodes.Status409Conflict,
            extensions: new Dictionary<string, object?> { ["code"] = result.Error.Code });
});

Stokta 3 Dune varken aynı isteği (2 adet) iki kez attığımda:

HTTP/1.1 201 Created
{"orderId":1,"subtotal":500.0,"shippingFee":0,"total":500.0}

HTTP/1.1 409 Conflict
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.10","title":"Conflict","status":409,"detail":"Only 1 copies of 'Dune' left.","code":"book.out_of_stock"}

Dependency yönü iki tarifte de aynı: handler IResult'ı, presenter'ı, HTTP'yi bilmiyor. Fark, çeviriyi kimin yaptığı. Presenter'lı tarif aynı use case birden çok çıktı biçimine (HTML, JSON, PDF) gidecekse ve formatlama mantığı büyükse değerli. Result'lı tarif çoğu Web API için yeterli, daha az tip ve daha düz bir akış demek. Result pattern'in kendisini Design Patterns serisinin "GoF Dışında Bilmen Gereken Kod Seviyesi Pattern'ler: Null Object, Specification, Result ve Pipeline" yazısında, ProblemDetails'i de ASP.NET Core Web API serisinin "ASP.NET Core'da Global Hata Yönetimi: Exception Middleware ve ProblemDetails" yazısında anlattık; GoF, Gang of Four'un (Gamma, Helm, Johnson ve Vlissides'in 1994 tarihli Design Patterns kitabı) kısaltması. Result mı exception mı sorusunu ve validation'ın yerini serinin 06. yazısında derinleştireceğiz.

.NET'te Sınırı Geçen Veri: IFeatureCollection

ASP.NET Core'un kendi sınırlarında da aynı prensip işliyor. Önceki yazılarda gördüğümüz IServer, uygulamayı IHttpApplication<TContext> üzerinden sürüyor. Bu interface'in method'larını reflection ile listelediğimde:

TContext CreateContext(Microsoft.AspNetCore.Http.Features.IFeatureCollection)
System.Threading.Tasks.Task ProcessRequestAsync(TContext)
Void DisposeContext(TContext, System.Exception)

Kestrel kendi bağlantı nesnelerini, socket'lerini ya da buffer'larını uygulamaya vermiyor; sınırı geçen şey bir IFeatureCollection: istek satırını, header'ları, gövde stream'ini tarif eden küçük feature interface'lerinin bir torbası. HttpContext bu torbadan, iç tarafın işine yarayan biçimde kuruluyor. Kestrel, HTTP.sys ya da TestServer değişince uygulamanın gördüğü veri yapısı değişmiyor; Martin'in "veri, iç çember için en uygun biçimde geçer" cümlesinin framework'teki karşılığı bu.

Üç Mimari Yan Yana

Serinin ilk dört yazısını tek bir tabloda toplayalım:

SoruHexagonal (2005)Onion (2008)Clean (2012)
Temel terimlerPort, adapter, driving ve driven tarafHalkalar, application core, repository interfaceÇemberler, Dependency Rule, use case, output port, presenter
İç yapıyı tarif ediyor mu?Hayır; içerisi kara kutuEvet; Domain Model, Domain Services, Application ServicesEvet; Entities ve Use Cases, sayının sabit olmadığı notuyla
Sınırı geçen veriPort'un API'si ne tanımlıyorsaAçıkça ele alınmıyorSade veri yapıları; entity ve satır değil
Test bakışıTest bir driving adapter, mock bir driven adapterTestler en dış halkada, çekirdek altyapısız çalışırİş kuralları UI, veritabanı ve web sunucusu olmadan test edilir
Ana vurguİçerisi ile dışarısının simetrisiCoupling'in yönü merkeze doğruKaynak kod dependency'leri yalnız içe

Sonuç: bunlar üç rakip mimari değil, aynı fikrin üç anlatımı. Hexagonal sınırı ve kapıları, Onion içerinin haritasını, Clean da sınırdan geçen verinin ve akışın kurallarını anlatıyor. Bir .NET solution'ı kurarken üçünden de bir şey alırsın: port isimlendirmesini Hexagonal'dan, domain model'i merkeze koymayı Onion'dan, request/response record'larını ve Dependency Rule'u Clean'den.

Sık Düşülen Tuzaklar

Çemberleri birebir project'e çevirmek. Martin'in çizimi bir şema; dört çember için dört project zorunlu değil. Bookstore'da Entities ve Use Cases tek project'te, iki namespace'te duruyor. Sıradaki yazıda yaygın dört project'lik düzeni kuracağız ama onun gerekçesi Martin'in çizimi değil, derleyicinin sınırları denetlemesi.

Her sınırda bir mapping katmanı daha açmak. Request record'u, command, entity, row, response ve view model arasında altı mapping, kuralın istediği bir şey değil. Kural sınırı geçen verinin iç çembere bağımlılık yaratmamasını istiyor; aynı record'u birkaç sınırdan geçirmek çoğu zaman yeterli.

Entity'yi cevap olarak döndürmek. Açılıştaki döngü hatası gürültülü bir belirti; sessiz olanı daha tehlikeli: entity'ye eklenen her property ertesi gün API sözleşmesinde görünür.

Presenter'ı törene çevirmek. Tek bir JSON cevabı üreten basit bir endpoint için output port, presenter ve view model zinciri kurmak dolaylılık ekler; Result döndüren handler çoğu yerde aynı dependency yönünü daha az kodla verir.

Framework'ü iç çembere sokmak. Use case'in IFormFile, ClaimsPrincipal ya da DbSet<T> alması kısa yol gibi görünür; her biri dış çemberin bir tipini içeri taşır.

Küçük Bir Deneme

★☆☆ Açılıştaki endpoint'i, OrderRow yerine kendi tanımladığın bir OrderDetailsResponse record'u döndürecek şekilde düzelt. ReferenceHandler.Preserve kullanmadan döngü hatasının ortadan kalktığını doğrula.

★★☆ PlaceOrderInteractor için ikinci bir presenter yaz: sipariş onayını düz metin olarak üreten bir TextPlaceOrderPresenter. Interactor'a dokunmadan console'dan çağır.

★★★ Kendi projendeki bir use case'in giriş ve çıkış tiplerini incele. Herhangi biri bir EF Core entity'si, bir ASP.NET Core tipi ya da bir third-party SDK (Software Development Kit) tipi mi? Birini sade bir record'a çevir ve kaç dosyanın değiştiğini say.

Blok A burada bitiyor: fikri ve üç orijinal kaynağı gördük. Bir sonraki yazıda Blok B'ye geçiyoruz ve Bookstore'un Ordering kısmını .NET 10'da gerçek bir solution olarak kuruyoruz: dotnet new sln ile dört project, project reference kuralları, Directory.Build.props ve composition root olarak Program.cs.