Testing and Protecting Clean Architecture: Core Tests, Adapter Tests, and Architecture Tests
In Clean Architecture you test the core without a database using in-memory adapters, test the adapters against the production SQL Server with Testcontainers, and make the Dependency Rule unbreakable in CI with ArchUnitNET rules. The decimal precision problem SQLite hid, violations the compiler misses, the trap of a rule that selects nothing without Types(true), and a note that NetArchTest hasn't been updated since 2021.
Version note: The code in this post was built and run with C# 14 and .NET 10.
Guess First
The customer panel needs a "my orders" screen. A new team member says "it's only a read, not worth adding a method to the repository" and takes the shortcut: they add Entity Framework Core's (EF Core) package to Application, define a small interface that hands out an IQueryable, and call ToListAsync directly in the handler.
<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;
// A shortcut added because "it's only a read, not worth adding a repository method"
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);
}
The Bookstore we built in the previous posts has these safeguards: the GuardDomainDependencies target from post 05 stops the build when a package is added to Domain, you can't add a project reference from Domain to Infrastructure, Web can't see BookstoreDbContext because it is internal, the use cases have unit tests and the adapters have integration tests. The question: which of them sounds the alarm on this pull request? The compiler, the tests, or a tired pair of eyes in code review?
Three Questions, Three Kinds of Tests
The answer: none of them. The build passes cleanly; all nine core and adapter tests are green (5 + 4, the tests you'll see shortly in this post). The guard only looks at Domain, Application is free to add packages, and there is no cycle in the project reference graph. But the Dependency Rule has been broken: the Use Cases circle now knows a framework detail, EF Core's query extensions. The IQueryable leak we saw in post 07 is back, this time on purpose. This post ends by writing the test that catches this kind of violation. Before we get there, let's look at the test strategy of a Clean Architecture solution.
When Microsoft's "Architect Modern Web Applications with ASP.NET Core and Azure" e-book explains Clean Architecture, it shows tests in two pictures: unit testing the Application Core in isolation, and integration testing the Infrastructure implementations against their external dependencies. Add testing the architecture itself, and you get three questions:
| Question | Kind of test | In Bookstore | How fast |
|---|---|---|---|
| Does the use case make the right decision? | Core unit test | Handler + in-memory adapters | Milliseconds, no Docker |
| Does the adapter talk to the outside world correctly? | Adapter integration test | EF Core + real SQL (Structured Query Language) Server, HTTP (Hypertext Transfer Protocol) adapter + fake handler | Seconds, needs Docker |
| Do the dependencies still point inward? | Architecture test | ArchUnitNET rules | Milliseconds, on the compiled assemblies |
Testing the Core Without a Database
In post 05 we started the Application tests with a single Fakes class implementing all four ports. As the number of use cases grows, a separate fake per port reads better:
using Bookstore.Application.Ordering;
using Bookstore.Domain.Catalog;
using Bookstore.Domain.Ordering;
namespace Bookstore.Application.Tests;
// Test adapters for the driven ports: no database, no network
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);
}
These aren't mocks; they are real but simple implementations of the ports. Like FlatRateShipping in post 07, they reflect Hexagonal Architecture's idea of seeing the test as a driving adapter and the in-memory store as a driven adapter. Instead of asking a mocking library "was Add called once?", we check at the end of the test whether there is an order in the list; the test doesn't break when the handler's internal steps change. We used Moq in depth in the ASP.NET Core Web API (Application Programming Interface) series post "Unit Testing an ASP.NET Core Web API: Service and Controller Tests with xUnit and Moq"; both are valid, but as long as the ports stay small, a hand-written fake reads better.
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);
}
}
The last test pins down the null we added to the port in post 07: if the shipping company doesn't deliver, the stock isn't saved. The free shipping threshold, the missing book and the insufficient stock are one-line scenarios too. With xUnit 2.9.3, as in post 05:
Passed! - Failed: 0, Passed: 5, Skipped: 0, Total: 5, Duration: 10 ms - Bookstore.Application.Tests.dll (net10.0)
Five tests, no Docker, around ten milliseconds. Thanks to these tests, when you change the free shipping threshold you get feedback without waiting.
Testing the Adapters Against Real SQL Server
What the core tests can't see is whether the adapter is correct: does OrderConfiguration really populate the items into the _items field, does the concurrency token really work, are monetary amounts stored correctly? Only a real database can answer that, and it should be the database used in production. In post 05, while running the examples on SQLite, we said Bookstore uses SQL Server in production. Neither EF Core's InMemory provider nor SQLite is the right stand-in for this job; their transaction, concurrency and type behavior differ. We looked at these in detail in the EF Core series post "Testing with EF Core: SQLite In-Memory and InMemory Provider Pitfalls".
Testcontainers (version 4.16.0) starts a real SQL Server in Docker during the test run:
using Bookstore.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Testcontainers.MsSql;
namespace Bookstore.Infrastructure.Tests;
// Same engine as production: a real SQL Server container per test class
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)) // no silent rounding allowed
.Options);
public async Task InitializeAsync()
{
await _container.StartAsync();
await using BookstoreDbContext db = CreateContext();
await db.Database.EnsureCreatedAsync();
}
public Task DisposeAsync() => _container.DisposeAsync().AsTask();
}
With IClassFixture, the container starts once per test class and the tests in the class share it; on my machine the SQL Server 2025 container is ready in about six seconds. Testcontainers normally starts one more helper container called Ryuk, so that no containers are left behind even if the test process crashes. For the runs in this post I turned it off with TESTCONTAINERS_RYUK_DISABLED=true and left the cleanup to the fixture's DisposeAsync; in CI (continuous integration) it is safer to leave it on.
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;
}
// a fresh DbContext: we read from the database, not from the change tracker
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)); // both saw the last copy in memory
await first.SaveChangesAsync();
await Assert.ThrowsAsync<DbUpdateConcurrencyException>(() => second.SaveChangesAsync());
}
}
The first test runs the handler from post 06, this time with the real repositories and a real DbContext, then reads the result back with a new context. The new context matters: had you read from the same context, EF Core would have handed you the object from the change tracker and a mapping bug would have stayed hidden. The second test proves the concurrency token we added in post 07: two customers reserve the same last copy in memory, the first one saves, the second one's UPDATE affects no rows, and EF Core throws DbUpdateConcurrencyException. There is no way to test this behavior with an in-memory fake.
These tests were red on the first run, and it's a good thing they were:
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)
In post 07 we saw that SQLite stores decimals as TEXT. On SQL Server, a decimal with no precision specified defaults to decimal(18,2), and values that don't fit can be silently rounded. EF Core normally just logs this as a warning; the ConfigureWarnings(w => w.Throw(...)) line in the fixture turns that warning into a test failure. The fix lives in Infrastructure only: HasPrecision(10, 2) for Price, ShippingFee and UnitPrice:
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");
Even if you keep running the examples on SQLite, this is exactly the value of running adapter tests against the production engine: a mapping gap that never shows up on SQLite was caught before it reached production. Testing the whole application end to end over HTTP with WebApplicationFactory is also possible; we set it up in the Web API series post "Integration Testing an ASP.NET Core Web API: End-to-End Tests with WebApplicationFactory". The details of combining Testcontainers with WebApplicationFactory belong to the upcoming Integration and E2E (end-to-end) Test series in the Testing and Quality category.
Testing the HTTP Adapter Without a Network
Testing the shipping adapter needs neither the real company nor a stub server. Replacing the bottom handler of HttpClient is enough:
using System.Net;
using System.Text;
using Bookstore.Infrastructure.Shipping;
namespace Bookstore.Infrastructure.Tests;
public sealed class CarrierShippingRatesTests
{
// Fake handler to test the HTTP adapter without touching the network
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));
}
}
This test pins down the adapter's translation in both directions: our postal code and weight into the company's snake_case JSON (JavaScript Object Notation), and the company's minor-unit amount into our decimal. Putting the sample response from the company's documentation here also shows what will break when their schema changes. Because the adapter is internal, I granted the test project access with InternalsVisibleTo in Infrastructure's .csproj. The tests of the whole solution, together with the architecture tests we'll add in the next section:
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)
Turning the Dependency Rule into a Test: ArchUnitNET
None of the core and adapter tests would catch the pull request from the opening, because none of them cares about the direction of dependencies. ArchUnitNET is the .NET counterpart of ArchUnit from the Java world; the team at the German consultancy TNG (TNG Technology Consulting) develops both under the Apache 2.0 license. It reads compiled assemblies, builds the dependency graph between types, and applies the rules you write to that graph. At the time of writing, the current version is 0.13.5, released on October 26, 2026. The core package is TngTech.ArchUnitNET, and you pick the .xUnit, .xUnitV3, .NUnit or .MSTestV2 extension for your test framework:
<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>
The architecture test project references Web; Web already brings in the other three projects. The rules:
using ArchUnitNET.Domain;
using ArchUnitNET.Fluent;
using ArchUnitNET.Loader;
using ArchUnitNET.xUnit;
using static ArchUnitNET.Fluent.ArchRuleDefinition;
namespace Bookstore.ArchitectureTests;
public sealed class DependencyRuleTests
{
// The architecture is loaded once: the compiled form of the four assemblies
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);
}
Three rules, three sentences of Clean Architecture. Domain knows no layer other than itself. Application knows neither Infrastructure nor Web. The core, meaning Domain and Application, knows nothing about EF Core, ASP.NET Core or HttpClient. We didn't need a separate rule for Web touching Infrastructure only from the composition root: since post 05 made BookstoreDbContext and the adapters internal, the compiler already guards that with CS0122.
Two notes about versions. ResideInNamespaceOrBelow arrived in 0.13.5; it selects a namespace and every namespace below it. 0.13.0 removed the old overloads that matched strings as regular expressions, so examples you find online such as ResideInNamespace("Bookstore.Domain", true) no longer compile. Also, ArchUnitNET's README reminds you that the rules run against the compiled binaries and recommends running the tests in the Debug configuration.
Three Deliberate Violations
The only way to see that the rules work is to break them. I tried three different violations in three separate copies of the solution.
The first violation is the crudest: a project reference from Domain to Infrastructure. dotnet add reference writes it into the .csproj without complaint, but the build stops as early as the restore step (I shortened the path):
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)
Since Infrastructure already depends on Domain through Application, a cycle forms and MSBuild doesn't allow it. So splitting into projects already protects the biggest arrows; the ADP (Acyclic Dependencies Principle) from the SOLID (Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion) and Design Principles series post "Component Principles: Splitting a .NET Solution into Projects" is enforced here by the compiler. The value of architecture tests lies where the compiler can't see.
The second violation is the pull request from the opening. The build passes, the core and adapter tests pass, but the architecture test stops it:
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
The message names the exact spot: CustomerOrdersHandler depends on EntityFrameworkQueryableExtensions, which hosts EF Core's ToListAsync. The right fix, as we said in post 07, is to turn the read need into an intention-revealing port method (something like IOrderRepository.ListByCustomerAsync) and write the query in Infrastructure.
The third violation is one of the pitfalls from post 06: leaking HTTP into Application. Someone moved the extension that turns a Result into an HTTP response into Application "so every endpoint doesn't write the same if", and added ASP.NET Core's framework reference to Application for it:
using Microsoft.AspNetCore.Http;
namespace Bookstore.Application;
// A convenience moved into Application "so every endpoint doesn't write the same if"
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"
This is also a violation the compiler can't see: adding a FrameworkReference is a perfectly valid MSBuild setting. In post 06 we said "if you see Microsoft.AspNetCore.App in Application's .csproj, something has slipped"; now a test checks that instead of a pair of eyes.
NetArchTest and Fitness Functions
The other library often recommended for this in .NET is NetArchTest.Rules. Its API is readable and it still works, but its latest version on NuGet is 1.3.2, from May 2021; no new version has shipped since. ArchUnitNET, on the other hand, has had several releases in 2026. That's why I choose ArchUnitNET for a new project. If NetArchTest works in your existing project, there's no need to migrate just for this; what really matters is that the rules exist.
These tests are the simplest example of what Neal Ford, Rebecca Parsons and Patrick Kua call a fitness function in "Building Evolutionary Architectures": a check that measures an architecture characteristic automatically and repeatably. We covered the concept itself and how erosion starts in the Introduction to Architecture series post "Keeping Architecture Healthy Over Time: Architecture Erosion and Fitness Functions". No separate CI step is needed: the architecture tests are ordinary xUnit tests, they run with the pipeline's dotnet test step, and when they turn red the pull request doesn't merge. The pipeline itself is in the Web API series post "Containerizing an ASP.NET Core Web API with Docker and Setting Up CI/CD with GitHub Actions".
Common Pitfalls
A rule that selects nothing. While trying the second violation, this was the first rule I wrote, and it passed despite the violation:
[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
The reason is that Types() by default only sees the types in the assemblies you loaded. EntityFrameworkQueryableExtensions lives in EF Core's assembly, which isn't loaded into the architecture; since the rule says "don't depend on an empty set", it always passes. When writing rules about external libraries you need Types(true) to include referenced types. The general lesson: break every new rule deliberately at least once, and don't trust it before you've seen it red.
A security warning stopping the build. TreatWarningsAsErrors is on in the Directory.Build.props from post 05. The first restore after I added the SQL Server provider to the test project ended like this (I shortened the path):
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
dotnet nuget why shows who brings the package in:
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)
Instead of silencing the warning, I defined the patched version (10.0.14) in Central Package Management and referenced it directly from the test project. Silencing the warning is easy; but NuGet audit stopping the build in CI is a fitness function in the same spirit as the architecture tests.
Mocking everything. Tests that set up the repositories, the unit of work and the shipping port with Moq and verify "this method was called with these arguments" lock onto the handler's implementation. Measure the use case's outcome: is the order in the list, did the stock go down, how many times was it saved?
Testing the adapter only with the InMemory provider or SQLite. The test goes green, but you haven't tested behavior specific to the production engine, like the decimal problem above. An adapter test only means something against the production engine.
Overly detailed architecture rules. Rules like "every handler's name must end with Handler" or "every class must be sealed" can be useful, but each one has a maintenance cost. Protect the Dependency Rule first; add naming rules when the team actually runs into a problem.
A Small Experiment
★☆☆ Fix the second violation: remove IOrderQueries, add a ListByCustomerAsync method to IOrderRepository, and write the query in Infrastructure. Run the architecture tests and the Infrastructure tests again.
★★☆ Write the rule "adapter classes in Infrastructure must be internal" and break it deliberately. Does the rule pass today? Could there be a legitimate reason to make an adapter public?
★★★ Add a new rule to Order: orders whose total exceeds 1000 TRY wait for approval. Write the core test first and see it red, then implement the rule. How many adapter tests did you have to touch for this change?
We now have a Clean Architecture solution that runs, is tested and protects itself. In the last post of the series we ask the hard question: when is all this too much? Template cargo cults, interface explosion, and ways to protect the same rules in a single project with less ceremony.