Onion Architecture: Domain Model Merkezde, Altyapı Dış Halkada
Onion Architecture (Palermo, 2008) uygulamayı bağımsız bir domain model'in etrafına kurar: iç halkalar interface'leri tanımlar, dış halkalar implement eder, tüm coupling merkeze doğrudur ve çekirdek altyapıdan ayrı derlenip çalışabilir. Bookstore'un sipariş kodunu Domain Model, Domain Services ve Application Services halkalarına yerleştiriyor, layered ve Hexagonal'dan farkını, halka-project ilişkisini ve ASP.NET Core Identity örneğini gösteriyoruz.
Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir.
Önce Tahmin Et
Bookstore'un sipariş tarafında iki endpoint var. Biri sipariş veriyor ve bunun için bir application service'i çağırıyor. Diğeri tek bir siparişi gösteriyor ve araya hiçbir servis koymadan repository'yi doğrudan kullanıyor:
// UI halkası application service'i çağırır...
app.MapPost("/orders", async (PlaceOrderCommand command, PlaceOrderService service, CancellationToken ct) =>
{
Order order = await service.PlaceAsync(command, ct);
return Results.Created($"/orders/{order.Id}", new { order.Id, order.Subtotal, order.ShippingFee, order.Total });
});
// ...ama okuma için domain services halkasındaki repository'yi doğrudan da kullanabilir
app.MapGet("/orders/{id:int}", async (int id, IOrderRepository orders, CancellationToken ct) =>
await orders.FindAsync(id, ct) is { } order
? Results.Ok(new { order.Id, order.CustomerEmail, Items = order.Items.Count, order.Total })
: Results.NotFound());
Klasik, katı (closed) layered bir mimaride presentation layer yalnız hemen altındaki business layer'ı çağırabilir; ikinci endpoint orada bir kural ihlali sayılır. IOrderRepository interface'i burada domain model'in hemen yanındaki bir halkada, implementation'ı ise en dıştaki Infrastructure project'inde duruyor. Sence Onion Architecture bu GET endpoint'ine izin verir mi? Ve sipariş tutarı 500 TL'yi geçince kargoyu bedava yapan kural hangi halkaya yazılmalı: Order'ın içine mi, application service'e mi, yoksa ikisinin arasına mı?
Palermo'nun 2008 Yazı Dizisi
Jeffrey Palermo "Onion Architecture" adını 29 Temmuz 2008'de yayımladığı yazıyla koydu; dizi aynı yaz dört bölüm olarak devam etti. İlk bölüm daha ilk paragrafta bağlamı sabitliyor: bu mimari küçük web siteleri için uygun değil, uzun ömürlü iş uygulamaları ve karmaşık davranışı olan uygulamalar için uygun. Palermo yeni bir şey icat ettiğini de iddia etmiyor; tek tek bilinen teknikleri bir araya getirip onlara ortak bir isim veriyor, çünkü bir pattern'in en büyük faydası ekibe ortak bir kelime dağarcığı vermek. Aynı yazıda Cockburn'ün Hexagonal Architecture'ının da aynı öncül üzerine kurulduğunu söylüyor: altyapıyı dışarı itmek ve aradaki bağı adapter kodla kurmak.
Palermo'nun eleştirdiği yapı, serinin ilk yazısında gördüğümüz klasik layered mimari: her layer altındakilere, hepsi de ortak bir altyapıya bağlı. En yaygın ve en zararlı coupling, UI (user interface) ve iş mantığının veri erişimine bağlı olması. Palermo transitive dependency'leri özellikle vurguluyor: UI veri erişimini doğrudan çağırmasa bile, iş mantığı veri erişimi olmadan çalışamadığı için UI da çalışamaz. Gerekçesi de pratik: sektör veri erişim tekniklerini en az üç yılda bir değiştiriyor ve bu değişikliğe uyum sağlayamayan sistemler bakımsız kalıp sonunda baştan yazılıyor.
Onion'ın temel kuralı tek cümle. Kod kendisinden daha merkezde olan halkalara bağımlı olabilir, daha dıştaki halkalara asla. Palermo'nun kendi ifadesiyle "all coupling is toward the center". Mimarinin nesne yönelimli programlamaya (OOP, object-oriented programming) açıkça yatkın olduğunu da saklamıyor: merkezde object'ler var.
Halkalar: Domain Model'den Altyapıya
Palermo'nun diyagramında merkezde Domain Model, onun etrafında Domain Services, onun da etrafında Application Services var; en dış halkayı UI, Infrastructure ve Tests paylaşıyor. Uygulamanın çekirdeği (application core) ilk üç halkadan oluşuyor ve çekirdekteki halka sayısının uygulamaya göre değişebileceğini Palermo açıkça söylüyor. Bookstore'un sipariş kodunu bu halkalara yerleştirelim.
Domain Model
Merkezde organizasyon için "doğru" olanı temsil eden state ve davranış birlikte duruyor. Merkez yalnız kendisine bağımlı:
namespace Bookstore.Ordering.DomainModel;
public sealed record OrderItem(int BookId, string Title, decimal UnitPrice, int Quantity)
{
public decimal LineTotal => UnitPrice * Quantity;
}
public sealed class Order(string customerEmail, string postalCode)
{
private readonly List<OrderItem> _items = [];
public int Id { get; set; }
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.LineTotal);
public decimal Total => Subtotal + ShippingFee;
public void AddItem(Book book, int quantity)
{
if (!book.TryReserve(quantity))
throw new InvalidOperationException($"Only {book.Stock} copies of '{book.Title}' left.");
_items.Add(new OrderItem(book.Id, book.Title, book.Price, quantity));
}
public void SetShippingFee(decimal fee) => ShippingFee = fee;
}
Book da aynı halkada; önceki yazılardaki TryReserve method'uyla stok kuralını taşıyor. Aggregate sınırları, value object'ler ve bu modelin nasıl zenginleşeceği ilerideki Domain-Driven Design serisinin konusu; burada önemli olan bu halkanın hiçbir şey import etmemesi.
Domain Services
Palermo'ya göre domain model'in hemen etrafındaki halka, object'leri kaydetme ve geri getirme davranışını tarif eden interface'lerin, yani repository interface'lerinin tipik yeri. Kaydetmenin kendisi çekirdekte değil, çünkü bir veritabanı gerektiriyor; çekirdekte yalnız interface var. Bookstore'da kargo ücretini soran interface de burada. Açılıştaki ikinci sorunun cevabı da bu halkada: bedava kargo eşiği tek bir entity'ye ait olmayan, ama yine de saf bir iş kuralı. Böyle kurallar için domain service yazılır:
// İç halka interface'i tanımlar, dış halka implement eder
public interface IOrderRepository
{
Task<Order?> FindAsync(int orderId, CancellationToken ct);
Task AddAsync(Order order, CancellationToken ct);
Task SaveChangesAsync(CancellationToken ct);
}
public interface IShippingRates
{
Task<decimal> QuoteAsync(string postalCode, int weightGrams, CancellationToken ct);
}
// Domain service: tek bir entity'ye ait olmayan iş kuralı
public sealed class ShippingFeePolicy(IShippingRates rates)
{
public const decimal FreeShippingThreshold = 500m;
private const int GramsPerBook = 400;
public async Task<decimal> FeeForAsync(Order order, CancellationToken ct)
{
if (order.Subtotal >= FreeShippingThreshold) return 0m;
int weight = order.Items.Sum(i => i.Quantity) * GramsPerBook;
return await rates.QuoteAsync(order.PostalCode, weight, ct);
}
}
ShippingFeePolicy bir iç halka class'ı ve dış dünyaya yalnız kendi halkasındaki IShippingRates interface'i üzerinden uzanıyor. Eşik kuralını test etmek için veritabanı ya da HTTP (Hypertext Transfer Protocol) gerekmiyor:
[Theory]
[InlineData(1, 40)] // 250 TL: kargo ücreti alınır
[InlineData(2, 0)] // 500 TL: eşik aşıldı, kargo bedava
public async Task Free_shipping_starts_at_threshold(int quantity, decimal expectedFee)
{
Order order = new("ada@example.com", "06100");
order.AddItem(new Book(1, "Dune", 250m, stock: 5), quantity);
decimal fee = await new ShippingFeePolicy(new FixedRates(40m)).FeeForAsync(order, CancellationToken.None);
Assert.Equal(expectedFee, fee);
}
Application Services
Bir sonraki halka use case'leri yönetiyor: hangi repository'den ne okunacağı, hangi domain method'unun çağrılacağı, sonucun ne zaman kaydedileceği. Kuralların kendisi iç halkalarda; application service onları sıraya koyuyor:
// Application service: use case akışını yönetir, kuralları iç halkalara bırakır
public sealed class PlaceOrderService(IBookRepository books, IOrderRepository orders, ShippingFeePolicy shipping)
{
public async Task<Order> PlaceAsync(PlaceOrderCommand command, CancellationToken ct)
{
Book book = await books.FindAsync(command.BookId, ct)
?? throw new InvalidOperationException($"Book {command.BookId} not found.");
Order order = new(command.CustomerEmail, command.PostalCode);
order.AddItem(book, command.Quantity);
order.SetShippingFee(await shipping.FeeForAsync(order, ct));
await orders.AddAsync(order, ct);
await orders.SaveChangesAsync(ct);
return order;
}
}
Dış halka: UI, Infrastructure, Tests
En dışta sık değişen şeyler duruyor. EfOrderRepository belirli bir veri erişim tekniğine (EF Core, Entity Framework Core) bağlı olduğu için çekirdeğin dışında ve IOrderRepository'yi implement ederek ona bağlanıyor. Minimal API (Application Programming Interface) endpoint'leri de, xUnit testleri de bu halkada. Testlerin dış halkada durması önemli bir ayrıntı: çekirdek testlere bağlı değil, testler çekirdeğe bağlı. Çalışma zamanında implementation'ları çekirdeğe vermek için Palermo bir IoC (Inversion of Control) container'ı tavsiye ediyor; tenet olarak saymasa da C# gibi bir dilde işi çok kolaylaştırdığını yazıyor. ASP.NET Core'da bu container yerleşik geliyor.
Dört Tenet
Dizinin 4 Ağustos 2008 tarihli üçüncü bölümü, Onion Architecture adının hangi mimariler için kullanılması gerektiğini dört tenet ile tarif ediyor. Kendi cümlelerimle:
- Uygulama bağımsız bir object model'in etrafına kurulur.
- İç halkalar interface'leri tanımlar, dış halkalar onları implement eder.
- Coupling'in yönü merkeze doğrudur.
- Uygulama çekirdeğinin tüm kodu altyapıdan ayrı olarak derlenip çalıştırılabilir.
Dördüncüsü en kolay ölçülebilen tenet. Çekirdek assembly'nin referans verdiği assembly'leri soran bir test yazdım:
[Fact]
public void Core_assembly_references_only_the_base_library()
{
string[] references = typeof(Order).Assembly.GetReferencedAssemblies()
.Select(a => a.Name!).Order().ToArray();
Assert.Equal(["System.Collections", "System.Linq", "System.Runtime"], references);
}
Passed! - Failed: 0, Passed: 3, Skipped: 0, Total: 3, Duration: 6 ms - Bookstore.Ordering.Tests.dll (net10.0)
Çekirdek yalnız base class library'yi görüyor. Üç testin hiçbiri bir veritabanı dosyası, bir port ya da bir HTTP sunucusu açmadı. Testin kendisi kırılgan sayılabilir (çekirdekte System.Text.Json kullanmaya karar verirsen güncellemen gerekir), ama dördüncü tenet'i somut bir kontrol hâline getiriyor. Kalıcı ve daha esnek bir yolu, ArchUnitNET ile yazılan architecture test'lerini serinin 08. yazısında göreceğiz.
Layered'dan Farkı: Dış Halka İçerideki Her Halkayı Görebilir
Palermo üçüncü bölümde Onion'ı klasik layered mimariyle karşılaştırırken asıl farkı şuraya koyuyor: Onion'da bir dış halka, kendisinden içerideki herhangi bir halkayı doğrudan çağırabilir; geleneksel layered mimaride ise bir layer yalnız hemen altındakini çağırır. Açılıştaki ilk sorunun cevabı bu: GET endpoint'inin repository'yi doğrudan kullanması Onion'da kural ihlali değil. Kural tek: ok her zaman içe bakacak.
Aynı uygulamayı çalıştırıp önce 1, sonra 2 adet Dune sipariş ettim, ardından ikinci siparişi okudum:
{"id":1,"subtotal":250.0,"shippingFee":40,"total":290.0}
{"id":2,"subtotal":500.0,"shippingFee":0,"total":500.0}
{"id":2,"customerEmail":"ada@example.com","items":1,"total":500.0}
İkinci sipariş eşiğe ulaştığı için kargo ücreti sıfır; GET isteği application service'e hiç uğramadan aynı domain object'ini okudu. Bunun bir bedeli var: okuma tarafında iş kuralı gerekirse (ör. siparişi yalnız sahibi görebilsin) o kural endpoint'te değil, bir iç halkada durmalı; aksi hâlde console ya da başka bir UI aynı kuralı atlar. "Dış halka içeriyi atlayabilir" özgürlüğü, kuralları dış halkaya taşımak için bir izin değil.
Palermo'nun bir başka gözlemi de veritabanının yeri. Onion'ı düz bir layer çizimine çevirdiğinde Data Access en altta değil, UI ile birlikte en üstte duruyor. Kendi cümlesiyle veritabanı merkez değil, dışarıda; "veritabanı uygulaması" diye bir şey yok, veritabanını bir depolama servisi olarak kullanan uygulamalar var.
Halka Project mi, Klasör mü?
Palermo halkaları birer project olarak tarif etmiyor. Bu örnekte çekirdeğin üç halkası tek bir Bookstore.Ordering project'inde, üç klasörde (ve üç namespace'te) duruyor; Infrastructure, Web ve test ise ayrı project'ler. Bu seçimin sonucu derleyicide hemen görülüyor. Çekirdeğin en içindeki DomainModel klasörüne, en dıştaki çekirdek halkasından bir tip kullanan bir dosya eklediğimde build sorunsuz geçti:
using Bookstore.Ordering.ApplicationServices;
namespace Bookstore.Ordering.DomainModel;
// İç halka dış halkadaki bir tipe uzanıyor: aynı project'te derleyici ses çıkarmaz
public static class Leak
{
public static PlaceOrderCommand Copy(Order order) => new(order.CustomerEmail, order.PostalCode, 0, 0);
}
Aynı klasöre EF Core'dan bir şey kullanmaya çalışan bir dosya koyduğumda ise derleyici durdurdu, çünkü çekirdek project EF Core paketine referans vermiyor:
Leak.cs(1,17): error CS0234: The type or namespace name 'EntityFrameworkCore' does not exist in the namespace 'Microsoft' (are you missing an assembly reference?)
Kural basit: project sınırını derleyici denetler, klasör sınırını yalnız insanlar. Çekirdekle altyapı arasındaki sınır en pahalı ihlalin olduğu yer olduğu için onu project ile çizmek neredeyse her zaman doğru. Çekirdeğin içindeki halkaları ayrı project'lere bölmek ise bir tercih: daha sıkı denetim karşılığında daha fazla project ve referans yönetimi. Solution'ı project'lere bölmenin genel ölçütlerini SOLID (Robert C. Martin'in beş class tasarımı prensibinin baş harfleri) ve Design Principles serisinin "Component Prensipleri: .NET Solution'ını Project'lere Bölmek" yazısında anlattık; bu serinin 05. yazısında Clean Architecture için dört project'lik yaygın düzeni kuracağız.
Sınırın bir bedeli de mapping tarafında ortaya çıkıyor. Domain class'larını EF Core ile doğrudan map etmeye çalıştığımda uygulama ilk açılışta şu exception'la durdu:
System.InvalidOperationException: 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)'
EF Core, setter'ı olmayan Title ve Price property'lerini convention ile map etmediği için constructor parametrelerini bağlayamıyor. Çözüm domain'e setter eklemek değil, Infrastructure'daki configuration'da e.Property(x => x.Title) diyerek bu property'leri açıkça map etmek. Persistence ignorance'ın sınırlarını ve bu mapping'in nasıl düzenleneceğini serinin "Persistence Ignorance ve Infrastructure Adapter'ları" yazısında ayrıntılı göreceğiz.
.NET'te Bir Onion: ASP.NET Core Identity
ASP.NET Core Identity, "repository interface'i iç halkada, implementation'ı dışta" fikrinin framework'teki örneği. Üç tipin hangi assembly'de durduğunu ve çekirdek assembly'nin Entity Framework'e referans verip vermediğini reflection ile sorduğumda:
UserManager`1 Microsoft.Extensions.Identity.Core
IUserStore`1 Microsoft.Extensions.Identity.Core
UserStore`1 Microsoft.AspNetCore.Identity.EntityFrameworkCore
no EF reference
Kullanıcı oluşturma, parola doğrulama, kilitleme gibi kuralları yöneten UserManager<TUser>, depolamayı IUserStore<TUser> interface'i üzerinden istiyor ve ikisi aynı çekirdek assembly'de. EF Core implementation'ı UserStore ayrı bir pakette; o paketin .nuspec dosyası da Microsoft.EntityFrameworkCore.Relational'a bağımlı. Kendi veritabanın için bir store yazdığında UserManager değişmiyor; tıpkı Bookstore'da EfOrderRepository'nin yerine başka bir implementation koyduğunda PlaceOrderService'in değişmemesi gibi.
Hexagonal'dan Farkı
İki mimari aynı öncülü paylaşıyor ve Palermo bunu kendisi söylüyor. Farklar bakış açısında:
| Soru | Hexagonal (Cockburn, 2005) | Onion (Palermo, 2008) |
|---|---|---|
| Ana ayrım | İçerisi ve dışarısı; sınırdaki port'lar | Merkezden dışa doğru halkalar |
| İç yapı | Hakkında bir şey söylemez | Domain Model, Domain Services, Application Services diye tarif eder |
| Merkezde ne var | "Uygulama"; nasıl modellendiği serbest | Nesne yönelimli bir domain model |
| Interface'lerin yeri | Port olarak uygulamanın sınırında | Onları kullanan iç halkada |
| Testler | Driving tarafta bir adapter | Açıkça en dış halkanın parçası |
Pratikte bir Onion çekirdeği, dışarıdan bakıldığında bir Hexagonal uygulama gibi davranır: IOrderRepository ve IShippingRates driven port'lar, endpoint'ler driving adapter'lar. Onion'ın eklediği şey, altıgenin içine bir harita çizmek.
Sık Düşülen Tuzaklar
Halkaları birer pass-through layer'a çevirmek. Her endpoint için bir application service, her service method'u için bir repository method'u, hepsinin tek satırla bir alttakini çağırması Onion değil, aynı layered mimarinin halka şeklinde çizilmiş hâli. Bir application service gerçekten bir akış yönetmiyorsa, dış halkanın iç halkayı doğrudan kullanmasına izin var.
Domain Model'i anemik bırakmak. Kurallar application service'lerde, entity'ler yalnız property torbası olursa merkezde korunacak bir şey kalmaz. Design Patterns serisindeki "Anti-Pattern'ler: God Object, Service Locator, Singleton Abuse, Anemic Domain Model ve Pattern Hastalığı" yazısı bu belirtiyi ayrıntılı anlatıyor.
Repository interface'ini veritabanı tablosuna göre tasarlamak. IOrderItemRepository, IBookRepository, ICustomerRepository diye her tabloya bir interface açmak iç halkayı veritabanı şemasına göre şekillendirir; bu da merkezi dışarıya bağlamanın dolaylı bir yolu. Interface'ler çekirdeğin ihtiyacına göre açılır.
"Common" ya da "Shared" bir project'i en içe koyup her şeyi oraya doldurmak. Merkez yalnız domain modeli içermeli; logging helper'ları, extension method'lar ve DTO'lar (Data Transfer Object) merkeze taşındıkça her değişiklik bütün halkaları etkiler.
Ne Zaman Kullanma
Palermo'nun "küçük web siteleri için uygun değil" uyarısı bir alçakgönüllülük cümlesi değil, bir maliyet hesabı. Onion; interface'ler, DI (Dependency Injection) kayıtları, domain ile persistence arasındaki mapping ve daha fazla project demek. Bu maliyet, uzun ömür ve karmaşık davranış varsa kendini geri öder: veri erişim teknolojisi birkaç kez değişecek, iş kuralları büyüyecek, ekip de bu kuralları altyapıdan bağımsız test etmek isteyecek. Birkaç form ve listeden oluşan, iş kuralı neredeyse olmayan, ömrü bir kampanya kadar kısa bir site için aynı yapı sadece fazladan dolaylılık getirir. Bookstore'un sipariş tarafı uzun ömürlü ve kuralları büyüyen bir alan olduğu için bu seride Onion'ın fikirlerine değer; bir yönetim panelindeki basit "yazar listesi" ekranı için değmeyebilir.
Küçük Bir Deneme
★☆☆ Kendi projende "en içteki" project'in referans verdiği assembly'leri bu yazıdaki testle listele. Listede bir ORM (Object-Relational Mapper), bir HTTP kütüphanesi ya da bir logging framework'ü var mı?
★★☆ Bookstore'a "aynı müşterinin ilk siparişinde kargo bedava" kuralını ekle. Kuralın hangi halkaya ait olduğuna karar ver; müşterinin önceki siparişlerini bulmak için gereken interface'i hangi halkada tanımlayacağını ve testini veritabanı olmadan nasıl yazacağını göster.
★★★ Çekirdeği üç project'e böl (DomainModel, DomainServices, ApplicationServices) ve bu yazıdaki Leak dosyasını tekrar ekle. Bu kez hangi hata kodunu alıyorsun? Bölmenin build süresine ve solution'ın okunabilirliğine etkisini not et.
Onion halkaları tarif etti ama halkalar arasında hangi verinin nasıl taşınacağı konusunda pek bir şey söylemedi. Bir sonraki yazıda Robert C. Martin'in 2012'de yazdığı Clean Architecture'a geçiyoruz: Dependency Rule, use case'ler ve bir sınırı geçen verinin neden entity değil basit bir veri yapısı olması gerektiği.