Skip to content
md ~/writing/clean-architecture-katman-testi-netarchtest
All posts

· 6 min read

Enforcing the Clean Architecture Dependency Rule with NetArchTest

Enforcing the Clean Architecture Dependency Rule with NetArchTest

Clean Architecture comes down to one rule: dependencies only point inward. Business rules know nothing about the database, the web framework or any outside library. That is simple on paper, but if nothing in the project actually guards the rule, it breaks on the first busy day. In this post I explain how I turned that rule into tests with NetArchTest in Tamirhane, the repair shop tracking app I'm building, and how I checked that the tests really catch violations.

The layers and the rule

Tamirhane has four projects:

Tamirhane.Web  ──►  Tamirhane.Application  ──►  Tamirhane.Domain
      │                      ▲
      └──► Tamirhane.Infrastructure
  • Domain: Entities and business rules: the work order status flow, quote totals, stock and warranty rules. It depends on no other project and no NuGet package.
  • Application: Use cases such as "receive a device" or "start repair". It knows outside services like storage, email and the clock only as interfaces: IWorkOrderRepository, ICurrentUser, IUnitOfWork.
  • Infrastructure: The implementations of those interfaces: EF Core with PostgreSQL, email, notifications.
  • Web: ASP.NET Core MVC, controllers and views.

The payoff is that I can test business rules without a database, in seconds. If PostgreSQL were replaced tomorrow, not a single line in Domain or Application would change.

The compiler only guards part of the rule

A fair question is whether project references already enforce this. Partly, yes. Application has no reference to Infrastructure, so if I write TamirhaneDbContext inside Application, the code doesn't compile.

But there are two gaps.

The first gap is the Web project. The app's entry point, Program.cs, has to register the Infrastructure services:

builder.Services.AddInfrastructure(connectionString);

So Web references Infrastructure. Once that reference exists, every file in Web can see Infrastructure. Injecting TamirhaneDbContext into a controller and querying the database directly is perfectly valid as far as the compiler is concerned. I think of this reference as a door opened for one file, Program.cs. The door is needed, but I don't want every controller walking through it.

The second gap is rules that references can't express. In Tamirhane a work order's status must never be assigned from outside. Instead of workOrder.Status = InRepair, a domain method is called, because the rule lives inside that method:

public Result StartRepair(Guid staffId, DateTimeOffset now) =>
    Status == WorkOrderStatus.Diagnosing
        ? MoveTo(WorkOrderStatus.InRepair, staffId, now, null)
        : WorkOrderErrors.InvalidTransition;

If a property's setter is written as public instead of private, the compiler says nothing, and a work order can suddenly skip diagnosis and land in repair.

I didn't want to rely on spotting these by eye in code review. If the rule is a test, it turns red the moment it breaks.

Writing the rules as tests with NetArchTest

NetArchTest is a small library that scans the types inside compiled assemblies and lets you write rules like "these types should not depend on that". Tamirhane has a separate test project for it, Tamirhane.Architecture.Tests, with five tests.

1. Domain depends on no other layer or framework

[Fact]
public void Domain_depends_on_no_other_layer_or_framework()
{
    AssertNoDependency(Types.InAssembly(Domain),
        ApplicationNs, InfrastructureNs, WebNs, EfCore, AspNetCore, "Microsoft.Extensions");
}

2. Domain references only .NET itself

The first test looks at types. I also check the assembly references: anything that doesn't start with System counts as a failure.

[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 depends only on Domain

[Fact]
public void Application_depends_only_on_domain()
{
    AssertNoDependency(Types.InAssembly(Application),
        InfrastructureNs, WebNs, EfCore, AspNetCore);
}

Microsoft.Extensions is not on the forbidden list here, because Application uses Microsoft.Extensions.DependencyInjection.Abstractions to register its own use cases. The forbidden list has to match the project's real decisions; a test that forbids everything is red on day one, and nobody trusts it after that.

4. Controllers don't use Infrastructure

This test closes the first gap the compiler can't see:

[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. Entities can't be changed from outside

The second gap can't be expressed with NetArchTest's built-in rules, so I wrote it with plain reflection: it lists every property with a public or internal setter on any class that derives from Entity.

[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)}");
}

The failure message is part of the job

When an architecture test fails, "Assert.True failed" is useless; you have to go hunting for who broke the rule. So a shared helper writes NetArchTest's FailingTypeNames list into the message:

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)}");
}

The messages are in Turkish because the project is; they read "should not depend on ..." and "types breaking the rule: ...".

Do the tests actually catch anything?

A green test doesn't prove it works. Maybe it can never turn red. So I copied the repository to a temporary folder and added violations on purpose.

Baseline: all 5 tests passed.

First attempt: I added two violations. The first was a controller that queries the database directly:

public class ShortcutController(TamirhaneDbContext db) : Controller
{
    public async Task<IActionResult> Index() => Json(await db.Customers.CountAsync());
}

The second changed the Name property on Customer from private set to set.

The project compiled without errors. The compiler missed both. The architecture tests caught both (output translated from Turkish):

Failed Controllers_do_not_use_infrastructure_directly
  Types breaking the rule: Tamirhane.Web.Controllers.ShortcutController
Failed Domain_entities_cannot_be_changed_from_outside
  Properties settable from outside: Customer.Name

Second attempt: I reverted those changes, added the Microsoft.Extensions.Logging.Abstractions package to the Domain project and wrote a small class that uses it. This time both Domain tests failed: one caught the type dependency, the other caught the assembly reference (again translated):

Failed Domain_assembly_references_only_the_runtime
  Domain must depend only on .NET; found: Microsoft.Extensions.Logging.Abstractions
Failed Domain_depends_on_no_other_layer_or_framework
  Types breaking the rule: Tamirhane.Domain.Common.DomainLog

In both attempts the message names the class that needs fixing. The five tests finish in under a second, so running them with the whole suite every time costs nothing.

What they don't catch

Architecture tests don't guarantee everything, and it's worth knowing their limits:

  • They only check the rules I wrote. A new layer or a new restriction means updating the tests too.
  • They say nothing about whether the code works. Correct business rules are the job of the Domain and Application tests.
  • Tamirhane has no CI yet; the tests run locally with the full suite. Once CI is added, they will stop pull requests before they are merged as well.

Summary

  • In Clean Architecture, project references already enforce part of the dependency rule, but the reference opened for the composition root and rules like private setters stay outside the compiler's reach.
  • With NetArchTest I turned these rules into five short tests; one of them uses plain reflection instead of NetArchTest.
  • The failure message names the type that broke the rule.
  • I checked that the tests really work by adding violations on purpose: the tests caught three violations the compiler let through.

Tamirhane's source code is on GitHub: github.com/MrcDprm/tamirhane-repair-tracker. The project is still in development; once it's finished, a full project post will be here.