Architecture Testing in .NET with NetArchTest: Enforcing Clean Architecture in CI

Architecture Testing in .NET with NetArchTest: Enforcing Clean Architecture in CI

Architecture violations are silent killers. A domain entity that imports an EF Core context, a service class that directly instantiates a repository, a controller that contains business logic — none of these blow up at compile time. They accumulate quietly until the codebase becomes a tangle that no amount of refactoring can untangle without a rewrite.

The fix isn't better code review. It's architecture tests: automated assertions that verify structural rules at the same time and with the same authority as unit tests. This post covers how to do that in .NET using NetArchTest.

Why Test Architecture?

Code review catches violations when a reviewer happens to notice them. Architecture tests catch them always, in CI, before merge, regardless of who's reviewing.

The value compounds over time. Early in a project, everyone remembers the rules. Six months in, with five engineers, two contractors, and a deadline, the rules live only in a wiki page nobody reads. Architecture tests are executable documentation — they fail loud when someone violates what was agreed.

Specific things architecture tests prevent:

  • Layer bleeding: domain logic importing infrastructure packages (Microsoft.EntityFrameworkCore, Newtonsoft.Json, AWS SDK)
  • Naming drift: classes called UserHelper, OrderProcessor, DataManager that don't map to any architectural concept
  • Missing abstractions: repositories implemented as concrete classes instead of interfaces
  • Sync-over-async: calling .Result or .GetAwaiter().GetResult() on tasks inside service methods, causing thread pool starvation
  • Circular dependencies: two assemblies importing each other

NetArchTest Setup

NetArchTest works against compiled assemblies. You point it at a type from the target assembly, it loads all types from that assembly, and then you run fluent assertions against them.

Install via NuGet:

dotnet add package NetArchTest.Rules

Add it to your test project — not your production project. A typical solution structure:

MyApp.sln
├── src/
│   ├── MyApp.Domain/
│   ├── MyApp.Application/
│   ├── MyApp.Infrastructure/
│   └── MyApp.Api/
└── tests/
    ├── MyApp.UnitTests/
    └── MyApp.ArchitectureTests/   ← new project

The architecture test project references all your production projects so it can load their types. It has no runtime role — it only exists for tests.

<!-- MyApp.ArchitectureTests.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="NetArchTest.Rules" Version="1.3.2" />
    <PackageReference Include="xunit" Version="2.6.6" />
    <PackageReference Include="xunit.runner.visualstudio" Version="2.5.6" />
  </ItemGroup>
  <ItemGroup>
    <ProjectReference Include="..\..\src\MyApp.Domain\MyApp.Domain.csproj" />
    <ProjectReference Include="..\..\src\MyApp.Application\MyApp.Application.csproj" />
    <ProjectReference Include="..\..\src\MyApp.Infrastructure\MyApp.Infrastructure.csproj" />
    <ProjectReference Include="..\..\src\MyApp.Api\MyApp.Api.csproj" />
  </ItemGroup>
</Project>

A base class that provides typed access to each assembly:

using System.Reflection;
using MyApp.Domain;
using MyApp.Application;
using MyApp.Infrastructure;
using MyApp.Api;

public abstract class ArchitectureTestBase
{
    protected static readonly Assembly DomainAssembly =
        typeof(IEntity).Assembly;  // any type from Domain

    protected static readonly Assembly ApplicationAssembly =
        typeof(ICommandHandler<>).Assembly;

    protected static readonly Assembly InfrastructureAssembly =
        typeof(AppDbContext).Assembly;

    protected static readonly Assembly ApiAssembly =
        typeof(Program).Assembly;
}

Testing Layer Dependencies

Clean Architecture has a strict dependency rule: dependencies point inward. Domain knows nothing outside itself. Application knows Domain. Infrastructure knows Application and Domain. Api knows Infrastructure.

public class LayerDependencyTests : ArchitectureTestBase
{
    [Fact]
    public void Domain_Should_Not_Reference_Application()
    {
        var result = Types.InAssembly(DomainAssembly)
            .ShouldNot()
            .HaveDependencyOn("MyApp.Application")
            .GetResult();

        Assert.True(result.IsSuccessful,
            FormatViolations("Domain references Application", result));
    }

