Learnixo
Back to blog
Backend Systemsintermediate

Architecture Tests in .NET — Enforce Layer Rules with NetArchTest

Use NetArchTest and ArchUnitNET to write automated tests that enforce Clean Architecture layer dependencies, naming conventions, access modifiers, and design rules in CI — catch violations before they reach production.

Asma Hafeez KhanMay 26, 20266 min read
.NETC#ArchitectureTestingNetArchTestClean ArchitectureCIDesign Rules
Share:𝕏

Architecture Tests in .NET — Enforce Layer Rules with NetArchTest

Architecture tests automatically verify that your code follows its own rules: that the Domain layer has no dependency on Infrastructure, that all command handlers live in the Application layer, that every repository interface is in Domain but every repository implementation is in Infrastructure. Without these tests, architecture erodes — one shortcut at a time — until the layers are meaningless.

What you'll learn:

  • Why architecture rules break without enforcement
  • NetArchTest: dependencies, naming, access modifiers
  • ArchUnitNET: more expressive layer rules
  • Testing patterns for Clean Architecture
  • Running architecture tests in CI
  • Custom rules for your specific conventions

Setup

Bash
dotnet add package NetArchTest.Rules           # lightweight, attribute-based
dotnet add package ArchUnitNET                 # more expressive, diagram-based
dotnet add package ArchUnitNET.xUnit           # xUnit integration

Both work on the compiled assembly — they reflect over types using System.Reflection and Mono.Cecil.


1. The Problem Architecture Tests Solve

With Clean Architecture, the dependency rule is: outer layers depend on inner layers, never the reverse.

Domain ←── Application ←── Infrastructure ←── Presentation

Without enforcement, this happens over time:

C#
// In Domain/Entities/Order.cs — a domain entity that imports EF Core
using Microsoft.EntityFrameworkCore; // VIOLATION: Domain depends on Infrastructure

The violation compiles, the tests pass, CI is green. But you've just made your domain untestable without a database. Architecture tests catch this at the PR stage.


2. NetArchTest — Basic Dependency Rules

C#
// Tests/ArchitectureTests.cs
public class ArchitectureTests
{
    // Load the assemblies once
    private static readonly Assembly DomainAssembly =
        typeof(Order).Assembly;  // MyApp.Domain

    private static readonly Assembly ApplicationAssembly =
        typeof(PlaceOrderCommand).Assembly;  // MyApp.Application

    private static readonly Assembly InfrastructureAssembly =
        typeof(AppDbContext).Assembly;  // MyApp.Infrastructure

    private static readonly Assembly ApiAssembly =
        typeof(Program).Assembly;  // MyApp.Api

