Clean Architecture ile .NET Projesi Kurmak: Katmanlar, Bağımlılıklar ve Gerçek Bir Örnek
Clean Architecture'ın .NET projelerinde nasıl uygulanacağını; Domain, Application, Infrastructure ve API katmanlarını, bağımlılık kuralını ve sık yapılan hataları bir e-ticaret örneği üzerinden anlatıyoruz.
Küçük bir projeye başlarken her şeyi tek bir projede toplamak hızlı ve mantıklıdır. Ama proje büyüdükçe controller'lar veritabanı sorgularıyla, iş kuralları HTTP detaylarıyla, e-posta gönderimi sipariş mantığıyla iç içe geçer. Bir noktadan sonra küçük bir değişiklik bile beklenmedik yerleri bozmaya başlar. Clean Architecture, tam da bu karmaşayı önlemek için bağımlılıkları belirli bir yöne zorlayan bir yaklaşımdır. Bu yazıda kavramları soyut bırakmadan, basit bir e-ticaret sipariş senaryosu üzerinden adım adım ilerleyeceğiz.
Clean Architecture'ın tek kuralı
Robert C. Martin'in popülerleştirdiği bu yaklaşımın özü tek bir cümleye sığar: Bağımlılıklar her zaman içe doğru akar. İç katmanlar dış katmanlar hakkında hiçbir şey bilmez.
- En içte Domain vardır: entity'ler, değer nesneleri ve iş kuralları.
- Onu Application sarar: kullanım senaryoları (use case), arayüzler, DTO'lar.
- Dışta Infrastructure (veritabanı, e-posta, ödeme servisi gibi teknik detaylar) ve Presentation/API (controller'lar, HTTP) bulunur.
Bu kuralın pratik sonucu şudur: iş mantığınız Entity Framework'ü, ASP.NET Core'u veya kullandığınız ödeme sağlayıcısını tanımaz. Yarın veritabanını değiştirmek veya bir iş kuralını test etmek, bütün uygulamayı ayağa kaldırmayı gerektirmez.
Mimari, kodun bugün değil altı ay sonra ne kadar kolay değiştirilebileceğini belirler.
Solution yapısı
Tipik bir .NET çözümü dört projeden oluşur:
Shop.sln
├── src/
│ ├── Shop.Domain/ (hiçbir projeye bağımlı değil)
│ ├── Shop.Application/ (→ Domain)
│ ├── Shop.Infrastructure/ (→ Application, Domain)
│ └── Shop.Api/ (→ Application, Infrastructure)
└── tests/
├── Shop.Domain.Tests/
└── Shop.Application.Tests/
Proje referansları bağımlılık kuralını derleyici seviyesinde garanti eder: Domain projesi Infrastructure'a referans vermediği sürece, yanlışlıkla bir entity içinde DbContext kullanamazsınız.
Domain katmanı: iş kuralları burada yaşar
Domain, uygulamanın kalbidir ve dış dünyaya hiçbir bağımlılığı yoktur; NuGet paketi bile mümkün olduğunca az olmalıdır. Burada entity'ler sadece veri torbası değil, kendi kurallarını koruyan nesnelerdir:
public class Order
{
private readonly List<OrderLine> _lines = [];
public Guid Id { get; private set; } = Guid.NewGuid();
public OrderStatus Status { get; private set; } = OrderStatus.Draft;
public IReadOnlyList<OrderLine> Lines => _lines;
public decimal Total => _lines.Sum(l => l.UnitPrice * l.Quantity);
public void AddLine(Guid productId, decimal unitPrice, int quantity)
{
if (Status != OrderStatus.Draft)
throw new DomainException("Onaylanmış siparişe ürün eklenemez.");
if (quantity <= 0)
throw new DomainException("Adet sıfırdan büyük olmalı.");
_lines.Add(new OrderLine(productId, unitPrice, quantity));
}
public void Confirm()
{
if (_lines.Count == 0) throw new DomainException("Boş sipariş onaylanamaz.");
Status = OrderStatus.Confirmed;
}
}
Dikkat edin: setter'lar private. Siparişin durumu dışarıdan keyfi olarak değiştirilemez; sadece Confirm() gibi anlamlı metotlarla değişir. Böylece "onaylanmış siparişe ürün eklemek" gibi geçersiz durumlar kodun hiçbir yerinde oluşamaz.
Application katmanı: kullanım senaryoları
Application katmanı "uygulama ne yapar?" sorusunu cevaplar: sipariş oluştur, siparişi onayla, ürünleri listele. Teknik detaylara ihtiyaç duyduğu yerde sadece arayüz tanımlar:
public interface IOrderRepository
{
Task<Order?> GetAsync(Guid id, CancellationToken ct);
Task AddAsync(Order order, CancellationToken ct);
}
public interface IEmailSender
{
Task SendAsync(string to, string subject, string body, CancellationToken ct);
}
public class ConfirmOrderHandler(IOrderRepository orders, IUnitOfWork uow, IEmailSender email)
{
public async Task HandleAsync(Guid orderId, CancellationToken ct)
{
var order = await orders.GetAsync(orderId, ct) ?? throw new NotFoundException("Sipariş bulunamadı.");
order.Confirm();
await uow.SaveChangesAsync(ct);
await email.SendAsync(order.CustomerEmail, "Siparişiniz onaylandı", $"#{order.Id}", ct);
}
}
Handler; siparişin SQL Server'da mı yoksa PostgreSQL'de mi tutulduğunu, e-postanın SMTP ile mi yoksa bir API ile mi gittiğini bilmez. Bu sayede birim testlerinde bu arayüzlerin sahte (fake) versiyonlarını vererek iş akışını saniyeler içinde test edebilirsiniz.
Bu katmanda MediatR gibi bir kütüphane ile command/query ayrımı (CQRS) yapmak yaygındır. Ancak zorunlu değildir; yukarıdaki gibi sade handler sınıfları da aynı faydayı sağlar. Kütüphane eklemek, ihtiyaçtan doğmalı.
Infrastructure katmanı: teknik detaylar
Application'da tanımlanan arayüzlerin gerçek uygulamaları burada yazılır:
public class OrderRepository(AppDbContext db) : IOrderRepository
{
public Task<Order?> GetAsync(Guid id, CancellationToken ct) =>
db.Orders.Include(o => o.Lines).FirstOrDefaultAsync(o => o.Id == id, ct);
public async Task AddAsync(Order order, CancellationToken ct) =>
await db.Orders.AddAsync(order, ct);
}
public static class DependencyInjection
{
public static IServiceCollection AddInfrastructure(this IServiceCollection services, IConfiguration config)
{
services.AddDbContext<AppDbContext>(o => o.UseSqlServer(config.GetConnectionString("Default")));
services.AddScoped<IOrderRepository, OrderRepository>();
services.AddScoped<IUnitOfWork>(sp => sp.GetRequiredService<AppDbContext>());
services.AddTransient<IEmailSender, SmtpEmailSender>();
return services;
}
}
EF Core yapılandırması (IEntityTypeConfiguration<T>), migration'lar, dış servis istemcileri ve dosya depolama gibi her şey bu katmanda kalır.
API katmanı: ince controller'lar
Clean Architecture'da controller'ların görevi çok basittir: HTTP isteğini al, ilgili handler'a ilet, sonucu HTTP yanıtına çevir.
[ApiController, Route("api/orders")]
public class OrdersController(ConfirmOrderHandler confirm) : ControllerBase
{
[HttpPost("{id:guid}/confirm")]
public async Task<IActionResult> Confirm(Guid id, CancellationToken ct)
{
await confirm.HandleAsync(id, ct);
return NoContent();
}
}
Program.cs ise sadece katmanları birbirine bağlar:
builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration);
Net sınırlar, birden fazla geliştiricinin aynı kod tabanında birbirini ezmeden çalışmasını sağlar.
Sık yapılan hatalar
1. Anemik domain modeli. Entity'ler sadece get; set; özelliklerinden oluşuyor, tüm kurallar servislerde dağınık duruyorsa Clean Architecture'ın en büyük faydasını kaçırıyorsunuz demektir. Kuralları ait oldukları nesneye taşıyın.
2. Her şey için repository ve arayüz. EF Core'un DbContext'i zaten bir unit of work, DbSet ise zaten bir repository'dir. Basit sorgular için her entity'ye ayrı bir generic repository yazmak çoğu zaman sadece kod tekrarıdır. Soyutlamayı, gerçekten değiştirilebilir olması gereken yerlerde kullanın.
3. Katmanları aşırı bölmek. Dört katman yerine on iki proje oluşturmak, mimariyi değil karmaşıklığı artırır. Başlangıçta dört proje çoğu ekip için yeterlidir.
4. Domain'e framework sızdırmak. Entity'lere [JsonIgnore] veya EF'e özgü öznitelikler eklemek, iç katmanın dış katmanı tanıması demektir. Bu yapılandırmaları Infrastructure'daki IEntityTypeConfiguration sınıflarına ve API'deki DTO'lara taşıyın.
5. Küçük projeye büyük mimari. Üç sayfalık bir yönetim paneli için Clean Architecture aşırı mühendislik olabilir. Mimari, projenin beklenen ömrüne ve karmaşıklığına göre seçilmeli.
Clean Architecture mı, Vertical Slice mı?
Son yıllarda popülerleşen Vertical Slice Architecture, kodu teknik katmanlara göre değil özelliklere göre (ör. Features/Orders/ConfirmOrder) gruplar. Her dilim kendi isteğini, handler'ını ve doğrulamasını içerir. İki yaklaşım birbirinin rakibi değildir: birçok ekip Domain katmanını korurken Application katmanını özellik bazlı dilimlerle organize eder. Önemli olan, bağımlılık yönünün korunması ve değişikliklerin tek bir yerde yapılabilmesidir.
| Kriter | Clean Architecture | Vertical Slice |
|---|---|---|
| Organizasyon | Teknik katmanlara göre | Özelliklere göre |
| Yeni özellik eklemek | Birkaç katmana dokunur | Genelde tek klasör |
| Paylaşılan iş kuralları | Domain'de merkezi | Dikkat edilmezse tekrar edebilir |
| Uygun olduğu yer | Karmaşık domain, uzun ömürlü proje | Çok sayıda bağımsız özellik |
Sonuç
Clean Architecture bir şablon değil, bir disiplindir: iş kurallarını teknik detaylardan korumak ve bağımlılıkları içe doğru yönlendirmek. Doğru uygulandığında test yazmak kolaylaşır, teknoloji değiştirmek korkutucu olmaktan çıkar ve ekip büyüdükçe kod tabanı dağılmaz. Küçük başlayın: dört proje, zengin bir domain modeli ve ince controller'lar. Gerisini proje ihtiyaç duydukça ekleyin.
Sık sorulan sorular
Clean Architecture ile Onion ve Hexagonal mimari arasındaki fark nedir?
Üçü de aynı fikrin farklı anlatımlarıdır: iş mantığını merkeze koymak ve dış dünyaya arayüzler (port) üzerinden bağlanmak. Terminoloji farklı olsa da bağımlılık kuralı ortaktır.
MediatR kullanmak zorunda mıyım?
Hayır. MediatR istekleri handler'lara yönlendirmeyi ve pipeline davranışlarını (loglama, validasyon) kolaylaştırır; ancak doğrudan handler enjekte etmek de tamamen geçerli ve daha sade bir yoldur.
Migration'lar hangi projede olmalı?
DbContext Infrastructure'da olduğu için migration'lar da genellikle orada tutulur. Komutu çalıştırırken başlangıç projesi olarak API projesini göstermeniz yeterlidir.