    [Fact]
    public void Domain_Should_Not_Reference_Infrastructure()
    {
        var result = Types.InAssembly(DomainAssembly)
            .ShouldNot()
            .HaveDependencyOnAny(
                "MyApp.Infrastructure",
                "Microsoft.EntityFrameworkCore",
                "Amazon.DynamoDBv2",
                "MongoDB.Driver")
            .GetResult();

        Assert.True(result.IsSuccessful,
            FormatViolations("Domain references Infrastructure", result));
    }

    [Fact]
    public void Application_Should_Not_Reference_Infrastructure()
    {
        var result = Types.InAssembly(ApplicationAssembly)
            .ShouldNot()
            .HaveDependencyOnAny(
                "MyApp.Infrastructure",
                "Microsoft.EntityFrameworkCore")
            .GetResult();

        Assert.True(result.IsSuccessful,
            FormatViolations("Application references Infrastructure", result));
    }

    [Fact]
    public void Infrastructure_Should_Not_Reference_Api()
    {
        var result = Types.InAssembly(InfrastructureAssembly)
            .ShouldNot()
            .HaveDependencyOn("MyApp.Api")
            .GetResult();

        Assert.True(result.IsSuccessful,
            FormatViolations("Infrastructure references Api", result));
    }

    private static string FormatViolations(string rule, TestResult result)
    {
        var violators = result.FailingTypes?
            .Select(t => t.FullName)
            .ToList() ?? new List<string>();

        return $"Rule violated: {rule}\nOffending types:\n" +
               string.Join("\n", violators.Select(v => $"  - {v}"));
    }
}

When a test fails, the error message lists exactly which types violated the rule. No hunting through the assembly.

Testing Naming Conventions

Consistent naming makes a codebase navigable. If everything that ends in Service is actually a service class, you can orient yourself in an unfamiliar module in seconds. Tests make that guarantee.

public class NamingConventionTests : ArchitectureTestBase
{
    [Fact]
    public void Services_Should_End_With_Service()
    {
        // Types in the Application layer's Services namespace must end in "Service"
        var result = Types.InAssembly(ApplicationAssembly)
            .That()
            .ResideInNamespace("MyApp.Application.Services")
            .And()
            .AreClasses()
            .Should()
            .HaveNameEndingWith("Service")
            .GetResult();

        Assert.True(result.IsSuccessful,
            FormatViolations("Service classes must end with 'Service'", result));
    }

    [Fact]
    public void Repositories_Should_End_With_Repository()
    {
        var result = Types.InAssembly(InfrastructureAssembly)
            .That()
            .ResideInNamespace("MyApp.Infrastructure.Repositories")
            .And()
            .AreClasses()
            .Should()
            .HaveNameEndingWith("Repository")
            .GetResult();

        Assert.True(result.IsSuccessful,
            FormatViolations("Repository classes must end with 'Repository'", result));
    }

    [Fact]
    public void Controllers_Should_End_With_Controller()
    {
        var result = Types.InAssembly(ApiAssembly)
            .That()
            .Inherit(typeof(ControllerBase))
            .Should()
            .HaveNameEndingWith("Controller")
            .GetResult();

        Assert.True(result.IsSuccessful,
            FormatViolations("Controllers must end with 'Controller'", result));
    }

    [Fact]
    public void Domain_Entities_Should_Live_In_Entities_Namespace()
    {
        var result = Types.InAssembly(DomainAssembly)
            .That()
            .Implement(typeof(IEntity))
            .Should()
            .ResideInNamespace("MyApp.Domain.Entities")
            .GetResult();

        Assert.True(result.IsSuccessful,
            FormatViolations("Entities must reside in Domain.Entities namespace", result));
    }

    private static string FormatViolations(string rule, TestResult result) { /* same as above */ }
}

Testing Interfaces: Repositories Must Be Interfaces

A concrete repository inside Application breaks testability. Mocking a concrete class is possible but fragile; mocking an interface is clean. Architecture tests enforce the abstraction exists.

public class InterfaceTests : ArchitectureTestBase
{
    [Fact]
    public void Repository_Interfaces_Should_Be_In_Domain()
    {
        // IUserRepository, IOrderRepository etc. must live in Domain, not Infrastructure
        var result = Types.InAssembly(DomainAssembly)
            .That()
            .HaveNameEndingWith("Repository")
            .Should()
            .BeInterfaces()
            .GetResult();

        Assert.True(result.IsSuccessful,
            FormatViolations("Repository contracts must be interfaces in Domain", result));
    }

