Mert Özen Her Satırda Daha İleri
Yazılım Mimarisi Clean, Onion ve Hexagonal Architecture — #08

Clean Architecture'ı Test Etmek ve Korumak: Core Test'leri, Adapter Test'leri ve Architecture Test'leri

Mert Özen 7 Kas 2026 16 dk 5 görüntülenme
Clean Architecture'ı Test Etmek ve Korumak: Core Test'leri, Adapter Test'leri ve Architecture Test'leri

Clean Architecture'da core'u in-memory adapter'larla veritabanı olmadan, adapter'ları Testcontainers ile üretimdeki SQL Server'a karşı test edersin; Dependency Rule'u ise ArchUnitNET kurallarıyla CI'da kırılmaz hale getirirsin. SQLite'ın gizlediği decimal hassasiyeti sorunu, derleyicinin yakalamadığı ihlaller, Types(true) olmadan hiçbir şey seçmeyen kural tuzağı ve NetArchTest'in 2021'den beri güncellenmediği notu.

Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir.

Önce Tahmin Et

Müşteri panelinde "siparişlerim" ekranı isteniyor. Ekibe yeni katılan bir arkadaş "sadece okuma, repository'ye method eklemeye değmez" diyor ve kısa yoldan gidiyor: Application'a Entity Framework Core'un (EF Core) paketini ekliyor, IQueryable veren küçük bir interface tanımlıyor ve handler'da doğrudan ToListAsync çağırıyor.

<ItemGroup>
  <PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" />
  <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
  <PackageReference Include="Scrutor" />
  <PackageReference Include="Microsoft.EntityFrameworkCore" />
</ItemGroup>
using Bookstore.Domain.Ordering;
using Microsoft.EntityFrameworkCore;

namespace Bookstore.Application.Ordering.Queries;

// "Sadece okuma, repository'ye method eklemeye değmez" diye eklenmiş kısayol
public interface IOrderQueries
{
    IQueryable<Order> Orders { get; }
}

public sealed record CustomerOrdersQuery(string CustomerEmail);
public sealed record CustomerOrderSummary(int OrderId, decimal ShippingFee);

public sealed class CustomerOrdersHandler(IOrderQueries queries)
    : IUseCaseHandler<CustomerOrdersQuery, IReadOnlyList<CustomerOrderSummary>>
{
    public async Task<Result<IReadOnlyList<CustomerOrderSummary>>> HandleAsync(CustomerOrdersQuery query, CancellationToken ct) =>
        await queries.Orders
            .Where(o => o.CustomerEmail == query.CustomerEmail)
            .Select(o => new CustomerOrderSummary(o.Id, o.ShippingFee))
            .ToListAsync(ct);
}

Önceki yazılarda kurduğumuz Bookstore'da şu güvenceler var: 05'teki GuardDomainDependencies target'ı Domain'e paket eklenince build'i durduruyor, Domain'den Infrastructure'a project reference eklenemiyor, BookstoreDbContext internal olduğu için Web onu göremiyor, use case'lerin unit test'leri ve adapter'ların integration test'leri var. Soru şu: bu pull request'te hangisi alarm verir? Derleyici mi, test'ler mi, yoksa code review'da yorgun bir göz mü?

Üç Soru, Üç Tür Test

Cevap: hiçbiri. Build temiz geçiyor; core ve adapter test'lerinin dokuzu da yeşil (5 + 4, bu yazıda birazdan göreceğin test'ler). Guard yalnızca Domain'e bakıyor, Application'ın paket eklemesi serbest; project reference grafiğinde döngü yok. Ama Dependency Rule delinmiş: Use Cases çemberi bir framework'ün detayını, EF Core'un sorgu extension'larını biliyor. 07. yazıda gördüğümüz IQueryable sızıntısı bu sefer kasıtlı olarak geri gelmiş. Bu yazı bu tür bir ihlali yakalayan test'i yazmakla bitiyor. Oraya gelmeden önce bir Clean Architecture solution'ının test stratejisine bakalım.

Microsoft'un "Architect Modern Web Applications with ASP.NET Core and Azure" e-kitabı Clean Architecture'ı anlatırken test'leri iki resimle gösteriyor: Application Core'u izole unit test etmek ve Infrastructure implementasyonlarını dış bağımlılıklarıyla integration test etmek. Buna mimarinin kendisini test etmeyi eklediğimizde üç soru çıkıyor:

SoruTest türüBookstore'daNe kadar hızlı
Use case doğru karar veriyor mu?Core unit test'iHandler + in-memory adapter'larMilisaniyeler, Docker yok
Adapter dış dünyayla doğru konuşuyor mu?Adapter integration test'iEF Core + gerçek SQL (Structured Query Language) Server, HTTP (Hypertext Transfer Protocol) adapter'ı + sahte handlerSaniyeler, Docker gerekli
Bağımlılıklar hâlâ içe mi bakıyor?Architecture test'iArchUnitNET kurallarıMilisaniyeler, derlenmiş assembly üzerinde

