The Dependency Rule ā Enforcing It With Architecture Tests
What the Dependency Rule means in practice, how to verify it with NetArchTest, the nine tests included in the Clean Architecture template, and why CI enforcement is the only reliable form.
The Rule, Stated Simply
Every source code dependency must point inward ā toward higher-level policy, never outward toward lower-level details.
Domain ā knows nothing about anything outside itself
Application ā knows Domain only
Infrastructure ā knows Domain and Application
Api ā knows Application and Infrastructure
Outer layers can change freely without Domain or Application knowing.The rule is enforced at two levels: project references (compile-time) and architecture tests (CI time).
Why Project References Alone Are Not Enough
Project references prevent a project from importing types from a project it doesn't reference. They cannot prevent violations within a referenced project boundary ā for example, a class in the Application layer that happens to import Microsoft.EntityFrameworkCore (a namespace available transitively).
Architecture tests catch exactly these cases.
The NetArchTest Package
<!-- tests/Architecture.Tests/Architecture.Tests.csproj -->
<ItemGroup>
<PackageReference Include="NetArchTest.Rules" Version="1.*" />
<PackageReference Include="xunit" Version="2.*" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.*" />
<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>All Nine Architecture Tests
// tests/Architecture.Tests/LayerDependencyTests.cs
using NetArchTest.Rules;
using Xunit;
public class LayerDependencyTests
{
// Assembly handles
private static readonly System.Reflection.Assembly DomainAssembly =
typeof(Domain.AssemblyReference).Assembly;
private static readonly System.Reflection.Assembly ApplicationAssembly =
typeof(Application.AssemblyReference).Assembly;
private static readonly System.Reflection.Assembly InfrastructureAssembly =
typeof(Infrastructure.AssemblyReference).Assembly;
private static readonly System.Reflection.Assembly ApiAssembly =
typeof(Api.AssemblyReference).Assembly;
// Namespace strings
private const string Domain = "SystemForge.Domain";
private const string Application = "SystemForge.Application";
private const string Infrastructure = "SystemForge.Infrastructure";
private const string Api = "SystemForge.Api";
// āāā Domain must depend on nothing āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
[Fact]
public void Domain_must_not_depend_on_Application()
=> AssertNoDependency(DomainAssembly, Application);
[Fact]
public void Domain_must_not_depend_on_Infrastructure()
=> AssertNoDependency(DomainAssembly, Infrastructure);
[Fact]
public void Domain_must_not_depend_on_Api()
=> AssertNoDependency(DomainAssembly, Api);
// āāā Application must depend on Domain only āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
[Fact]
public void Application_must_not_depend_on_Infrastructure()
=> AssertNoDependency(ApplicationAssembly, Infrastructure);
[Fact]
public void Application_must_not_depend_on_Api()
=> AssertNoDependency(ApplicationAssembly, Api);
// āāā Infrastructure must not depend on Api āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
[Fact]
public void Infrastructure_must_not_depend_on_Api()
=> AssertNoDependency(InfrastructureAssembly, Api);
// āāā Naming conventions āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
[Fact]
public void Handlers_must_be_in_Application_layer()
{
var result = Types.InAssembly(ApplicationAssembly)
.That()
.HaveNameEndingWith("Handler")
.Should()
.ResideInNamespace(Application)
.GetResult();
Assert.True(result.IsSuccessful, Format(result));
}
[Fact]
public void Repositories_must_be_in_Infrastructure_layer()
{
var result = Types.InAssembly(InfrastructureAssembly)
.That()
.HaveNameEndingWith("Repository")
.Should()
.ResideInNamespace(Infrastructure)
.GetResult();
Assert.True(result.IsSuccessful, Format(result));
}
[Fact]
public void Controllers_must_be_in_Api_layer()
{
var result = Types.InAssembly(ApiAssembly)
.That()
.HaveNameEndingWith("Controller")
.Should()
.ResideInNamespace(Api)
.GetResult();
Assert.True(result.IsSuccessful, Format(result));
}
// āāā Helpers āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
private static void AssertNoDependency(
System.Reflection.Assembly assembly,
string forbiddenNamespace)
{
var result = Types.InAssembly(assembly)
.ShouldNot()
.HaveDependencyOn(forbiddenNamespace)
.GetResult();
Assert.True(result.IsSuccessful, Format(result));
}
private static string Format(TestResult result)
=> string.Join("\n", result.FailingTypeNames ?? []);
}What Each Test Catches
Test Catches
āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
Domain ā Application forbidden Handler accidentally put in Domain
Domain ā Infrastructure forbidden DbContext imported in an entity
Domain ā Api forbidden Controller referenced in Domain
Application ā Infrastructure forbidden PatientRepository instantiated in handler
Application ā Api forbidden IActionResult used in Application
Infrastructure ā Api forbidden Controller called from a repository
Handlers in Application Handler accidentally placed in Api
Repositories in Infrastructure Repository accidentally placed in Application
Controllers in Api Controller accidentally placed in InfrastructureRunning the Tests
# Run only architecture tests
dotnet test tests/SystemForge.Architecture.Tests
# Run all tests
dotnet test
# Fail fast on first violation
dotnet test --no-build --filter "FullyQualifiedName~LayerDependency"CI Pipeline Integration
# .github/workflows/ci.yml
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.x'
- run: dotnet build --configuration Release
- run: dotnet test --no-build --configuration Release
# Architecture tests run as part of dotnet test
# A violation causes the step to fail and blocks the PRCommon Violations and Why They Happen
// Violation 1: importing EF Core types into the Application layer
// Application/Handlers/GetPatientQueryHandler.cs ā WRONG
using Microsoft.EntityFrameworkCore; // ā Application should not know about EF Core
public sealed class GetPatientQueryHandler
{
private readonly AppDbContext _context; // ā direct DB dependency in Application
// Fix: inject IPatientRepository instead (defined in Application/Abstractions/)
}
// Violation 2: instantiating infrastructure in a handler ā WRONG
public sealed class CreatePatientCommandHandler
{
private readonly PatientRepository _repo = new(); // ā concrete type, not interface
// Fix: inject IPatientRepository, let DI resolve to PatientRepository
}
// Violation 3: putting business logic in infrastructure ā WRONG
// Infrastructure/Repositories/PatientRepository.cs
public async Task<Result<Patient>> CreateAsync(CreatePatientCommand cmd)
{
if (await _context.Patients.AnyAsync(p => p.Name == cmd.Name))
return Result.Failure<Patient>(PatientErrors.NameTaken); // ā business logic in infra
// Fix: this logic belongs in the Application handler
}Key Takeaway
The Dependency Rule is the only rule that defines Clean Architecture. Project references enforce it at compile time ā if Infrastructure imports Domain, the compiler allows it; if Domain imports Infrastructure, the compiler blocks it. Architecture tests with NetArchTest catch the subtler violations: transitive namespace imports, mis-placed type names, and naming convention drift. Running them on every CI build means the rule cannot be accidentally broken and silently shipped.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.