    [Fact]
    public void Repository_Implementations_Should_Be_In_Infrastructure()
    {
        var result = Types.InAssembly(InfrastructureAssembly)
            .That()
            .HaveNameEndingWith("Repository")
            .And()
            .AreClasses()
            .Should()
            .ResideInNamespace("MyApp.Infrastructure.Repositories")
            .GetResult();

        Assert.True(result.IsSuccessful,
            FormatViolations("Repository implementations must be in Infrastructure.Repositories", result));
    }

    [Fact]
    public void Services_In_Application_Should_Be_Public()
    {
        var result = Types.InAssembly(ApplicationAssembly)
            .That()
            .HaveNameEndingWith("Service")
            .Should()
            .BePublic()
            .GetResult();

        Assert.True(result.IsSuccessful,
            FormatViolations("Application services must be public", result));
    }
}

Testing Async Patterns: No Sync-Over-Async

.Result and .GetAwaiter().GetResult() on tasks cause thread pool starvation under load. They're the number one cause of deadlocks in ASP.NET Core services that haven't been properly awaited. You can't detect these with a code style rule alone — NetArchTest lets you write custom predicates.

public class AsyncPatternTests : ArchitectureTestBase
{
    [Fact]
    public void Services_Should_Not_Use_Result_On_Tasks()
    {
        // Custom predicate: look for IL that accesses Task.Result
        var result = Types.InAssembly(ApplicationAssembly)
            .That()
            .HaveNameEndingWith("Service")
            .ShouldNot()
            .MeetCustomRule(new AccessesTaskResultRule())
            .GetResult();

        Assert.True(result.IsSuccessful,
            FormatViolations("Services must not call .Result on Tasks", result));
    }
}

The custom rule class:

using Mono.Cecil;
using Mono.Cecil.Cil;
using NetArchTest.Rules;

public class AccessesTaskResultRule : ICustomRule
{
    public bool MeetsRule(TypeDefinition type)
    {
        foreach (var method in type.Methods)
        {
            if (!method.HasBody) continue;

            foreach (var instruction in method.Body.Instructions)
            {
                if (instruction.OpCode == OpCodes.Call ||
                    instruction.OpCode == OpCodes.Callvirt)
                {
                    if (instruction.Operand is MethodReference methodRef &&
                        methodRef.Name == "get_Result" &&
                        methodRef.DeclaringType.FullName.StartsWith("System.Threading.Tasks.Task"))
                    {
                        return false; // violates the rule
                    }
                }
            }
        }
        return true; // passes
    }
}

MeetsRule returning true means the type is compliant. The ShouldNot().MeetCustomRule() pattern inverts this: types where MeetsRule returns false are flagged as violations.

NetArchTest uses Mono.Cecil internally to inspect IL, which is what makes this kind of deep inspection possible without reflection.

Writing Custom Predicate Rules

Beyond async patterns, custom rules let you encode any structural constraint. A few practical examples:

Domain entities must not have public setters:

public class NoPublicSettersOnEntitiesRule : ICustomRule
{
    public bool MeetsRule(TypeDefinition type)
    {
        foreach (var property in type.Properties)
        {
            if (property.SetMethod != null &&
                property.SetMethod.IsPublic)
            {
                return false;
            }
        }
        return true;
    }
}

[Fact]
public void Domain_Entities_Should_Not_Have_Public_Setters()
{
    var result = Types.InAssembly(DomainAssembly)
        .That()
        .Implement(typeof(IEntity))
        .Should()
        .MeetCustomRule(new NoPublicSettersOnEntitiesRule())
        .GetResult();

    Assert.True(result.IsSuccessful,
        FormatViolations("Entities must use private/init setters only", result));
}

Command handlers must be sealed:

[Fact]
public void CommandHandlers_Should_Be_Sealed()
{
    var result = Types.InAssembly(ApplicationAssembly)
        .That()
        .ImplementInterface(typeof(ICommandHandler<>))
        .Should()
        .BeSealed()
        .GetResult();

    Assert.True(result.IsSuccessful,
        FormatViolations("Command handlers must be sealed", result));
}

No static classes in Domain (except extension methods):