Core'u Veritabanı Olmadan Test Etmek

05'te Application test'lerini dört port'u birden implement eden tek bir Fakes class'ıyla başlatmıştık. Use case sayısı arttıkça her port'un kendi sahte implementasyonu daha okunaklı oluyor:

using Bookstore.Application.Ordering;
using Bookstore.Domain.Catalog;
using Bookstore.Domain.Ordering;

namespace Bookstore.Application.Tests;

// Driven port'ların test adapter'ları: veritabanı yok, ağ yok
internal sealed class InMemoryBooks(params Book[] books) : IBookRepository
{
    public Task<Book?> FindAsync(int bookId, CancellationToken ct) => Task.FromResult(books.FirstOrDefault(b => b.Id == bookId));
}

internal sealed class InMemoryOrders : IOrderRepository
{
    public List<Order> Added { get; } = [];
    public void Add(Order order) => Added.Add(order);
}

internal sealed class CountingUnitOfWork : IUnitOfWork
{
    public int Saves { get; private set; }
    public Task SaveChangesAsync(CancellationToken ct) { Saves++; return Task.CompletedTask; }
}

internal sealed class FixedShippingRates(decimal? fee) : IShippingRates
{
    public Task<decimal?> QuoteAsync(string postalCode, int weightGrams, CancellationToken ct) => Task.FromResult(fee);
}

Bunlar mock değil, port'ların gerçek ama basit implementasyonları. 07'deki FlatRateShipping gibi; Hexagonal Architecture'ın test'i bir driving adapter, in-memory store'u da bir driven adapter olarak görme fikrinin karşılığı. Mock library'si ile "Add bir kez çağrıldı mı?" diye sormak yerine, test sonunda listede bir sipariş olup olmadığına bakıyoruz; handler'ın iç adımları değişse de test kırılmıyor. ASP.NET Core Web API (Application Programming Interface) serisinin "ASP.NET Core Web API'de Unit Test: xUnit ve Moq ile Service ve Controller Testleri" yazısında Moq'u ayrıntılı kullanmıştık; ikisi de geçerli, ama port'lar küçük kaldıkça elle yazılmış bir sahte daha okunaklı oluyor.

using Bookstore.Application.Ordering;
using Bookstore.Domain.Catalog;

namespace Bookstore.Application.Tests;

public sealed class PlaceOrderHandlerTests
{
    private readonly InMemoryOrders _orders = new();
    private readonly CountingUnitOfWork _unitOfWork = new();

    private PlaceOrderHandler CreateHandler(Book book, decimal? shippingFee = 40m) =>
        new(new InMemoryBooks(book), _orders, new FixedShippingRates(shippingFee), _unitOfWork);

    private static PlaceOrderCommand Order(int bookId, int quantity) => new("ada@example.com", "06100", bookId, quantity);

    [Fact]
    public async Task Places_order_reserves_stock_and_saves_once()
    {
        Book dune = new(1, "Dune", 250m, 3);

        Result<PlaceOrderResponse> result = await CreateHandler(dune).HandleAsync(Order(1, 1), CancellationToken.None);

        Assert.Equal(290m, result.Value!.Total);
        Assert.Equal(2, dune.Stock);
        Assert.Single(_orders.Added);
        Assert.Equal(1, _unitOfWork.Saves);
    }

    [Fact]
    public async Task Free_shipping_from_500_upwards()
    {
        Result<PlaceOrderResponse> result = await CreateHandler(new Book(1, "Dune", 250m, 3)).HandleAsync(Order(1, 2), CancellationToken.None);

        Assert.Equal(0m, result.Value!.ShippingFee);
    }

    [Theory]
    [InlineData(42, 1, "book.not_found", ErrorKind.NotFound)]
    [InlineData(1, 5, "book.out_of_stock", ErrorKind.Conflict)]
    public async Task Expected_failures_come_back_as_errors_and_save_nothing(int bookId, int quantity, string code, ErrorKind kind)
    {
        Result<PlaceOrderResponse> result = await CreateHandler(new Book(1, "Dune", 250m, 3)).HandleAsync(Order(bookId, quantity), CancellationToken.None);

        Assert.Equal((code, kind), (result.Error!.Code, result.Error.Kind));
        Assert.Equal(0, _unitOfWork.Saves);
    }

    [Fact]
    public async Task Unserved_destination_is_a_validation_error_and_stock_is_not_saved()
    {
        Book dune = new(1, "Dune", 250m, 3);

        Result<PlaceOrderResponse> result = await CreateHandler(dune, shippingFee: null).HandleAsync(Order(1, 1), CancellationToken.None);

        Assert.Equal(ErrorKind.Validation, result.Error!.Kind);
        Assert.Empty(_orders.Added);
        Assert.Equal(0, _unitOfWork.Saves);
    }
}

