Hexagonal Architecture (Ports & Adapters): Uygulamayı Dış Dünyadan Yalıtmak
Hexagonal Architecture (Cockburn, 2005), uygulamayı UI, test, batch script ve veritabanından eşit uzaklıkta tutar: dış dünya amaca göre tanımlanmış port'lar üzerinden konuşur, her teknoloji bir adapter ile takılır. Bookstore'da IPlaceOrder driving port'unu Minimal API, console CSV import ve xUnit testiyle, IOrderStore driven port'unu EF Core ve in-memory adapter'la sürüyoruz; ASP.NET Core'un IServer'ı ve altıgenle ilgili yanlış okumalar.
Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir.
Önce Tahmin Et
Bookstore'un müşteri hizmetleri ekibi telefonla gelen siparişleri gün boyu bir CSV (Comma-Separated Values) dosyasına yazıyor ve akşam toplu olarak sisteme aktarmak istiyor. Aynı stok kuralı, aynı kargo ücreti hesabı, aynı sipariş kaydı geçerli olmalı; web sitesinden verilen siparişten tek farkı, isteğin bir tarayıcıdan değil bir dosyadan gelmesi:
email,postalCode,bookId,quantity
ada@example.com,06100,1,2
alan@example.com,34000,2,1
grace@example.com,35000,1,2
Önceki yazının sonunda Ordering project'i hiçbir şeye referans vermiyordu ve iş kuralı PlaceOrderService içindeydi. Şimdi bu servisin önüne tek bir interface koyuyoruz:
// Driving port: dış dünya uygulamayı bu interface üzerinden sürer
public interface IPlaceOrder
{
Task<PlaceOrderResult> PlaceAsync(PlaceOrderCommand command, CancellationToken ct);
}
İki soru. CSV'yi okuyup bu interface'i çağıran console programı uygulamanın içinde mi durur, dışında mı? Ve Hexagonal Architecture'ın meşhur altıgeninde altı kenar varsa, Bookstore'un sipariş tarafının altı tane port'u mu olmalı?
Cockburn'ün 2005'teki Niyeti
Alistair Cockburn bu pattern'i 4 Eylül 2005 tarihli bir teknik rapor olarak, okur yorumlarından sonra güncelleneceği notuyla 0.9 sürümüyle yayımladı. Adı iki parçalı: pattern'in kendisi Ports and Adapters, alternatif adı Hexagonal Architecture. Raporun ilk cümlesi niyeti tek satırda söylüyor (UI burada user interface demek): "Create your application to work without either a UI or a database." Yani uygulamayı hem kullanıcı arayüzü olmadan hem de veritabanı olmadan çalışabilecek şekilde kur; böylece otomatik regresyon testleri koşabilsin, veritabanı erişilemezken de çalışabilsin, başka uygulamalarla insan müdahalesi olmadan bağlanabilsin.
Cockburn'ün teşhis ettiği problem tanıdık. İş mantığı UI koduna sızıyor; bu yüzden test edilmesi gereken mantık alan genişliği, buton yeri gibi sık değişen görsel detaylara bağlı kalıyor, sistemi bir insanın kullanımından toplu (batch) çalıştırmaya ya da başka bir programın sürmesine geçirmek imkânsızlaşıyor. Aynı şey veritabanı tarafında da oluyor: veritabanı çöktüğünde ya da değiştirildiğinde geliştirici çalışamıyor. Organizasyonların tekrar tekrar denediği çözüm, mimariye yeni bir layer eklemek; Cockburn bunun neden tutmadığını da yazıyor: layer çizimi bir sınır çiziyor ama kimse mantığın o sınırı geçmesini engellemiyor.
Pattern'in niyet cümlesi bu problemi tersinden tarif ediyor: uygulama kullanıcılar, programlar, otomatik testler ya da batch script'ler tarafından eşit şekilde sürülebilmeli ve sonunda çalışacağı cihazlardan ve veritabanlarından yalıtılmış olarak geliştirilip test edilebilmeli. "Eşit şekilde" kısmı önemli: web arayüzü uygulamanın sahibi değil, onu süren birkaç şeyden biri. Cockburn'e göre asıl ayrım sol ve sağ değil, içerisi ve dışarısı.
Port: Amaca Göre Konuşma, Adapter: Teknolojiye Çeviri
Raporda port, uygulamanın dış dünyayla belirli bir amaç için yaptığı konuşmanın kapısı. İşletim sistemindeki port'lardan ve elektronik cihazlardaki soketlerden esinlenilmiş: protokole uyan her cihaz takılabilir. Port'un protokolü bir API (Application Programming Interface). Adapter ise o API ile belirli bir teknoloji arasında çeviri yapan parça: GUI (graphical user interface), test harness, batch sürücüsü, HTTP (Hypertext Transfer Protocol) interface'i, gerçek veritabanı ya da bellekte çalışan bir mock veritabanı. Bir port'a birden fazla adapter takılabilir.
C#'ta port'un doğal karşılığı interface'in kendisi ve imzasındaki tipler. Bookstore'un sipariş tarafında üç tane var:
namespace Bookstore.Ordering;
// Driving port: dış dünya uygulamayı bu interface üzerinden sürer
public interface IPlaceOrder
{
Task<PlaceOrderResult> PlaceAsync(PlaceOrderCommand command, CancellationToken ct);
}
public sealed record PlaceOrderCommand(string CustomerEmail, string PostalCode, int BookId, int Quantity);
public abstract record PlaceOrderResult
{
public sealed record Placed(int OrderId, decimal Total) : PlaceOrderResult;
public sealed record Rejected(string Reason) : PlaceOrderResult;
}
// Driven port'lar: uygulama dış dünyayı bu interface'ler üzerinden sürer
public interface IOrderStore
{
Task<Book?> FindBookAsync(int bookId, CancellationToken ct);
Task<int> SaveAsync(Order order, Book reservedBook, CancellationToken ct);
}
public interface IShippingQuoteProvider
{
Task<decimal> QuoteAsync(string postalCode, int weightGrams, CancellationToken ct);
}
Port'ların adına dikkat et: hiçbirinde HTTP, SQL (Structured Query Language) ya da JSON (JavaScript Object Notation) geçmiyor. IPlaceOrder "sipariş ver" konuşmasını, IOrderStore "siparişi ve kitabı sakla" konuşmasını, IShippingQuoteProvider da "kargo ücretini sor" konuşmasını tarif ediyor. Sonuç tipi PlaceOrderResult de port'un parçası: stok yetmediğinde uygulama exception fırlatmıyor, bir Rejected döndürüyor; bu cevabın HTTP'de 409'a mı, console'da bir hata satırına mı dönüşeceğine adapter karar veriyor.
Port'un arkasındaki implementation, önceki yazıdakiyle neredeyse aynı; tek fark artık IPlaceOrder'ı implement etmesi ve sonucu bir değer olarak döndürmesi:
public sealed class PlaceOrderService(IOrderStore store, IShippingQuoteProvider shipping) : IPlaceOrder
{
private const int GramsPerBook = 400;
public async Task<PlaceOrderResult> PlaceAsync(PlaceOrderCommand command, CancellationToken ct)
{
Book? book = await store.FindBookAsync(command.BookId, ct);
if (book is null)
return new PlaceOrderResult.Rejected($"Book {command.BookId} not found.");
if (!book.TryReserve(command.Quantity))
return new PlaceOrderResult.Rejected($"Only {book.Stock} copies of '{book.Title}' left.");
decimal fee = await shipping.QuoteAsync(command.PostalCode, command.Quantity * GramsPerBook, ct);
Order order = new(command.CustomerEmail, command.PostalCode, book.Id, command.Quantity, book.Price, fee);
int id = await store.SaveAsync(order, book, ct);
return new PlaceOrderResult.Placed(id, order.Total);
}
}
Adapter kavramı, Design Patterns serisinde bir SDK (Software Development Kit) örneğiyle işlediğimiz "Adapter Pattern: Third-Party SDK'yı Kendi Interface'ine Uydurmak" yazısındaki GoF (Gang of Four: Gamma, Helm, Johnson ve Vlissides'in 1994 tarihli Design Patterns kitabı) Adapter'ının mimari ölçekteki hâli; Cockburn de raporunda kendi pattern'ini bunun özel bir uygulaması olarak anıyor.
Driving ve Driven Taraf
Cockburn port'ları iki gruba ayırırken use case dünyasındaki primary actor ve secondary actor ayrımını kullanıyor. Primary actor uygulamayı süren taraf: onu bekleme hâlinden çıkarıp sunduğu işlerden birini yaptırıyor. Secondary actor ise uygulamanın sürdüğü taraf: uygulama ona bir şey soruyor ya da sadece haber veriyor. Ayrımı belirleyen şey konuşmayı kimin başlattığı. Bugün daha çok kullanılan adlarla primary taraf driving, secondary taraf driven. Raporun çizimlerinde driving taraf solda (ya da üstte), driven taraf sağda (ya da altta) duruyor; Cockburn yine de bu sol-sağ asimetrisinin asıl mesele olmadığını, asıl simetrinin içeri-dışarı arasında olduğunu vurguluyor.

Bookstore'da IPlaceOrder driving port: web sitesi, CSV import ve test onu çağırıyor. IOrderStore ve IShippingQuoteProvider driven port: konuşmayı uygulama başlatıyor, veritabanı ve kargo firması cevap veriyor. Cockburn'ün test için önerisi de bu ayrıma göre şekilleniyor: driving tarafta en doğal ikame bir test framework'ü (o yıllarda FIT (Framework for Integrated Test)), driven tarafta ise bir mock. İki uçta da aynı şey oluyor: gerçek dünya yerine onu taklit eden bir adapter takılıyor ve uygulamanın kendisi bunu fark etmiyor.
Önceki yazıdaki kuralı da hatırla: adapter'lar port'a referans verir, tersi değil. Hem driving hem driven port'un interface'i içeride, Ordering project'inde duruyor; Minimal API endpoint'i de Entity Framework Core (EF Core) adapter'ı da Ordering'e bağımlı.
Üç Driving Adapter: Endpoint, Console ve Test
İlk driving adapter bir Minimal API endpoint'i. Görevi HTTP isteğini port'un diline, port'un cevabını da HTTP'nin diline çevirmek:
// Driving adapter: HTTP isteğini port'un diline çevirir
app.MapPost("/orders", async (PlaceOrderCommand command, IPlaceOrder placeOrder, CancellationToken ct) =>
await placeOrder.PlaceAsync(command, ct) switch
{
PlaceOrderResult.Placed placed => Results.Created($"/orders/{placed.OrderId}", placed),
PlaceOrderResult.Rejected rejected => Results.Problem(rejected.Reason, statusCode: 409),
_ => Results.StatusCode(500)
});
Stokta 3 adet Dune varken önce 2, sonra 2 adet daha sipariş ettiğimde gelen cevaplar (header'ların bir kısmını kısalttım):
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
Location: /orders/1
{"orderId":1,"total":540.0}
HTTP/1.1 409 Conflict
Content-Type: application/problem+json
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.10","title":"Conflict","status":409,"detail":"Only 1 copies of 'Dune' left."}
İkinci driving adapter, açılıştaki CSV'yi işleyen console programı. Aynı port'u, aynı komut tipiyle çağırıyor; sadece girdiyi dosyadan okuyup çıktıyı ekrana yazıyor:
// Driving adapter: telefonla alınan siparişlerin CSV dosyasını satır satır port'a verir
InMemoryOrderStore store = new(new Book(1, "Dune", 250m, stock: 3), new Book(2, "Emma", 180m, stock: 1));
IPlaceOrder placeOrder = new PlaceOrderService(store, new FlatRateShipping(40m));
foreach (string line in File.ReadLines(args[0]).Skip(1))
{
string[] f = line.Split(',');
PlaceOrderCommand command = new(f[0], f[1], int.Parse(f[2]), int.Parse(f[3]));
PlaceOrderResult result = await placeOrder.PlaceAsync(command, CancellationToken.None);
Console.WriteLine(result switch
{
PlaceOrderResult.Placed p => string.Create(CultureInfo.InvariantCulture, $"OK #{p.OrderId} {f[0]} total={p.Total}"),
PlaceOrderResult.Rejected r => $"FAIL {f[0]}: {r.Reason}",
_ => "?"
});
}
OK #1 ada@example.com total=540
OK #2 alan@example.com total=220
FAIL grace@example.com: Only 1 copies of 'Dune' left.
Bu örnekte console programı veritabanı yerine InMemoryOrderStore kullanıyor; gerçek import'ta composition root'ta EfOrderStore'u takmak yeterli. Açılıştaki sorunun cevabı da burada: console programı uygulamanın dışında, tıpkı web endpoint'i gibi bir driving adapter. İçeride değişen tek satır yok.
Üçüncü driving adapter test. Cockburn'ün raporunda FIT'in oynadığı rolü burada xUnit oynuyor:
// Driving adapter: test, uygulamayı UI ile aynı port'tan sürer
public sealed class PlaceOrderTests
{
private readonly FakeStore _store = new(new Book(1, "Dune", 250m, stock: 3));
private readonly IPlaceOrder _placeOrder;
public PlaceOrderTests() => _placeOrder = new PlaceOrderService(_store, new FakeShipping(40m));
// FakeStore ve FakeShipping: önceki yazıdaki gibi birkaç satırlık fake'ler
[Fact]
public async Task Rejects_when_stock_is_insufficient()
{
PlaceOrderResult result = await _placeOrder.PlaceAsync(
new PlaceOrderCommand("ada@example.com", "06100", BookId: 1, Quantity: 5), CancellationToken.None);
PlaceOrderResult.Rejected rejected = Assert.IsType<PlaceOrderResult.Rejected>(result);
Assert.Equal("Only 3 copies of 'Dune' left.", rejected.Reason);
Assert.Empty(_store.Saved);
}
}
Passed! - Failed: 0, Passed: 2, Skipped: 0, Total: 2, Duration: 8 ms - Bookstore.Ordering.Tests.dll (net10.0)
Üç adapter da aynı PlaceOrderCommand'ı kuruyor, aynı PlaceOrderResult'ı yorumluyor. Testin endpoint'ten geçmeden doğrudan port'u çağırması bir kısayol değil; Cockburn'ün tarif ettiği şey tam olarak bu: test, UI ile eşit haklara sahip bir sürücü.
Driven Adapter'lar: EF Core ve In-Memory
Sağ tarafta IOrderStore için iki adapter var. Biri EF Core ile SQLite'a yazıyor (örneklerde SQLite kullanıyoruz; Bookstore'un üretimdeki veritabanı SQL Server ve fark yalnız provider satırında):
public sealed class EfOrderStore(BookstoreDbContext db) : IOrderStore
{
public async Task<Book?> FindBookAsync(int bookId, CancellationToken ct) =>
await db.Books.Where(b => b.Id == bookId)
.Select(b => new Book(b.Id, b.Title, b.Price, b.Stock))
.SingleOrDefaultAsync(ct);
public async Task<int> SaveAsync(Order order, Book reservedBook, CancellationToken ct)
{
BookRow row = await db.Books.SingleAsync(b => b.Id == reservedBook.Id, ct);
row.Stock = reservedBook.Stock;
OrderRow orderRow = new()
{
CustomerEmail = order.CustomerEmail, PostalCode = order.PostalCode, BookId = order.BookId,
Quantity = order.Quantity, UnitPrice = order.UnitPrice, ShippingFee = order.ShippingFee
};
db.Orders.Add(orderRow);
await db.SaveChangesAsync(ct);
return orderRow.Id;
}
}
Diğeri bellekte çalışan InMemoryOrderStore; console örneğinde onu kullandık. Kargo tarafında da durum aynı: önceki yazıdaki HTTP adapter'ı OldCarrierQuoteProvider ve sabit ücret döndüren FlatRateShipping aynı port'u karşılıyor. Hangisinin takılacağına composition root karar veriyor:
// Composition root: hangi adapter hangi port'a takılacak, yalnız burada karar verilir
builder.Services.AddDbContext<BookstoreDbContext>(o => o.UseSqlite("Data Source=bookstore.db"));
builder.Services.AddScoped<IOrderStore, EfOrderStore>();
builder.Services.AddSingleton<IShippingQuoteProvider>(new FlatRateShipping(40m));
builder.Services.AddScoped<IPlaceOrder, PlaceOrderService>();
Cockburn raporunda bir ekibin geliştirme sırasını şöyle önerir: önce test harness ve in-memory veritabanıyla, sonra GUI ekleyip yine in-memory veritabanıyla, ardından entegrasyon testlerinde gerçek bir test veritabanıyla, en sonda canlı veritabanıyla. Bu sıranın her adımında uygulamanın içi aynı kalıyor, sadece port'lara takılan adapter'lar değişiyor. Bookstore'da da UI ekibi API'yi tasarlarken sipariş kuralları InMemoryOrderStore ile geliştirilip test edilebilir; veritabanı şeması netleştiğinde EfOrderStore takılır.
.NET'in İçinde Port ve Adapter: IServer
ASP.NET Core'un kendisi bu pattern'in güzel bir örneği. Uygulamanın request pipeline'ı, sunucunun sürdüğü bir port'un arkasında duruyor: Microsoft.AspNetCore.Hosting.Server.IServer interface'i, uygulamayı IHttpApplication<TContext> üzerinden sürüyor. Shared framework'teki assembly'leri ve Microsoft.AspNetCore.TestHost paketini reflection ile taradığımda IServer'ı implement eden concrete class'lar şunlar çıktı:
System.Threading.Tasks.Task StartAsync[TContext](Microsoft.AspNetCore.Hosting.Server.IHttpApplication`1[TContext], System.Threading.CancellationToken)
Microsoft.AspNetCore.Server.HttpSys.MessagePump (internal) in Microsoft.AspNetCore.Server.HttpSys
Microsoft.AspNetCore.Server.IIS.Core.IISHttpServer (internal) in Microsoft.AspNetCore.Server.IIS
Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServerImpl (internal) in Microsoft.AspNetCore.Server.Kestrel.Core
Microsoft.AspNetCore.Server.Kestrel.Core.KestrelServer (public) in Microsoft.AspNetCore.Server.Kestrel.Core
Microsoft.AspNetCore.TestHost.TestServer (public) in Microsoft.AspNetCore.TestHost
Kestrel, HTTP.sys ve IIS (Internet Information Services) gerçek ağ trafiğini uygulamaya çeviren driving adapter'lar. TestServer ise ağ hiç kullanmadan, bellekteki bir isteği aynı pipeline'a veren adapter; ASP.NET Core Web API serisinin "ASP.NET Core Web API'de Integration Test: WebApplicationFactory ile Uçtan Uca Test" yazısında kullandığımız WebApplicationFactory arka planda bunu takıyor. Middleware'lerin, endpoint'lerin hangi sunucunun arkasında çalıştığından haberi yok. Cockburn'ün "uygulama testlerle ve gerçek kullanıcılarla eşit şekilde sürülebilmeli" cümlesinin framework seviyesindeki hâli bu.
Yanlış Okumalar
Altıgenin altı kenarı bir anlam taşımıyor. Cockburn bunu açıkça söylüyor: şekil, çizen kişiye port ve adapter ekleyecek yer bırakmak ve tek boyutlu layer çiziminden kurtulmak için seçilmiş. Bookstore'un altı port'a ihtiyacı yok.
Port sayısı küçük kalır. Raporda Cockburn'ün tercihi iki, üç ya da dört port; o güne kadar karşılaştığı en büyük sayı dört. Hava durumu uyarılarını abonelere telefonla bildiren örnek sistemde dört port var: hava durumu beslemesi, yönetim, bildirim gönderilen aboneler ve abone veritabanı. Burada port'u bir konuşma olarak say, interface olarak değil: siparişle ilgili birden çok use case aynı driving port'un parçası olabilir ve C#'ta bunlar birkaç ayrı interface olarak durabilir. Her interface'e bir port demek, port sayısını anlamsız şekilde şişirir.
Hexagonal bir layer yığını değil. Ortada "Domain layer", etrafında "Application layer" gibi halkalar çizmek Hexagonal'ın değil, Onion ve Clean'in konusu. Cockburn'ün pattern'i içerinin nasıl organize edileceği hakkında hiçbir şey söylemiyor; sadece içerisi ile dışarısı arasındaki sınırı ve o sınırdaki kapıları tarif ediyor. İçeride tek bir class da olabilir, zengin bir domain modeli de.
Adapter teknoloji başına, port amaç başına. IEfCoreOrderStore ya da IHttpShippingClient gibi port isimleri port'u teknolojiye bağlar; port'un adı amacı, adapter'ın adı teknolojiyi söylemeli.
Sık Düşülen Tuzaklar
Driving port'u atlayıp endpoint'ten doğrudan servisi kurmak. Endpoint'in içinde new PlaceOrderService(new EfOrderStore(...), ...) yazmak, adapter seçimini composition root'tan alıp bir driving adapter'ın içine gömer. Console ve test aynı seçimi tekrar yapmak zorunda kalır.
İş kuralını adapter'a kaçırmak. "409 dönmeden önce stok sıfırsa kampanya kodunu iptal et" gibi bir kural endpoint'e yazılırsa console import'u o kuralı atlar. Adapter çevirir, karar vermez.
Port imzasına teknoloji tipi koymak. PlaceAsync(HttpContext context) ya da Task<IQueryable<Book>> döndüren bir driven port, dışarıyı içeri taşır. İçeri giren her tip içerinin dilinde olmalı.
Her şeyi port yapmak. DateTime.UtcNow için IClock, log için ILogger gibi .NET'in zaten sunduğu abstraction'ları yeniden port diye sarmak gereksiz; .NET 8'den beri test edilebilir zaman için TimeProvider var. Port, gerçekten değiştirilebilir bir dış dünya parçası için açılır.
Ne Zaman Kullanma
Uygulamanın tek bir sürücüsü (bir web arayüzü) ve tek bir driven tarafı (bir veritabanı) varsa ve bu yakın gelecekte değişmeyecekse, driving port'u ayrı bir interface olarak tanımlamak pek bir şey kazandırmaz; endpoint servisi doğrudan çağırabilir. Driven tarafta interface'i korumak ise genelde ucuzdur ve testi kolaylaştırır. Değer, ikinci bir sürücü (batch, mesaj kuyruğu, başka bir uygulama) ya da sık değişen bir dış servis ortaya çıktığında belirginleşir.
Küçük Bir Deneme
★☆☆ Kendi projende uygulamayı süren her şeyi listele: web endpoint'leri, background job'lar, mesaj tüketicileri, test'ler. Kaç tanesi iş kuralını aynı giriş noktasından çağırıyor, kaç tanesi kendi kopyasını taşıyor?
★★☆ Bookstore'a dördüncü bir driving adapter ekle: bir queue'dan gelen sipariş mesajlarını işleyen bir BackgroundService. IPlaceOrder'a ve PlaceOrderResult'a dokunmadan yazabildiğini doğrula.
★★★ IOrderStore için bir contract test seti yaz: aynı test class'ı hem InMemoryOrderStore'u hem de SQLite in-memory veritabanı üzerindeki EfOrderStore'u koşsun. İki adapter'ın davranışının nerede ayrıştığını bul (ör. aynı kitap için art arda iki kayıt).
Hexagonal Architecture içeriyi kara kutu olarak bıraktı. Bir sonraki yazıda Jeffrey Palermo'nun 2008'de adını koyduğu Onion Architecture'a geçiyoruz; o, aynı içerinin halkalara nasıl bölüneceğini ve coupling'in neden hep merkeze doğru olması gerektiğini söylüyor.