· 5 dk okuma
Clean Architecture Bağımlılık Kuralını Testle Zorunlu Kılmak: NetArchTest
Clean Architecture'ın tek bir temel kuralı var: bağımlılıklar yalnızca içe doğru akar. İş kuralları veritabanını, web çatısını ya da herhangi bir dış kütüphaneyi tanımaz. Kâğıt üzerinde basit, ama projede bu kuralı kimin koruduğu belli değilse ilk acele günü bozuluyor. Bu yazıda, geliştirdiğim teknik servis takip uygulaması Tamirhane'de bu kuralı NetArchTest ile nasıl teste çevirdiğimi ve testin gerçekten ihlal yakaladığını nasıl denediğimi anlatıyorum.
Katmanlar ve kural
Tamirhane dört projeden oluşuyor:
Tamirhane.Web ──► Tamirhane.Application ──► Tamirhane.Domain
│ ▲
└──► Tamirhane.Infrastructure
- Domain: Varlıklar ve iş kuralları. İş emrinin durum akışı, teklif toplamları, stok ve garanti kuralları burada. Hiçbir projeye ve hiçbir NuGet paketine bağlı değil.
- Application: Kullanım senaryoları, yani "cihazı teslim al", "onarıma başla" gibi işler. Depo, e-posta ve saat gibi dış hizmetleri sadece arayüz olarak tanıyor:
IWorkOrderRepository,ICurrentUser,IUnitOfWork. - Infrastructure: Bu arayüzlerin uygulamaları: EF Core ve PostgreSQL, e-posta, bildirimler.
- Web: ASP.NET Core MVC, controller'lar ve view'lar.
Bu düzenin faydası şu: iş kurallarını veritabanı olmadan, saniyeler içinde test edebiliyorum. Yarın PostgreSQL yerine başka bir veritabanı gelse Domain ve Application'da tek satır değişmiyor.
Derleyici kuralın sadece bir kısmını koruyor
İlk akla gelen soru şu olabilir: proje referansları zaten bunu sağlamıyor mu? Kısmen sağlıyor. Application projesinde Infrastructure'a referans olmadığı için Application içinde TamirhaneDbContext yazarsam kod derlenmez.
Ama iki boşluk var.
Birincisi Web projesi. Uygulamanın başlangıç noktası Program.cs, Infrastructure'daki servisleri kaydetmek zorunda:
builder.Services.AddInfrastructure(connectionString);
Bunun için Web, Infrastructure'a referans veriyor. Bu referans bir kez eklenince Web'deki her dosya Infrastructure'ı görebiliyor. Bir controller'a TamirhaneDbContext enjekte edip veritabanını doğrudan sorgulamak derleyici açısından tamamen geçerli. Bunu tek bir Program.cs için açılmış bir kapı olarak düşünüyorum: kapı lazım, ama bütün controller'ların ondan geçmesini istemiyorum.
İkincisi kurallar referanslarla ifade edilemiyor. Örneğin Tamirhane'de bir iş emrinin durumu dışarıdan atanamamalı. workOrder.Status = InRepair yazmak yerine domain metodu çağrılmalı, çünkü kural metodun içinde:
public Result StartRepair(Guid staffId, DateTimeOffset now) =>
Status == WorkOrderStatus.Diagnosing
? MoveTo(WorkOrderStatus.InRepair, staffId, now, null)
: WorkOrderErrors.InvalidTransition;
Bir özelliğin setter'ı private yerine public yazılırsa derleyici bir şey demez. İş emri birden "teşhis" aşamasını atlayıp "onarımda" durumuna geçebilir.
Bu kuralları kod incelemesinde gözle yakalamaya güvenmek istemedim. Kural bir testse, bozulduğu an kırmızı görünür.
NetArchTest ile kuralı test olarak yazmak
NetArchTest, derlenmiş assembly'lerin içindeki tipleri tarayıp "bu tipler şuna bağımlı olmamalı" gibi kurallar yazmayı sağlayan küçük bir kütüphane. Tamirhane'de ayrı bir test projesi var: Tamirhane.Architecture.Tests. İçinde beş test bulunuyor.
1. Domain hiçbir katmana ve çatıya bağlı değil
[Fact]
public void Domain_depends_on_no_other_layer_or_framework()
{
AssertNoDependency(Types.InAssembly(Domain),
ApplicationNs, InfrastructureNs, WebNs, EfCore, AspNetCore, "Microsoft.Extensions");
}
2. Domain sadece .NET'in kendisine referans veriyor
İlk test tipler üzerinden bakıyor. Bunu bir de assembly referanslarıyla kontrol ediyorum. System ile başlamayan her referans hata sayılıyor:
[Fact]
public void Domain_assembly_references_only_the_runtime()
{
var references = Domain.GetReferencedAssemblies().Select(a => a.Name!).ToList();
var foreign = references.Where(n => !(n.StartsWith("System") || n is "netstandard" or "mscorlib")).ToList();
Assert.True(foreign.Count == 0, $"Domain yalnızca .NET'e bağlı olmalı; bulunan: {string.Join(", ", foreign)}");
}
3. Application sadece Domain'e bağlı
[Fact]
public void Application_depends_only_on_domain()
{
AssertNoDependency(Types.InAssembly(Application),
InfrastructureNs, WebNs, EfCore, AspNetCore);
}
Burada Microsoft.Extensions yasak listesinde yok, çünkü Application kendi kullanım senaryolarını servis olarak kaydederken Microsoft.Extensions.DependencyInjection.Abstractions paketini kullanıyor. Yasak listesini projenin gerçek kararlarına göre yazmak gerekiyor; her şeyi yasaklayan bir test ilk gün kırmızı olur ve kimse ona güvenmez.
4. Controller'lar Infrastructure'ı kullanmıyor
Derleyicinin göremediği ilk boşluk bu testle kapanıyor:
[Fact]
public void Controllers_do_not_use_infrastructure_directly()
{
// Web, Infrastructure'ı sadece Program.cs'te servis kaydı için tanır.
AssertNoDependency(Types.InAssembly(Web).That().ResideInNamespace($"{WebNs}.Controllers"),
InfrastructureNs, EfCore);
}
5. Varlıklar dışarıdan değiştirilemiyor
İkinci boşluk NetArchTest'in hazır kurallarıyla ifade edilemiyor. Onu düz yansıma (reflection) ile yazdım: Entity'den türeyen her sınıfın public ya da internal setter'ı olan özellikleri listeleniyor.
[Fact]
public void Domain_entities_cannot_be_changed_from_outside()
{
var publicSetters = Domain.GetTypes()
.Where(t => t.IsSubclassOf(typeof(Entity)))
.SelectMany(t => t.GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly))
.Where(p => p.SetMethod is { IsPublic: true } || p.SetMethod is { IsAssembly: true })
.Select(p => $"{p.DeclaringType!.Name}.{p.Name}")
.ToList();
Assert.True(publicSetters.Count == 0, $"Dışarıdan atanabilen özellikler: {string.Join(", ", publicSetters)}");
}
Hata mesajı da işin parçası
Bir mimari testi kırıldığında "Assert.True failed" yazması işe yaramaz. Kimin kuralı bozduğunu aramak gerekir. Bu yüzden ortak bir yardımcı, NetArchTest'in sonucundaki FailingTypeNames listesini mesaja yazıyor:
private static void AssertNoDependency(TestResult result, string[] forbidden)
{
var offenders = result.FailingTypeNames ?? [];
Assert.True(result.IsSuccessful,
$"Şunlara bağımlı olmamalı: {string.Join(", ", forbidden)}. Kuralı bozan tipler: {string.Join(", ", offenders)}");
}
Test gerçekten yakalıyor mu?
Bir testin yeşil olması, işe yaradığını göstermez. Belki hiçbir zaman kırmızı olmuyordur. Bu yüzden repoyu geçici bir klasöre kopyalayıp bilerek kural ihlalleri ekledim.
Başlangıç: 5 testin 5'i geçti.
İlk deneme: İki ihlal ekledim. Birincisi veritabanını doğrudan sorgulayan bir controller:
public class ShortcutController(TamirhaneDbContext db) : Controller
{
public async Task<IActionResult> Index() => Json(await db.Customers.CountAsync());
}
İkincisi Customer sınıfında Name özelliğinin setter'ını private set yerine set yaptım.
Proje hatasız derlendi. Derleyici iki ihlali de görmedi. Mimari testleri ise iki ihlali de yakaladı:
Başarısız Controllers_do_not_use_infrastructure_directly
Şunlara bağımlı olmamalı: Tamirhane.Infrastructure, Microsoft.EntityFrameworkCore.
Kuralı bozan tipler: Tamirhane.Web.Controllers.ShortcutController
Başarısız Domain_entities_cannot_be_changed_from_outside
Dışarıdan atanabilen özellikler: Customer.Name
İkinci deneme: Değişiklikleri geri alıp Domain projesine Microsoft.Extensions.Logging.Abstractions paketini ekledim ve bu paketi kullanan küçük bir sınıf yazdım. Bu sefer Domain'le ilgili iki test birden kırıldı. Biri tip bağımlılığını, diğeri assembly referansını yakaladı:
Başarısız Domain_assembly_references_only_the_runtime
Domain yalnızca .NET'e bağlı olmalı; bulunan: Microsoft.Extensions.Logging.Abstractions
Başarısız Domain_depends_on_no_other_layer_or_framework
Kuralı bozan tipler: Tamirhane.Domain.Common.DomainLog
Her iki denemede de mesaj, düzeltilmesi gereken sınıfın adını veriyor. Beş test bir saniyenin altında bitiyor, yani bütün test takımıyla birlikte her çalıştırmada koşmaları bir maliyet değil.
Neyi yakalamaz?
Mimari testleri her şeyi garanti etmez, sınırlarını bilmek gerekiyor:
- Sadece yazdığım kuralları kontrol eder. Yeni bir katman ya da yeni bir yasak çıkarsa testi de güncellemek gerekir.
- Kodun doğru çalışıp çalışmadığını söylemez. Bir iş kuralının doğruluğu Domain ve Application testlerinin işi.
- Tamirhane'de şu an CI yok; testler yerelde, bütün test takımıyla birlikte çalışıyor. CI eklendiğinde bu testler pull request'leri birleşmeden önce de durduracak.
Özet
- Clean Architecture'da bağımlılık kuralının bir kısmını proje referansları zaten zorluyor, ama composition root için açılan referans ve setter'lar gibi kurallar derleyicinin dışında kalıyor.
- NetArchTest ile bu kuralları beş kısa teste çevirdim. Biri NetArchTest yerine düz reflection kullanıyor.
- Hata mesajı kuralı bozan tipin adını veriyor.
- Testlerin gerçekten işe yaradığını bilerek ihlal ekleyerek denedim: derleyicinin geçirdiği üç ihlali testler yakaladı.
Tamirhane'nin kaynak kodu GitHub'da: github.com/MrcDprm/tamirhane-repair-tracker. Proje hâlâ geliştiriliyor; bitince ayrıntılı bir proje yazısıyla burada olacak.