Son test 07'de port'a eklediğimiz null dönüşünü sabitliyor: kargo firması teslimat yapmıyorsa stok kaydedilmiyor. Ücretsiz kargo eşiği, bulunamayan kitap ve stok yetersizliği de birer satırlık senaryolar. 05'teki gibi xUnit 2.9.3 ile:

Passed!  - Failed:     0, Passed:     5, Skipped:     0, Total:     5, Duration: 10 ms - Bookstore.Application.Tests.dll (net10.0)

Beş test, Docker yok, on milisaniye civarı. Bu test'ler sayesinde ücretsiz kargo eşiğini değiştirdiğinde geri bildirimi beklemeden alıyorsun.

Adapter'ları Gerçek SQL Server ile Test Etmek

Core test'lerinin göremediği şey adapter'ın doğruluğu: OrderConfiguration kalemleri gerçekten _items field'ına dolduruyor mu, concurrency token gerçekten çalışıyor mu, para tutarları doğru saklanıyor mu? Bunun cevabı ancak gerçek veritabanından geliyor; üstelik üretimde kullanılan veritabanından. 05'te örnekleri SQLite ile çalıştırırken Bookstore'un üretimde SQL Server kullandığını söylemiştik. EF Core'un InMemory provider'ı da, SQLite da bu iş için doğru vekil değil; transaction, concurrency ve tip davranışları farklı. EF Core serisinin "EF Core ile Test: SQLite In-Memory ve InMemory Provider Tuzakları" yazısında bunları ayrıntılı görmüştük.

Testcontainers (sürüm 4.16.0), test sırasında Docker'da gerçek bir SQL Server başlatıyor:

using Bookstore.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Testcontainers.MsSql;

namespace Bookstore.Infrastructure.Tests;

// Üretimdeki motorla aynı: test class'ı başına gerçek bir SQL Server container'ı
public sealed class SqlServerFixture : IAsyncLifetime
{
    private readonly MsSqlContainer _container = new MsSqlBuilder("mcr.microsoft.com/mssql/server:2025-latest").Build();

    internal BookstoreDbContext CreateContext() =>
        new(new DbContextOptionsBuilder<BookstoreDbContext>()
            .UseSqlServer(_container.GetConnectionString())
            .ConfigureWarnings(w => w.Throw(SqlServerEventId.DecimalTypeDefaultWarning)) // sessiz yuvarlamaya izin yok
            .Options);

    public async Task InitializeAsync()
    {
        await _container.StartAsync();
        await using BookstoreDbContext db = CreateContext();
        await db.Database.EnsureCreatedAsync();
    }

    public Task DisposeAsync() => _container.DisposeAsync().AsTask();
}

IClassFixture sayesinde container test class'ı başına bir kez başlıyor, class'taki test'ler onu paylaşıyor; benim makinemde SQL Server 2025 container'ı altı saniye civarında hazır oluyor. Testcontainers normalde Ryuk adında yardımcı bir container daha başlatıyor; test süreci çökse bile geride container kalmasın diye. Bu yazıdaki koşularda onu TESTCONTAINERS_RYUK_DISABLED=true ile kapattım ve temizliği fixture'ın DisposeAsync'ine bıraktım; CI'da (continuous integration) açık bırakmak daha güvenli.

public sealed class OrderPersistenceTests(SqlServerFixture sql) : IClassFixture<SqlServerFixture>
{
    private async Task SeedAsync(Book book)
    {
        await using BookstoreDbContext db = sql.CreateContext();
        db.Books.Add(book);
        await db.SaveChangesAsync();
    }

    [Fact]
    public async Task Placed_order_round_trips_with_items_and_stock()
    {
        await SeedAsync(new Book(1, "Dune", 250m, 3));

        int orderId;
        await using (BookstoreDbContext db = sql.CreateContext())
        {
            PlaceOrderHandler handler = new(new BookRepository(db), new OrderRepository(db), new FlatRateShipping(40m), db);
            Result<PlaceOrderResponse> result = await handler.HandleAsync(new PlaceOrderCommand("ada@example.com", "06100", 1, 1), CancellationToken.None);
            orderId = result.Value!.OrderId;
        }

        // yeni bir DbContext: change tracker'dan değil, veritabanından okuyoruz
        await using BookstoreDbContext check = sql.CreateContext();
        Order saved = await check.Orders.SingleAsync(o => o.Id == orderId);
        Assert.Equal(290m, saved.Total);
        Assert.Equal(new OrderItem(1, 250m, 1), Assert.Single(saved.Items));
        Assert.Equal(2, (await check.Books.SingleAsync(b => b.Id == 1)).Stock);
    }