    [Fact]
    public void Domain_Should_Not_Reference_Application()
    {
        var result = Types.InAssembly(DomainAssembly)
            .ShouldNot()
            .HaveDependencyOn(ApplicationAssembly.GetName().Name)
            .GetResult();

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

    [Fact]
    public void Domain_Should_Not_Reference_Infrastructure()
    {
        var result = Types.InAssembly(DomainAssembly)
            .ShouldNot()
            .HaveDependencyOn(InfrastructureAssembly.GetName().Name)
            .GetResult();

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

    [Fact]
    public void Application_Should_Not_Reference_Infrastructure()
    {
        var result = Types.InAssembly(ApplicationAssembly)
            .ShouldNot()
            .HaveDependencyOn(InfrastructureAssembly.GetName().Name)
            .GetResult();

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

    [Fact]
    public void Application_Should_Not_Reference_Api()
    {
        var result = Types.InAssembly(ApplicationAssembly)
            .ShouldNot()
            .HaveDependencyOn(ApiAssembly.GetName().Name)
            .GetResult();

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

    private static string FormatViolations(TestResult result)
    {
        if (result.IsSuccessful) return string.Empty;
        return "Architecture violations:\n" +
               string.Join("\n", result.FailingTypeNames.Select(t => $"  - {t}"));
    }
}

3. Naming Convention Rules

Enforce that classes follow the naming conventions your team agreed on:

C#
[Fact]
public void CommandHandlers_Should_Be_Named_With_Handler_Suffix()
{
    var result = Types.InAssembly(ApplicationAssembly)
        .That()
        .ImplementInterface(typeof(IRequestHandler<,>))
        .Should()
        .HaveNameEndingWith("Handler")
        .GetResult();

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

[Fact]
public void Repositories_Should_Be_Named_With_Repository_Suffix()
{
    var result = Types.InAssembly(InfrastructureAssembly)
        .That()
        .ImplementInterface(typeof(IRepository<>))
        .Should()
        .HaveNameEndingWith("Repository")
        .GetResult();

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

[Fact]
public void Controllers_Should_Be_In_Controllers_Namespace()
{
    var result = Types.InAssembly(ApiAssembly)
        .That()
        .HaveNameEndingWith("Controller")
        .Should()
        .ResideInNamespace("MyApp.Api.Controllers")
        .GetResult();

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

[Fact]
public void DomainEvents_Should_Reside_In_Domain()
{
    var result = Types.InAssemblies([ApplicationAssembly, InfrastructureAssembly, ApiAssembly])
        .That()
        .ImplementInterface(typeof(IDomainEvent))
        .Should()
        .ResideInAssembly(DomainAssembly)
        .GetResult();

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

4. Access Modifier Rules

C#
[Fact]
public void Domain_Entities_Should_Not_Have_Public_Setters()
{
    // All properties in Domain entities should have private/protected setters
    // (use factory methods and domain methods instead)
    var entityTypes = Types.InAssembly(DomainAssembly)
        .That()
        .ResideInNamespace("MyApp.Domain.Entities")
        .GetTypes();

    var violations = new List<string>();
    foreach (var type in entityTypes)
    {
        var publicSetters = type.GetProperties()
            .Where(p => p.SetMethod?.IsPublic == true)
            .ToList();

        if (publicSetters.Any())
            violations.Add($"{type.Name}: {string.Join(", ", publicSetters.Select(p => p.Name))}");
    }

    Assert.Empty(violations);
}

[Fact]
public void Infrastructure_DbContext_Should_Be_Internal()
{
    var result = Types.InAssembly(InfrastructureAssembly)
        .That()
        .HaveNameEndingWith("DbContext")
        .Should()
        .NotBePublic()
        .GetResult();

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

5. ArchUnitNET — Layer Diagram Approach

ArchUnitNET lets you define an architecture diagram and test against it:

C#
// Tests/ArchUnitArchitectureTests.cs
public class ArchUnitArchitectureTests
{
    private static readonly Architecture Architecture =
        new ArchLoader()
            .LoadAssemblies(
                typeof(Order).Assembly,
                typeof(PlaceOrderCommand).Assembly,
                typeof(AppDbContext).Assembly,
                typeof(Program).Assembly
            )
            .Build();

    // Define layers
    private static readonly IObjectProvider<IType> DomainLayer =
        ArchRuleDefinition.Types().That()
            .ResideInAssembly(typeof(Order).Assembly.GetName().Name!)
            .As("Domain Layer");

    private static readonly IObjectProvider<IType> ApplicationLayer =
        ArchRuleDefinition.Types().That()
            .ResideInAssembly(typeof(PlaceOrderCommand).Assembly.GetName().Name!)
            .As("Application Layer");

    private static readonly IObjectProvider<IType> InfrastructureLayer =
        ArchRuleDefinition.Types().That()
            .ResideInAssembly(typeof(AppDbContext).Assembly.GetName().Name!)
            .As("Infrastructure Layer");

    [Fact]
    public void Domain_Should_Not_Depend_On_Application_Or_Infrastructure()
    {
        IArchRule rule = ArchRuleDefinition.Types().That()
            .Are(DomainLayer)
            .Should()
            .NotDependOnAny(ApplicationLayer)
            .AndShould()
            .NotDependOnAny(InfrastructureLayer)
            .Because("Domain is the innermost layer");

        rule.Check(Architecture);
    }

    [Fact]
    public void Application_Should_Only_Depend_On_Domain()
    {
        IArchRule rule = ArchRuleDefinition.Types().That()
            .Are(ApplicationLayer)
            .Should()
            .NotDependOnAny(InfrastructureLayer)
            .Because("Application references Domain interfaces, not Infrastructure implementations");

        rule.Check(Architecture);
    }
}

6. Custom Rules — Your Team's Conventions

NetArchTest has an extension point for custom predicates:

C#
// Custom rule: all MediatR handlers must be sealed (prevent inheritance)
[Fact]
public void CommandHandlers_Should_Be_Sealed()
{
    var result = Types.InAssembly(ApplicationAssembly)
        .That()
        .ImplementInterface(typeof(IRequestHandler<,>))
        .Should()
        .BeSealed()
        .GetResult();

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

// Custom rule: validators must be in the same namespace as their command
[Fact]
public void Validators_Should_Match_Their_Command_Namespace()
{
    var validatorTypes = Types.InAssembly(ApplicationAssembly)
        .That()
        .HaveNameEndingWith("Validator")
        .GetTypes()
        .ToList();

    var violations = new List<string>();

    foreach (var validator in validatorTypes)
    {
        // e.g. PlaceOrderCommandValidator → expect namespace contains PlaceOrderCommand
        var commandName = validator.Name.Replace("Validator", "");
        var commandType = ApplicationAssembly.GetTypes()
            .FirstOrDefault(t => t.Name == commandName);

        if (commandType is not null &&
            validator.Namespace != commandType.Namespace)
        {
            violations.Add($"{validator.FullName} should be in {commandType.Namespace}");
        }
    }

    Assert.Empty(violations);
}

// Custom rule: no static classes in Application layer (except extensions)
[Fact]
public void Application_Should_Have_No_Static_Classes_Except_Extensions()
{
    var result = Types.InAssembly(ApplicationAssembly)
        .That()
        .AreStatic()
        .And()
        .DoNotHaveNameEndingWith("Extensions")
        .Should()
        .NotExist()
        .GetResult();

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

7. Running in CI

Architecture tests run as standard xUnit tests — no special CI setup:

YAML
# .github/workflows/ci.yml
- name: Run architecture tests
  run: dotnet test tests/MyApp.ArchitectureTests/ --no-build --logger trx

Architecture tests are fast (reflection-based, no I/O) — they typically run in under 1 second. Put them in a dedicated project so they're always run and the failure message is clear.

Failure message quality

Make the failure message explicit enough for a developer to fix without context:

C#
private static string FormatViolations(TestResult result)
{
    if (result.IsSuccessful) return string.Empty;

    var violations = string.Join("\n", result.FailingTypeNames.Select(t => $"  ✗ {t}"));
    return $"""
        Architecture rule violated. These types break the rule:
        {violations}

        Fix: move the type to the correct layer, or remove the forbidden dependency.
        Rule: {result.RuleDescription}
        """;
}

What to Test vs What to Skip

Good candidates for architecture tests:

  • Layer dependency rules (the core of Clean Architecture)
  • Naming conventions your team actually enforces
  • That domain entities don't have public setters
  • That handlers are sealed
  • That your API controllers don't access repositories directly

Not worth architecture-testing:

  • "Best practices" your team doesn't consistently follow — the test will have too many exceptions
  • Rules that change frequently — maintenance cost outweighs the benefit
  • Formatting or style rules — use an analyser (Roslyn) or .editorconfig instead

Architecture tests are most valuable when they protect invariants that, if broken, cause real maintenance problems. A domain entity that imports EF Core is such an invariant — it makes the domain untestable without a real database and couples your business logic to your persistence technology.

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.