.NET & C# Development · Lesson 24 of 229
Architecture Tests — Enforce Layer Rules with NetArchTest
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
dotnet add package NetArchTest.Rules # lightweight, attribute-based
dotnet add package ArchUnitNET # more expressive, diagram-based
dotnet add package ArchUnitNET.xUnit # xUnit integrationBoth 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 ←── PresentationWithout enforcement, this happens over time:
// In Domain/Entities/Order.cs — a domain entity that imports EF Core
using Microsoft.EntityFrameworkCore; // VIOLATION: Domain depends on InfrastructureThe 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
// 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:
[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
[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:
// 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:
// 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:
# .github/workflows/ci.yml
- name: Run architecture tests
run: dotnet test tests/MyApp.ArchitectureTests/ --no-build --logger trxArchitecture 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:
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
.editorconfiginstead
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.