    [Fact]
    public async Task Two_buyers_for_the_last_copy_second_one_hits_the_concurrency_token()
    {
        await SeedAsync(new Book(2, "Emma", 180m, 1));

        await using BookstoreDbContext first = sql.CreateContext();
        await using BookstoreDbContext second = sql.CreateContext();
        Book a = await first.Books.SingleAsync(b => b.Id == 2);
        Book b = await second.Books.SingleAsync(b => b.Id == 2);

        Assert.True(a.TryReserve(1));
        Assert.True(b.TryReserve(1)); // ikisi de bellekte son kopyayı gördü
        await first.SaveChangesAsync();

        await Assert.ThrowsAsync<DbUpdateConcurrencyException>(() => second.SaveChangesAsync());
    }
}

İlk test, 06'daki handler'ı bu sefer gerçek repository'ler ve gerçek DbContext ile çalıştırıyor, sonra sonucu yeni bir context'le okuyor. Yeni context önemli: aynı context'ten okusaydın EF Core object'i change tracker'dan verirdi ve mapping hatası gizli kalırdı. İkinci test, 07'de eklediğimiz concurrency token'ı kanıtlıyor: iki müşteri aynı son kopyayı bellekte ayırıyor, birincisi kaydediyor, ikincisinin UPDATE'i hiçbir satırı etkilemiyor ve EF Core DbUpdateConcurrencyException fırlatıyor. Bu davranışı in-memory bir sahteyle test etmenin yolu yok.

Bu test'ler ilk koşuda kırmızıydı, ve iyi ki öyleydi:

Failed Bookstore.Infrastructure.Tests.OrderPersistenceTests.Placed_order_round_trips_with_items_and_stock [1 ms]
 System.InvalidOperationException : An error was generated for warning 'Microsoft.EntityFrameworkCore.Model.Validation.DecimalTypeDefaultWarning': No store type was specified for the decimal property 'Price' on entity type 'Book'. This will cause values to be silently truncated if they do not fit in the default precision and scale. Explicitly specify the SQL server column type that can accommodate all the values in 'OnModelCreating' using 'HasColumnType', specify precision and scale using 'HasPrecision', or configure a value converter using 'HasConversion'. This exception can be suppressed or logged by passing event ID 'SqlServerEventId.DecimalTypeDefaultWarning' to the 'ConfigureWarnings' method in 'DbContext.OnConfiguring' or 'AddDbContext'.
Failed!  - Failed:     2, Passed:     2, Skipped:     0, Total:     4, Duration: 6 s - Bookstore.Infrastructure.Tests.dll (net10.0)

07'de SQLite'ın decimal'i TEXT olarak sakladığını görmüştük. SQL Server'da ise hassasiyeti belirtilmemiş bir decimal varsayılan olarak decimal(18,2) oluyor ve sığmayan değerler sessizce yuvarlanabiliyor. EF Core bunu normalde yalnızca bir uyarı olarak logluyor; fixture'daki ConfigureWarnings(w => w.Throw(...)) satırı o uyarıyı test'te hataya çeviriyor. Düzeltme yine yalnızca Infrastructure'da: Price, ShippingFee ve UnitPrice için HasPrecision(10, 2):

builder.HasKey(o => o.Id);
builder.Property(o => o.CustomerEmail).HasMaxLength(256);
builder.Property(o => o.PostalCode).HasMaxLength(10);
builder.Property(o => o.ShippingFee).HasPrecision(10, 2);
builder.HasIndex(o => o.CustomerEmail);
builder.Ignore(o => o.Subtotal);
builder.Ignore(o => o.Total);
builder.OwnsMany(o => o.Items, items =>
{
    items.ToTable("OrderItems");
    items.WithOwner().HasForeignKey("OrderId");
    items.Property<int>("Id");
    items.HasKey("Id");
    items.Property(i => i.UnitPrice).HasPrecision(10, 2);
});
builder.Navigation(o => o.Items).HasField("_items");

Örnekleri SQLite'la çalıştırmaya devam etsen de adapter test'ini üretim motoruna karşı koşturmanın değeri tam olarak bu: SQLite'ta hiç görünmeyen bir mapping eksiği, üretime çıkmadan yakalandı. Uygulamanın tamamını HTTP üzerinden, WebApplicationFactory ile uçtan uca test etmek de mümkün; Web API serisinin "ASP.NET Core Web API'de Integration Test: WebApplicationFactory ile Uçtan Uca Test" yazısında kurmuştuk. Testcontainers ile WebApplicationFactory'nin birlikte kullanımının ayrıntıları, Test ve Kalite kategorisinde ilerideki Integration ve E2E (end-to-end) Test serisinin konusu.

HTTP Adapter'ını Ağsız Test Etmek

Kargo adapter'ının test'i için ne gerçek firmaya ne de stub bir sunucuya ihtiyaç var. HttpClient'ın en alttaki handler'ını değiştirmek yeterli:

