Clean Architecture ile .NET Proje Yapısı

Domain, Application, Infrastructure ve Web katmanlariyla bagimlilik yonu korunan .NET proje mimarisi.

Bir CMS’te “Yayımla” düğmesi ilk bakışta bir durum alanını güncelliyor gibi görünür. Oysa yayımlama, başlığı boş bir taslağı reddetmeli, yayımlanma zamanını kaydetmeli ve aynı istek tekrar gelirse ilk yayım tarihini korumalı. Bu kuralları controller’a koyarsanız yarın aynı işi bir zamanlanmış görevden çağırmak istediğinizde HTTP koduna bağımlı kalırsınız. Clean Architecture’da projenin yapısını bu kuralların nerede yaşaması gerektiğinden başlayarak kurarsınız.

Proje referanslarını içeri doğru kurun

Bu örnekte Domain, Post varlığını ve yayımlama kuralını barındırır. Application, taslağı bulup yayımlayan kullanım senaryosunu ve veri erişimi için ihtiyaç duyduğu IPostStore sözleşmesini tanımlar. Infrastructure, bu sözleşmeyi EF Core ile uygular. Api ise HTTP isteğini karşılar, kullanım senaryosunu çağırır ve sonucu bir HTTP yanıtına dönüştürür.

Application → Domain
Infrastructure → Application ve Domain
Api → Application
Api → Infrastructure (yalnızca başlangıçtaki kayıtlar için)

Oklar derleme zamanı proje referanslarını gösterir. Çalışma zamanında PublishPostService veri erişimini EfPostStore üzerinden yapar, ancak kodu yalnızca IPostStore’u bilir. Somut sınıfı seçen yer, uygulamanın bileşenlerinin bir araya getirildiği Program.cs dosyasıdır. Bu dosya Api projesinde olduğu için Api’nin Infrastructure referansına ihtiyacı vardır. Endpoint’ler ve controller’lar Application tarafındaki kullanım senaryosuyla konuşur.

Yayımlama kuralını Post içinde tutun

İş kuralı Post.Publish metodunda, kayıt akışı PublishPostService içinde durur. Publish, ilk yayımda zamanı kaydeder ve durum değiştiğinde true döndürür. Zaten yayımlanmış bir yazıda false dönmesi sayesinde aynı istek yeniden geldiğinde tarih korunur ve gereksiz bir kayıt yapılmaz. .NET 8 ve sonrasındaki TimeProvider, testte sabit bir zaman kullanmayı kolaylaştırır.

// Domain — Post sınıfından bir kesit
public DateTimeOffset? PublishedAtUtc { get; private set; }

public bool Publish(DateTimeOffset utcNow)
{
    if (PublishedAtUtc is not null)
        return false;

    if (string.IsNullOrWhiteSpace(Title))
        throw new PostCannotBePublishedException("Yayımlamak için başlık gerekli.");

    PublishedAtUtc = utcNow;
    return true;
}
// Application
public interface IPostStore
{
    Task<Post?> FindAsync(Guid id, CancellationToken ct);
    Task SaveChangesAsync(CancellationToken ct);
}

public sealed class PublishPostService(IPostStore posts, TimeProvider clock)
{
    public async Task<Guid> ExecuteAsync(Guid id, CancellationToken ct)
    {
        var post = await posts.FindAsync(id, ct)
            ?? throw new PostNotFoundException(id);

        if (post.Publish(clock.GetUtcNow()))
            await posts.SaveChangesAsync(ct);

        return post.Id;
    }
}

Bu kesitlerdeki özel hata tiplerinden PostCannotBePublishedException Domain’de, PostNotFoundException Application’da tanımlanır. Api, örneğin bulunamayan yazı için 404, yayımlama kuralını ihlal eden bir istek için 422 döndürebilir. Böylece aynı kullanım senaryosu bir arka plan görevinde çağrıldığında görev kendi hata işleme biçimini seçebilir.

EF Core’u başlangıçta bağlayın

Infrastructure’daki EfPostStore, yazıyı değişiklik takibi açık olan bir EF Core sorgusuyla yükler ve SaveChangesAsync çağrısını aynı DbContext’e iletir. Bu iki işlem aynı kapsamda çalışır. Application tarafındaki sözleşme ise dışarıya DbSet, IQueryable veya EF Core’a özgü başka bir tip açmaz. Böylece veri erişimiyle ilgili kararlar bağdaştırıcıda, kullanım senaryosu Application’da kalır.

// Api/Program.cs — EF Core ve DI kayıtları
var connectionString = builder.Configuration.GetConnectionString("Cms")
    ?? throw new InvalidOperationException("Cms bağlantı dizesi gerekli.");

builder.Services.AddDbContext<CmsDbContext>(options =>
    options.UseSqlServer(connectionString));
builder.Services.AddScoped<IPostStore, EfPostStore>();
builder.Services.AddScoped<PublishPostService>();
builder.Services.AddSingleton<TimeProvider>(TimeProvider.System);

Ardışık tekrarları güvenle karşılamak ile eşzamanlı yazmaları yönetmek iki farklı iştir. İki editör aynı taslağı aynı anda yüklerse ikisi de henüz yayımlanmadığını görebilir. İlk yayım zamanını korumak için EF Core modelinde bir eşzamanlılık belirteci kullanın. Bir yazma çakıştığında güncel durumu yeniden okuyup zaten yayımlanmış yazıyı başarılı kabul edebilir veya başka bir değişiklik varsa çakışmayı API katmanına taşıyabilirsiniz. Bu davranışı gerçek veritabanı sağlayıcısıyla bir entegrasyon testinde doğrulayın.

Sınırları bir değişiklikle sınayın

Domain testi boş başlığın reddedildiğini ve ikinci yayım denemesinde tarihin korunduğunu doğrudan Post üzerinde kontrol edebilir. Application testi ise bellekte çalışan bir IPostStore uygulaması ve sabit bir saatle kullanım senaryosunu çalıştırabilir. Örneğin, yazı bulunamadığında kayıt çağrısının yapılmadığını bu seviyede doğrulayabilirsiniz. Sorgunun değişiklik takibiyle çalıştığını ve sonucun veritabanına kaydedildiğini Infrastructure entegrasyon testine bırakın.

Başlığın yanında kapak görselini de zorunlu kılmak istediğinizi düşünün. Değişiklik Post’un yayımlama kuralına ve onun testine sığıyorsa sınır doğru yerdedir. Bağlantı dizesi, EF eşlemesi ve HTTP yanıtı bu karardan ayrı kalır. Yeni bir özellik eklerken önce kuralı ve onu çağıran kullanım senaryosunu bulun. Projeleri ayırmanın maliyeti, bu iki parçayı günlük geliştirmede gerçekten bağımsız tutabildiğinizde karşılığını verir.

Kaynaklar