public class NoArbitraryStaticClassesRule : ICustomRule
{
    public bool MeetsRule(TypeDefinition type)
    {
        if (!type.IsAbstract || !type.IsSealed) return true; // not static
        // Allow extension method classes
        return type.Methods.Any(m => m.CustomAttributes
            .Any(a => a.AttributeType.Name == "ExtensionAttribute"));
    }
}

Running in CI and Failing the Build on Violations

Architecture tests are xUnit tests. They run with dotnet test. No special CI configuration needed — whatever runs your unit tests runs these.

# .github/workflows/build.yml
- name: Run all tests including architecture tests
  run: dotnet test --configuration Release --no-build --logger trx

Because architecture tests are fast (milliseconds — they don't hit the network or disk), there's no reason to separate them into a different pipeline stage. Run them in the same step as unit tests.

One thing to be deliberate about: architecture tests should be in a clearly named project (*.ArchitectureTests) so that if one fails, the developer immediately knows it's a structural violation, not a logic bug. A failing test named Domain_Should_Not_Reference_Infrastructure is self-documenting.

Making violations informative. The FormatViolations helper shown earlier matters for CI readability. When a build fails on a merge request, the engineer needs to understand the violation from the CI log without running the tests locally:

Rule violated: Domain references Infrastructure
Offending types:
  - MyApp.Domain.Entities.Order (references Microsoft.EntityFrameworkCore.DbContext)
  - MyApp.Domain.ValueObjects.Money (references Newtonsoft.Json.JsonConverter)

That output names exactly what to fix. Compare to a bare assertion failure with no context.

Suppressing intentional violations. Sometimes you need to make a temporary exception (a migration class that spans layers, a legacy adapter). Document it explicitly:

[Fact]
public void Domain_Should_Not_Reference_Infrastructure()
{
    var result = Types.InAssembly(DomainAssembly)
        .That()
        // Exclude the legacy payment adapter during migration period (remove by 2026-Q3)
        .DoNotHaveName("LegacyPaymentAdapter")
        .ShouldNot()
        .HaveDependencyOn("MyApp.Infrastructure")
        .GetResult();

    Assert.True(result.IsSuccessful, FormatViolations(...));
}

An exclusion with a comment and a deadline is honest. An exclusion with no comment is a hole in your architecture.

Alternatives: ArchUnitNET

ArchUnitNET is the other major option in the .NET space, ported from ArchUnit (the Java library). It's more expressive for complex dependency graphs and has built-in support for cycle detection:

// ArchUnitNET syntax
private static readonly Architecture Architecture =
    new ArchLoader().LoadAssemblies(
        typeof(Order).Assembly,
        typeof(OrderService).Assembly)
    .Build();

[Fact]
public void Domain_Should_Not_Depend_On_Infrastructure()
{
    var domainLayer = Architecture.Assemblies
        .WithName("MyApp.Domain").As("Domain layer");

    var infraLayer = Architecture.Assemblies
        .WithName("MyApp.Infrastructure").As("Infrastructure layer");

    AreTypes().That().Are(domainLayer)
        .Should().NotDependOnAny(infraLayer)
        .Check(Architecture);
}

ArchUnitNET is stronger for detecting cycles and has a richer predicate model. NetArchTest has a simpler API and is easier to get started with. For most projects, NetArchTest covers everything needed. If you find yourself hitting its limits — complex multi-assembly dependency graphs, custom cycle analysis — switch to ArchUnitNET.

Key Takeaways

Architecture tests are cheap to write and expensive to skip. A test that verifies Domain doesn't import Infrastructure takes ten lines. The refactoring cost of discovering that violation eighteen months later is weeks.

Start with the dependency tests. Layer dependencies are the highest-value rules to enforce. Everything else — naming, interfaces, async patterns — is secondary. Get the dependency graph locked first.

Write the rules you actually agreed on. Don't test naming conventions you don't actually enforce in code review. Architecture tests that aren't backed by team agreement become noise that people learn to ignore.

Custom rules cover anything. NetArchTest's ICustomRule with Mono.Cecil gives you IL-level access. If you can inspect it in code, you can test it.

Treat violations as build failures. Architecture tests that live in a separate optional pipeline don't protect anything. They run with dotnet test, same stage, same severity as unit test failures. A violation that doesn't break the build isn't a constraint — it's a suggestion.

The goal is a codebase where the rules exist in two places: the team's agreed design, and the test suite. When those two things agree, the architecture stays honest over time without relying on anyone's memory.

Read more

Start now free