using System.Net;
using System.Text;
using Bookstore.Infrastructure.Shipping;

namespace Bookstore.Infrastructure.Tests;

public sealed class CarrierShippingRatesTests
{
    // Ağa çıkmadan HTTP adapter'ını test etmek için sahte handler
    private sealed class StubHandler(HttpStatusCode status, string json) : HttpMessageHandler
    {
        public string? RequestBody { get; private set; }

        protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
        {
            RequestBody = await request.Content!.ReadAsStringAsync(ct);
            return new HttpResponseMessage(status) { Content = new StringContent(json, Encoding.UTF8, "application/json") };
        }
    }

    private static CarrierShippingRates CreateAdapter(StubHandler handler) =>
        new(new HttpClient(handler) { BaseAddress = new Uri("https://carrier.test/") });

    [Fact]
    public async Task Translates_our_request_and_the_carriers_rate()
    {
        StubHandler handler = new(HttpStatusCode.OK,
            """{"amount_minor":3990,"currency":"TRY","transit_days":2,"service_code":"STD-TR-02"}""");

        decimal? fee = await CreateAdapter(handler).QuoteAsync("34000", 400, CancellationToken.None);

        Assert.Equal("""{"destination_zip":"34000","weight_grams":400,"service_level":"STANDARD"}""", handler.RequestBody);
        Assert.Equal(39.90m, fee);
    }

    [Fact]
    public async Task Unserved_destination_becomes_null()
    {
        StubHandler handler = new(HttpStatusCode.UnprocessableEntity, """{"error":"destination_not_served"}""");

        Assert.Null(await CreateAdapter(handler).QuoteAsync("99000", 400, CancellationToken.None));
    }
}

Bu test adapter'ın iki yönlü çevirisini sabitliyor: bizim posta kodu ve ağırlığımız firmanın snake_case JSON'una (JavaScript Object Notation), firmanın kuruş cinsinden tutarı bizim decimal'ımıza. Firmanın dokümanındaki örnek cevabı buraya koymak, şemaları değiştiğinde neyin kırılacağını da gösteriyor. Adapter internal olduğu için Infrastructure'ın .csproj'una InternalsVisibleTo ile test project'ine izin verdim. Bütün solution'ın test'leri, bir sonraki bölümde ekleyeceğimiz architecture test'leriyle birlikte:

Passed!  - Failed:     0, Passed:     5, Skipped:     0, Total:     5, Duration: 11 ms - Bookstore.Application.Tests.dll (net10.0)
Passed!  - Failed:     0, Passed:     5, Skipped:     0, Total:     5, Duration: 142 ms - Bookstore.ArchitectureTests.dll (net10.0)
Passed!  - Failed:     0, Passed:     4, Skipped:     0, Total:     4, Duration: 6 s - Bookstore.Infrastructure.Tests.dll (net10.0)

Dependency Rule'u Test'e Çevirmek: ArchUnitNET

Açılıştaki pull request'i core ve adapter test'lerinin hiçbiri yakalamıyor, çünkü hiçbiri bağımlılıkların yönüyle ilgilenmiyor. ArchUnitNET, Java dünyasındaki ArchUnit'in .NET karşılığı; ikisini de Alman danışmanlık şirketi TNG'nin (TNG Technology Consulting) ekibi Apache 2.0 lisansıyla geliştiriyor. Derlenmiş assembly'leri okuyup type'lar arasındaki bağımlılık grafiğini çıkarıyor ve senin yazdığın kuralları bu grafiğe uyguluyor. Bu yazı yazılırken güncel sürüm 26 Ekim 2026'da yayımlanan 0.13.5. Çekirdek paket TngTech.ArchUnitNET, test framework'üne göre .xUnit, .xUnitV3, .NUnit ya da .MSTestV2 eklentisi seçiliyor:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <IsPackable>false</IsPackable>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.NET.Test.Sdk" />
    <PackageReference Include="xunit" />
    <PackageReference Include="xunit.runner.visualstudio" />
    <PackageReference Include="TngTech.ArchUnitNET.xUnit" />
  </ItemGroup>
  <ItemGroup>
    <Using Include="Xunit" />
  </ItemGroup>
  <ItemGroup>
    <ProjectReference Include="..\..\src\Bookstore.Web\Bookstore.Web.csproj" />
  </ItemGroup>
</Project>

Architecture test project'i Web'e referans veriyor; Web zaten diğer üç project'i getiriyor. Kurallar:

using ArchUnitNET.Domain;
using ArchUnitNET.Fluent;
using ArchUnitNET.Loader;
using ArchUnitNET.xUnit;
using static ArchUnitNET.Fluent.ArchRuleDefinition;

namespace Bookstore.ArchitectureTests;

