Learnixo
Back to blog
AI Systemsintermediate

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.

Asma Hafeez KhanMay 16, 20264 min read
Clean Architecture.NETArchitecture TestsNetArchTestDependency Rule
Share:š•

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

XML
<!-- 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

C#
// 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 Infrastructure

Running the Tests

Bash
# 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

YAML
# .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 PR

Common Violations and Why They Happen

C#
// 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.

Enjoyed this article?

Explore the AI 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.