Architecture Tests — Enforcing Layer Boundaries With NetArchTest
How to write architecture tests with NetArchTest in .NET: testing layer dependencies, naming conventions, encapsulation rules, and why these tests prevent the codebase from silently drifting from Clean Architecture principles.
Why Architecture Tests
Code reviews catch some architectural violations. Senior developers catch some. Nobody catches all of them all the time, especially in a team of more than 2 people.
Architecture tests are automated checks that run on every CI build. If someone puts a DbContext injection in an Application handler, the build fails. No code review needed.
Production issue I've seen: A developer under deadline pressure added an EF Core query directly inside a command handler "just this once." The code worked, the PR was approved without a thorough review, and over the next year 12 more developers followed the same pattern. By the time the team tried to add integration tests, the Application layer had 40 direct EF Core dependencies. Architecture tests would have caught violation #1 and prevented all 12 that followed.
NetArchTest Package
<!-- tests/SystemForge.Architecture.Tests/SystemForge.Architecture.Tests.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="NetArchTest.Rules" Version="1.*" />
<PackageReference Include="xunit" Version="2.*" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.*" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.*" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Domain\SystemForge.Domain.csproj" />
<ProjectReference Include="..\..\src\Application\SystemForge.Application.csproj" />
<ProjectReference Include="..\..\src\Infrastructure\SystemForge.Infrastructure.csproj" />
<ProjectReference Include="..\..\src\Api\SystemForge.Api.csproj" />
</ItemGroup>
</Project>Test Setup
// tests/Architecture.Tests/ArchitectureTestBase.cs
namespace SystemForge.Architecture.Tests;
public abstract class ArchitectureTestBase
{
protected static readonly System.Reflection.Assembly DomainAssembly =
typeof(Domain.AssemblyReference).Assembly;
protected static readonly System.Reflection.Assembly ApplicationAssembly =
typeof(Application.AssemblyReference).Assembly;
protected static readonly System.Reflection.Assembly InfrastructureAssembly =
typeof(Infrastructure.AssemblyReference).Assembly;
protected static readonly System.Reflection.Assembly ApiAssembly =
typeof(Api.AssemblyReference).Assembly;
protected const string DomainNs = "SystemForge.Domain";
protected const string ApplicationNs = "SystemForge.Application";
protected const string InfrastructureNs = "SystemForge.Infrastructure";
protected const string ApiNs = "SystemForge.Api";
protected static void AssertNoDependency(
System.Reflection.Assembly assembly,
string forbiddenNamespace)
{
var result = Types.InAssembly(assembly)
.ShouldNot()
.HaveDependencyOn(forbiddenNamespace)
.GetResult();
Assert.True(result.IsSuccessful,
$"Violations:\n{string.Join("\n", result.FailingTypeNames ?? [])}");
}
}Layer Dependency Tests
// tests/Architecture.Tests/LayerDependencyTests.cs
public class LayerDependencyTests : ArchitectureTestBase
{
// ─── Domain must depend on nothing ────────────────────────────────────────
[Fact] public void Domain_must_not_reference_Application()
=> AssertNoDependency(DomainAssembly, ApplicationNs);
[Fact] public void Domain_must_not_reference_Infrastructure()
=> AssertNoDependency(DomainAssembly, InfrastructureNs);
[Fact] public void Domain_must_not_reference_Api()
=> AssertNoDependency(DomainAssembly, ApiNs);
// ─── Application must depend on Domain only ─────────────────────────────
[Fact] public void Application_must_not_reference_Infrastructure()
=> AssertNoDependency(ApplicationAssembly, InfrastructureNs);
[Fact] public void Application_must_not_reference_Api()
=> AssertNoDependency(ApplicationAssembly, ApiNs);
// ─── Infrastructure must not reference Api ───────────────────────────────
[Fact] public void Infrastructure_must_not_reference_Api()
=> AssertNoDependency(InfrastructureAssembly, ApiNs);
}Naming Convention Tests
// tests/Architecture.Tests/NamingConventionTests.cs
public class NamingConventionTests : ArchitectureTestBase
{
[Fact]
public void Command_handlers_must_be_in_Application_layer()
{
var result = Types.InAssembly(ApplicationAssembly)
.That().HaveNameEndingWith("CommandHandler")
.Should().ResideInNamespace(ApplicationNs)
.GetResult();
Assert.True(result.IsSuccessful,
string.Join("\n", result.FailingTypeNames ?? []));
}
[Fact]
public void Query_handlers_must_be_in_Application_layer()
{
var result = Types.InAssembly(ApplicationAssembly)
.That().HaveNameEndingWith("QueryHandler")
.Should().ResideInNamespace(ApplicationNs)
.GetResult();
Assert.True(result.IsSuccessful,
string.Join("\n", result.FailingTypeNames ?? []));
}
[Fact]
public void Repositories_must_be_in_Infrastructure_layer()
{
var result = Types.InAssembly(InfrastructureAssembly)
.That().HaveNameEndingWith("Repository")
.Should().ResideInNamespace(InfrastructureNs)
.GetResult();
Assert.True(result.IsSuccessful,
string.Join("\n", result.FailingTypeNames ?? []));
}
[Fact]
public void Controllers_must_be_in_Api_layer()
{
var result = Types.InAssembly(ApiAssembly)
.That().HaveNameEndingWith("Controller")
.Should().ResideInNamespace(ApiNs)
.GetResult();
Assert.True(result.IsSuccessful,
string.Join("\n", result.FailingTypeNames ?? []));
}
[Fact]
public void Validators_must_be_in_Application_layer()
{
var result = Types.InAssembly(ApplicationAssembly)
.That().HaveNameEndingWith("Validator")
.Should().ResideInNamespace(ApplicationNs)
.GetResult();
Assert.True(result.IsSuccessful,
string.Join("\n", result.FailingTypeNames ?? []));
}
}Encapsulation Tests
// tests/Architecture.Tests/EncapsulationTests.cs
public class EncapsulationTests : ArchitectureTestBase
{
[Fact]
public void Domain_entities_must_be_sealed()
{
// Entities should not be inherited — inheritance breaks encapsulation in DDD
var result = Types.InAssembly(DomainAssembly)
.That()
.Inherit(typeof(Entity<>))
.Should()
.BeSealed()
.GetResult();
Assert.True(result.IsSuccessful,
string.Join("\n", result.FailingTypeNames ?? []));
}
[Fact]
public void Command_handlers_must_be_sealed()
{
var result = Types.InAssembly(ApplicationAssembly)
.That().HaveNameEndingWith("CommandHandler")
.Should().BeSealed()
.GetResult();
Assert.True(result.IsSuccessful,
string.Join("\n", result.FailingTypeNames ?? []));
}
[Fact]
public void Controllers_must_be_sealed()
{
var result = Types.InAssembly(ApiAssembly)
.That().HaveNameEndingWith("Controller")
.Should().BeSealed()
.GetResult();
Assert.True(result.IsSuccessful,
string.Join("\n", result.FailingTypeNames ?? []));
}
}Interface Presence Tests
// tests/Architecture.Tests/InterfaceTests.cs
public class InterfaceTests : ArchitectureTestBase
{
[Fact]
public void Command_handlers_must_implement_ICommandHandler()
{
var result = Types.InAssembly(ApplicationAssembly)
.That()
.HaveNameEndingWith("CommandHandler")
.Should()
.ImplementInterface(typeof(ICommandHandler<,>))
.GetResult();
Assert.True(result.IsSuccessful,
string.Join("\n", result.FailingTypeNames ?? []));
}
[Fact]
public void Domain_events_must_implement_IDomainEvent()
{
var result = Types.InAssembly(DomainAssembly)
.That()
.HaveNameEndingWith("DomainEvent")
.Should()
.ImplementInterface(typeof(IDomainEvent))
.GetResult();
Assert.True(result.IsSuccessful,
string.Join("\n", result.FailingTypeNames ?? []));
}
}Running Architecture Tests in CI
# .github/workflows/ci.yml
- name: Run architecture tests
run: dotnet test tests/SystemForge.Architecture.Tests --no-build --configuration Release
# A violation on the Architecture.Tests project fails the build immediately
# This blocks the PR from merging until the violation is fixedPRO TIP — Fail Fast With Specific Messages
When an architecture test fails, the default message is "failing types: [...]" — just type names. Add the violation to the assert message so the developer knows immediately what to fix:
Assert.True(result.IsSuccessful,
$"""
Architecture violation in {assembly.GetName().Name}:
Rule: must not depend on '{forbiddenNamespace}'
Failing types:
{string.Join("\n", result.FailingTypeNames ?? [])}
""");Key Takeaway
Architecture tests are the automated equivalent of a senior developer reviewing every PR for layer violations. They run in milliseconds, never forget, and never have a bad day. Nine tests is enough to cover the core Clean Architecture rules. Once they are in CI, the architecture is self-enforcing — no amount of deadline pressure can accidentally break a structural rule without failing the build.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.