public sealed class DependencyRuleTests
{
    // Mimari bir kez yüklenir: dört assembly'nin derlenmiş hali
    private static readonly Architecture Architecture = new ArchLoader().LoadAssemblies(
        typeof(Domain.Ordering.Order).Assembly,
        typeof(Application.DependencyInjection).Assembly,
        typeof(Infrastructure.DependencyInjection).Assembly,
        typeof(Web.ErrorMapping).Assembly).Build();

    private static readonly IObjectProvider<IType> DomainLayer =
        Types().That().ResideInNamespaceOrBelow("Bookstore.Domain").As("Domain");
    private static readonly IObjectProvider<IType> ApplicationLayer =
        Types().That().ResideInNamespaceOrBelow("Bookstore.Application").As("Application");
    private static readonly IObjectProvider<IType> InfrastructureLayer =
        Types().That().ResideInNamespaceOrBelow("Bookstore.Infrastructure").As("Infrastructure");
    private static readonly IObjectProvider<IType> WebLayer =
        Types().That().ResideInNamespaceOrBelow("Bookstore.Web").As("Web");

    [Fact]
    public void Domain_depends_on_nothing_outside_itself() =>
        Types().That().Are(DomainLayer).Should()
            .NotDependOnAny(Types().That().Are(ApplicationLayer).Or().Are(InfrastructureLayer).Or().Are(WebLayer))
            .Because("the Dependency Rule points inward")
            .Check(Architecture);

    [Fact]
    public void Application_does_not_know_infrastructure_or_web() =>
        Types().That().Are(ApplicationLayer).Should()
            .NotDependOnAny(Types().That().Are(InfrastructureLayer).Or().Are(WebLayer))
            .Check(Architecture);

    [Theory]
    [InlineData("Microsoft.EntityFrameworkCore")]
    [InlineData("Microsoft.AspNetCore")]
    [InlineData("System.Net.Http")]
    public void Core_does_not_use_frameworks(string frameworkNamespace) =>
        Types().That().Are(DomainLayer).Or().Are(ApplicationLayer).Should()
            .NotDependOnAny(Types(true).That().ResideInNamespaceOrBelow(frameworkNamespace))
            .Because("frameworks are details that belong to the outer ring")
            .Check(Architecture);
}

Üç kural, Clean Architecture'ın üç cümlesi. Domain kendinden başka hiçbir katmanı bilmiyor. Application, Infrastructure'ı ve Web'i bilmiyor. Core, yani Domain ile Application, EF Core'u, ASP.NET Core'u ve HttpClient'ı bilmiyor. Web'in Infrastructure'a yalnızca composition root'tan dokunması için ayrı bir kurala ihtiyaç duymadık: 05'te BookstoreDbContext'i ve adapter'ları internal yaptığımız için derleyici bunu zaten CS0122 ile koruyor.

Sürümle ilgili iki not. ResideInNamespaceOrBelow 0.13.5 ile geldi; bir namespace'i ve altındaki bütün namespace'leri seçiyor. 0.13.0 ise string ile regex eşleştiren eski overload'ları kaldırdı; internette gördüğün ResideInNamespace("Bookstore.Domain", true) gibi örnekler artık derlenmiyor. Bir de ArchUnitNET'in README'si, kuralların derlenmiş binary'ler üzerinden çalıştığını hatırlatıp test'leri Debug konfigürasyonunda koşturmayı öneriyor.

Kasıtlı Üç İhlal

Kuralların işe yaradığını görmenin tek yolu onları kırmak. Solution'ın üç ayrı kopyasında üç ayrı ihlal denedim.

Birinci ihlal en kaba olanı: Domain'den Infrastructure'a project reference. dotnet add reference komutu itiraz etmeden .csproj'a yazıyor, ama build daha restore aşamasında duruyor (path'i kısalttım):

NuGet.targets(1298,5): error MSB4006: There is a circular dependency in the target dependency graph involving target "_GenerateRestoreProjectPathWalk". [src/Bookstore.Application/Bookstore.Application.csproj]
    0 Warning(s)
    1 Error(s)

Infrastructure zaten Application üzerinden Domain'e bağlı olduğu için döngü oluşuyor ve MSBuild buna izin vermiyor. Yani project'lere bölmenin kendisi en büyük okları zaten koruyor; SOLID (Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion) ve Design Principles serisinin "Component Prensipleri: .NET Solution'ını Project'lere Bölmek" yazısındaki ADP (Acyclic Dependencies Principle) burada derleyici tarafından uygulanıyor. Architecture test'lerin değeri, derleyicinin göremediği yerlerde.

İkinci ihlal açılıştaki pull request. Build geçiyor, core ve adapter test'leri geçiyor, ama architecture test'i duruyor:

Failed Bookstore.ArchitectureTests.DependencyRuleTests.Core_does_not_use_frameworks(frameworkNamespace: "Microsoft.EntityFrameworkCore") [1 ms]
ArchUnitNET.xUnit.FailedArchRuleException : "Types that are Domain or are Application should not depend on any Types (including referenced) that reside in namespace with full name "Microsoft.EntityFrameworkCore" or below because frameworks are details that belong to the outer ring" failed:
	Bookstore.Application.Ordering.Queries.CustomerOrdersHandler does depend on "Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions"
Total tests: 6
     Passed: 5
     Failed: 1

Mesaj ihlalin tam yerini söylüyor: CustomerOrdersHandler, EF Core'un ToListAsync'ini barındıran EntityFrameworkQueryableExtensions'a bağımlı. Doğru çözüm, 07'de söylediğimiz gibi, okuma ihtiyacını niyet anlatan bir port method'una çevirmek (IOrderRepository.ListByCustomerAsync gibi) ve sorguyu Infrastructure'da yazmak.

Üçüncü ihlal 06'daki tuzaklardan biri: Application'a HTTP sızdırmak. Biri "her endpoint aynı if'i yazmasın" diye Result'u HTTP cevabına çeviren extension'ı Application'a taşımış ve bunun için Application'a ASP.NET Core'un framework referansını eklemiş:

using Microsoft.AspNetCore.Http;

namespace Bookstore.Application;

// "Her endpoint aynı if'i yazmasın" diye Application'a taşınmış kolaylık
public static class ResultHttpExtensions
{
    public static IResult ToHttp<T>(this Result<T> result) =>
        result.IsSuccess ? Results.Ok(result.Value) : Results.Problem(result.Error!.Message, title: result.Error.Code);
}
Failed Bookstore.ArchitectureTests.DependencyRuleTests.Core_does_not_use_frameworks(frameworkNamespace: "Microsoft.AspNetCore") [1 ms]
ArchUnitNET.xUnit.FailedArchRuleException : "Types that are Domain or are Application should not depend on any Types (including referenced) that reside in namespace with full name "Microsoft.AspNetCore" or below because frameworks are details that belong to the outer ring" failed:
	Bookstore.Application.ResultHttpExtensions does depend on "Microsoft.AspNetCore.Http.IResult" and "Microsoft.AspNetCore.Http.Results"

Bu da derleyicinin göremeyeceği bir ihlal: FrameworkReference eklemek tamamen geçerli bir MSBuild ayarı. 06'da "Application'ın .csproj'unda Microsoft.AspNetCore.App görüyorsan bir şeyler kaçmış" demiştik; artık bunu bir göz değil, bir test kontrol ediyor.

NetArchTest ve Fitness Function'lar

.NET'te bu iş için sık önerilen diğer library NetArchTest.Rules. API'si okunaklı ve hâlâ çalışıyor, ama NuGet'teki son sürümü 1.3.2, Mayıs 2021 tarihli; o günden beri yeni sürüm çıkmadı. ArchUnitNET ise 2026'da birkaç sürüm aldı. Yeni bir projede ArchUnitNET'i seçmemin sebebi bu. Mevcut projende NetArchTest çalışıyorsa sırf bunun için taşımana gerek yok; asıl önemli olan kuralların var olması.

Bu test'ler, Neal Ford, Rebecca Parsons ve Patrick Kua'nın "Building Evolutionary Architectures" kitabında fitness function dediği şeyin en basit örneği: bir architecture characteristic'i otomatik ve tekrarlanabilir şekilde ölçen bir kontrol. Kavramın kendisini ve erosion'ın nasıl başladığını Mimariye Giriş serisinin "Mimariyi Zaman İçinde Korumak: Architecture Erosion ve Fitness Function'lar" yazısında anlattık. CI tarafında ayrı bir adım gerekmiyor: architecture test'leri sıradan xUnit test'leri, pipeline'daki dotnet test adımıyla birlikte koşuyor ve kırmızıya döndüklerinde pull request birleşmiyor. Pipeline'ın kendisi Web API serisinin "ASP.NET Core Web API'yi Docker'a Almak ve GitHub Actions ile CI/CD Kurmak" yazısında.

Sık Düşülen Tuzaklar

Hiçbir şeyi seçmeyen kural. İkinci ihlali denerken ilk yazdığım kural şuydu ve ihlale rağmen geçti:

[Fact]
public void Application_does_not_use_ef_core_first_try() =>
    Types().That().ResideInNamespaceOrBelow("Bookstore.Application").Should()
        .NotDependOnAny(Types().That().ResideInNamespaceOrBelow("Microsoft.EntityFrameworkCore"))
        .Check(Architecture);
Failed Bookstore.ArchitectureTests.DependencyRuleTests.Core_does_not_use_frameworks(frameworkNamespace: "Microsoft.EntityFrameworkCore")
Passed Bookstore.ArchitectureTests.DependencyRuleTests.Application_does_not_know_infrastructure_or_web
Passed Bookstore.ArchitectureTests.DependencyRuleTests.Core_does_not_use_frameworks(frameworkNamespace: "Microsoft.AspNetCore")
Passed Bookstore.ArchitectureTests.DependencyRuleTests.Core_does_not_use_frameworks(frameworkNamespace: "System.Net.Http")
Passed Bookstore.ArchitectureTests.DependencyRuleTests.Domain_depends_on_nothing_outside_itself
Passed Bookstore.ArchitectureTests.FirstTryRuleTests.Application_does_not_use_ef_core_first_try

Sebep Types()'ın varsayılan olarak yalnızca yüklediğin assembly'lerdeki type'ları görmesi. EntityFrameworkQueryableExtensions EF Core'un assembly'sinde, mimariye yüklenmemiş; kural "boş bir kümeye bağımlı olma" dediği için her zaman geçiyor. Dış library'ler hakkında kural yazarken Types(true) ile referans verilen type'ları da dahil etmen gerekiyor. Ders genel: her yeni kuralı en az bir kez bilerek kır, kırmızıyı görmeden güvenme.

Güvenlik uyarısının build'i durdurması. 05'teki Directory.Build.props'ta TreatWarningsAsErrors açık. SQL Server provider'ını test project'ine eklediğim ilk restore şöyle bitti (path'i kısalttım):

tests/Bookstore.Infrastructure.Tests/Bookstore.Infrastructure.Tests.csproj : error NU1902: Warning As Error: Package 'System.Security.Cryptography.Pkcs' 9.0.11 has a known moderate severity vulnerability, https://github.com/advisories/GHSA-2fmv-rv2c-59x7
tests/Bookstore.Infrastructure.Tests/Bookstore.Infrastructure.Tests.csproj : error NU1903: Warning As Error: Package 'System.Security.Cryptography.Pkcs' 9.0.11 has a known high severity vulnerability, https://github.com/advisories/GHSA-846f-f764-76mx

Paketi kimin getirdiğini dotnet nuget why gösteriyor:

Project 'Bookstore.Infrastructure.Tests' has the following dependency graph(s)
for 'System.Security.Cryptography.Pkcs':
  [net10.0]
  └── Microsoft.EntityFrameworkCore.SqlServer (v10.0.14)
      └── Microsoft.Data.SqlClient (v6.1.7)
          └── System.Security.Cryptography.Pkcs (v9.0.11)

Uyarıyı kapatmak yerine, Central Package Management'ta yamalı sürümü (10.0.14) tanımlayıp test project'ine doğrudan referans verdim. Uyarıyı susturmak kolay; ama NuGet audit'in CI'da build'i durdurması, mimari test'lerle aynı mantıkta bir fitness function.

Her şeyi mock'lamak. Repository'leri, unit of work'ü ve kargo port'unu Moq ile kurup "şu method şu parametreyle çağrıldı" diye doğrulayan test'ler, handler'ın implementasyonuna kilitlenir. Use case'in sonucunu ölç: sipariş listede mi, stok düştü mü, kaç kez kaydedildi?

Adapter'ı InMemory provider ya da yalnızca SQLite ile test etmek. Test yeşil olur ama yukarıdaki decimal sorunu gibi üretim motoruna özgü davranışları test etmemiş olursun. Adapter test'i üretimdeki motorla anlamlı.

Fazla ayrıntılı architecture kuralları. "Her handler'ın adı Handler ile bitmeli", "her class sealed olmalı" gibi kurallar faydalı olabilir, ama her biri bir bakım maliyeti. Önce Dependency Rule'u koru; isim kurallarını ekip gerçekten bir sorun yaşadığında ekle.

Küçük Bir Deneme

★☆☆ İkinci ihlali düzelt: IOrderQueries'i kaldırıp IOrderRepository'ye bir ListByCustomerAsync method'u ekle ve sorguyu Infrastructure'da yaz. Architecture test'lerini ve Infrastructure test'lerini tekrar çalıştır.

★★☆ "Infrastructure'daki adapter class'ları internal olmalı" kuralını yaz ve bilerek kır. Şu an kural geçiyor mu? Bir adapter'ı public yapmanın meşru bir gerekçesi olabilir mi?

★★★ Order'a, toplamı 1000 TL'yi geçen siparişlerin onay beklemesini sağlayan yeni bir kural ekle. Önce core test'ini yaz ve kırmızı gör, sonra kuralı implement et. Bu değişiklik için kaç adapter test'ine dokunman gerekti?

Elimizde artık çalışan, test edilen ve kendini koruyan bir Clean Architecture solution'ı var. Serinin son yazısında zor soruyu soruyoruz: bütün bunlar ne zaman fazla gelir? Template kültü, interface patlaması ve aynı kuralları tek bir project'te, daha az törenle korumanın yolları.