🏗️ Make Bad Architecture Fail the Build

Architecture Testing in .NET: Enforce Layer and Module Boundaries with NetArchTest and ArchUnitNET

Turn architectural decisions into executable rules that identify forbidden dependencies and stop them from entering the main branch.

What You Will Build

You will build a dedicated architecture-test project that checks the shape of a layered .NET solution and fails the build when a dependency points in the wrong direction.

ArchitectureGuard.Api
        │
        ▼
ArchitectureGuard.Infrastructure ──► ArchitectureGuard.Application ──► ArchitectureGuard.Domain

Allowed direction: toward Domain only.
Architecture tests reject every forbidden reverse arrow.

The sample also includes a modular-monolith rule: Orders may reference Billing.Contracts, but it must not reference Billing's internal implementation.

Verified stack. This page targets .NET 10, xUnit v3, NetArchTest.eNhancedEdition, and ArchUnitNET. The original NetArchTest.Rules package is not used.

Architectural Drift and Why Reviews Do Not Stop It

An architecture diagram is easy to approve and easy to ignore. Under deadline pressure, one convenient project reference can quietly reverse the intended dependency direction.

Test typeQuestion
Unit testDoes this class behave correctly?
Integration testDo these components work together?
Architecture testIs this structural dependency allowed?

Architecture tests run through the normal test runner. The difference is that they inspect compiled assemblies and type relationships instead of calling application behavior.

Important: a green architecture suite does not prove the architecture is good. It proves only that the code follows the rules your team wrote.

Prerequisites

  • .NET 10 SDK and an IDE.
  • Basic xUnit knowledge.
  • A multi-project solution with clear layers or modules.
  • A CI job that can run dotnet test.

Create the Solution and Test Project

Create four application projects and one dedicated architecture-test project.

bash
dotnet new sln -n ArchitectureGuard
mkdir src tests

dotnet new classlib -n ArchitectureGuard.Domain -o src/ArchitectureGuard.Domain -f net10.0
dotnet new classlib -n ArchitectureGuard.Application -o src/ArchitectureGuard.Application -f net10.0
dotnet new classlib -n ArchitectureGuard.Infrastructure -o src/ArchitectureGuard.Infrastructure -f net10.0
dotnet new webapi -n ArchitectureGuard.Api -o src/ArchitectureGuard.Api -f net10.0

dotnet new install xunit.v3.templates
dotnet new xunit3 -n ArchitectureGuard.ArchitectureTests -o tests/ArchitectureGuard.ArchitectureTests -f net10.0

dotnet sln add src/*/*.csproj tests/*/*.csproj

Add project references from the test project to every assembly that it must inspect.

xml
<Project>
  <PropertyGroup>
    <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
  </PropertyGroup>
  <ItemGroup>
    <PackageVersion Include="NetArchTest.eNhancedEdition" Version="x.y.z" />
    <PackageVersion Include="TngTech.ArchUnitNET.xUnitV3" Version="x.y.z" />
    <PackageVersion Include="xunit.v3" Version="x.y.z" />
    <PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="x.y.z" />
  </ItemGroup>
</Project>
Package choice: install NetArchTest.eNhancedEdition, not the old NetArchTest.Rules package. Keep exact versions in Directory.Packages.props.

Your First Rule: Dependency Direction

Start with the most valuable rule: Domain must not depend on any outer layer.

csharp
using ArchitectureGuard.Domain;
using NetArchTest.Rules;
using Xunit;

namespace ArchitectureGuard.ArchitectureTests;

public sealed class LayerRules
{
    private static readonly Assembly DomainAssembly = typeof(DomainAssemblyMarker).Assembly;

    [Fact]
    public void Domain_must_not_depend_on_outer_layers()
    {
        TestResult result = Types.InAssembly(DomainAssembly)
            .ShouldNot()
            .HaveDependencyOnAny(
                "ArchitectureGuard.Application",
                "ArchitectureGuard.Infrastructure",
                "ArchitectureGuard.Api")
            .GetResult();

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

    private static string FormatFailure(TestResult result) =>
        string.Join(Environment.NewLine,
            result.FailingTypes.Select(type =>
                $"{type.FullName}: {type.Explanation}"));
}

Now prove the test works. Temporarily reference an Infrastructure type from Domain and run the suite. The test should turn red and name the offending type.

bash
dotnet test tests/ArchitectureGuard.ArchitectureTests -c Debug
Trust but verify: every architecture rule should have a demonstrated failure case. A selector that matches zero types can otherwise pass without protecting anything.

Make Failures Readable

A useful failure should explain the architectural decision, the offending type, and the dependency that caused the problem.

csharp
private static string FormatFailure(string rule, TestResult result)
{
    if (result.IsSuccessful)
    {
        return string.Empty;
    }

    var failures = result.FailingTypes.Select(type =>
        $" - {type.FullName}{Environment.NewLine}   {type.Explanation}");

    return $"Architecture rule failed: {rule}{Environment.NewLine}" +
           string.Join(Environment.NewLine, failures);
}

A message such as Domain must remain independent of Infrastructure is far more useful than a bare Assert.True(false).

Write the Same Rule with ArchUnitNET

ArchUnitNET loads an architecture model once, then evaluates fluent rules against it.

csharp
using ArchUnitNET.Domain;
using ArchUnitNET.Loader;
using ArchUnitNET.Fluent;
using Xunit;
using static ArchUnitNET.Fluent.ArchRuleDefinition;

public sealed class ArchUnitLayerRules
{
    private static readonly Architecture Architecture = new ArchLoader()
        .LoadAssemblies(
            typeof(DomainAssemblyMarker).Assembly,
            typeof(ApplicationAssemblyMarker).Assembly,
            typeof(InfrastructureAssemblyMarker).Assembly,
            typeof(Program).Assembly)
        .Build();

    private static readonly IObjectProvider<IType> DomainLayer =
        Types().That().ResideInAssembly(typeof(DomainAssemblyMarker).Assembly)
            .As("Domain layer");

    private static readonly IObjectProvider<IType> InfrastructureLayer =
        Types().That().ResideInAssembly(typeof(InfrastructureAssemblyMarker).Assembly)
            .As("Infrastructure layer");

    [Fact]
    public void Domain_must_not_depend_on_infrastructure()
    {
        Types().That().Are(DomainLayer).Should()
            .NotDependOnAny(InfrastructureLayer)
            .Because("Domain must stay independent of infrastructure concerns")
            .Check(Architecture);
    }
}

Teams familiar with Java ArchUnit often prefer this model. For most solutions, choose either ArchUnitNET or eNhancedEdition as the primary fluent library rather than expressing the same rule twice.

Convention Rules

After dependency direction, add a small number of conventions that protect decisions your team actually cares about.

csharp
[Fact]
public void Handlers_must_be_sealed_and_named_handler()
{
    TestResult result = Types.InAssembly(typeof(ApplicationAssemblyMarker).Assembly)
        .That()
        .ImplementInterface(typeof(IRequestHandler<,>))
        .Should()
        .BeSealed()
        .AndShould()
        .HaveNameEndingWith("Handler")
        .GetResult();

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

[Fact]
public void Domain_value_objects_must_be_immutable()
{
    TestResult result = Types.InAssembly(typeof(DomainAssemblyMarker).Assembly)
        .That()
        .ResideInNamespace("ArchitectureGuard.Domain.ValueObjects")
        .Should()
        .BeImmutableExternally()
        .GetResult();

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

Avoid creating dozens of cosmetic rules. Each rule adds maintenance cost and should protect a meaningful design choice.

Enforce Module Boundaries

Architecture tests become especially valuable in a modular monolith, where accidental cross-module references can slowly turn modules into one large dependency graph.

csharp
[Fact]
public void Orders_must_not_depend_on_billing_internals()
{
    TestResult result = Types.InAssembly(typeof(OrdersAssemblyMarker).Assembly)
        .That()
        .ResideInNamespace("ArchitectureGuard.Modules.Orders")
        .ShouldNot()
        .HaveDependencyOnAny("ArchitectureGuard.Modules.Billing.Internal")
        .GetResult();

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

[Fact]
public void Feature_slices_must_not_depend_on_each_other()
{
    TestResult result = Types.InAssembly(typeof(OrdersAssemblyMarker).Assembly)
        .Slice()
        .ByNamespacePrefix("ArchitectureGuard.Modules")
        .Should()
        .NotHaveDependenciesBetweenSlices()
        .GetResult();

    Assert.True(result.IsSuccessful, FormatFailure(result));
}
Public contracts are the doorway. Put cross-module messages and interfaces in a dedicated Billing.Contracts assembly. Keep implementation namespaces internal and reject direct references to them.

When the Tools Cannot See It: CIL Limits and Reflection

Both fluent libraries analyze compiled output. Source-only information can disappear during compilation. For example, a dependency hidden only inside nameof(SomeType) may not be visible at CIL level.

Use a custom reflection test when the rule is easy to express against runtime metadata.

csharp
[AttributeUsage(AttributeTargets.Class)]
public sealed class RequireAuthorizationAttribute : Attribute;

[Fact]
public void Every_public_endpoint_must_declare_authorization()
{
    Type[] endpoints = typeof(Program).Assembly.GetTypes()
        .Where(type => type.IsClass && type.IsPublic)
        .Where(type => type.Name.EndsWith("Endpoint", StringComparison.Ordinal))
        .ToArray();

    Assert.NotEmpty(endpoints);

    Type[] missing = endpoints
        .Where(type => !type.IsDefined(typeof(RequireAuthorizationAttribute), inherit: true))
        .ToArray();

    Assert.True(
        missing.Length == 0,
        "Endpoints missing authorization: " +
        string.Join(", ", missing.Select(type => type.FullName)));
}
Guard against vacuous passes: the example first asserts that at least one endpoint was discovered. Without that check, a renamed namespace or selector could make the rule pass while testing nothing.

When to Use a Roslyn Analyzer Instead

A Roslyn analyzer works at source level and can report a diagnostic in the editor and during every build. It is the better fit when immediate feedback matters or when the rule depends on syntax and semantic information that CIL analysis loses.

csharp
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class ForbiddenInfrastructureReferenceAnalyzer : DiagnosticAnalyzer
{
    public const string DiagnosticId = "ARCH001";

    private static readonly DiagnosticDescriptor Rule = new(
        DiagnosticId,
        "Domain cannot reference Infrastructure",
        "Type '{0}' belongs to Infrastructure and cannot be used from Domain",
        "Architecture",
        DiagnosticSeverity.Error,
        isEnabledByDefault: true);

    public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => [Rule];

    public override void Initialize(AnalysisContext context)
    {
        context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
        context.EnableConcurrentExecution();
        context.RegisterSyntaxNodeAction(AnalyzeName, SyntaxKind.IdentifierName);
    }

    private static void AnalyzeName(SyntaxNodeAnalysisContext context)
    {
        var name = (IdentifierNameSyntax)context.Node;
        ISymbol? symbol = context.SemanticModel.GetSymbolInfo(name).Symbol;

        if (symbol?.ContainingNamespace.ToDisplayString()
            .StartsWith("ArchitectureGuard.Infrastructure", StringComparison.Ordinal) == true)
        {
            context.ReportDiagnostic(Diagnostic.Create(Rule, name.GetLocation(), symbol.Name));
        }
    }
}

This is intentionally a small sketch. Production analyzers need careful assembly filtering, tests, packaging, generated-code handling, and clear diagnostics.

Fail the Build in CI

No special architecture server is required. Run the test project in the same pipeline as the rest of the solution.

yaml
name: architecture-tests

on:
  pull_request:
  push:
    branches: [ main ]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '10.0.x'
      - run: dotnet restore
      - run: dotnet build --configuration Debug --no-restore
      - run: dotnet test tests/ArchitectureGuard.ArchitectureTests --configuration Debug --no-build

ArchUnitNET recommends testing analyzed binaries in Debug configuration. More importantly, keep the test output readable so the developer who broke the rule knows how to fix it.

Adopt Architecture Tests in a Legacy Codebase

Do not introduce architecture tests by making the pipeline permanently red. Capture known violations in a checked-in baseline, block new violations, and reduce the list over time.

text
ArchitectureGuard.Application.Legacy.LegacyOrderService
ArchitectureGuard.Modules.Orders.Legacy.BillingAdapter
csharp
[Fact]
public void No_new_domain_dependency_violations_are_added()
{
    var baseline = File.ReadAllLines("Baseline/domain-dependencies.txt")
        .Where(line => !string.IsNullOrWhiteSpace(line))
        .ToHashSet(StringComparer.Ordinal);

    TestResult result = BuildDomainDependencyRule().GetResult();

    string[] current = result.FailingTypes
        .Select(type => type.FullName)
        .Order(StringComparer.Ordinal)
        .ToArray();

    string[] newViolations = current.Where(name => !baseline.Contains(name)).ToArray();

    Assert.True(newViolations.Length == 0,
        "New architecture violations:" + Environment.NewLine +
        string.Join(Environment.NewLine, newViolations));
}

Review baseline changes like production code. Removing an entry is progress; adding one should require an explicit architectural decision.

Keep the Rules Trustworthy

  • Use typed assembly markers wherever possible.
  • Assert that selectors find at least one type.
  • Demonstrate that every important rule can fail.
  • Name tests after the decision they protect.
  • Load assemblies once and reuse them.
  • Keep rules few, clear, and owned by the team.
  • Choose one fluent architecture library per solution.
Do not overfit the folder structure. Namespace strings are sometimes unavoidable, but a rule tied to a temporary folder name can become noise rather than protection.

Choose the Right Enforcement Tool

NeedeNhancedEditionArchUnitNETRoslyn analyzer
Layer dependenciesConciseStrong ArchUnit modelPossible, more work
Naming and sealingYesYesYes
IDE diagnosticNoNoYes
Source-only constructsLimitedLimitedStrong
Typical useFast structural testsRich architecture modelShift-left enforcement

Use a fluent architecture library for structural boundaries, reflection for simple runtime-metadata conventions, and a Roslyn analyzer when you need source-level enforcement in every build and editor.

Production Readiness Checklist

  • The architecture-test project references every assembly under test.
  • Rules are run in CI on every pull request.
  • Failure messages list offending types and the protected decision.
  • Selectors cannot pass silently when they match zero types.
  • Assemblies are loaded once for performance.
  • Legacy exceptions are kept in a reviewed baseline.
  • Rules are documented beside the code, not only in an external wiki.
  • Source-level needs are assigned to analyzers rather than forced into CIL tests.

What to Build Next

Frequently Asked Questions

Are architecture tests unit tests?

They use the same runner, but inspect structure and dependencies rather than business behavior.

Which library should a new project choose?

Choose one. eNhancedEdition is concise and close to the familiar NetArchTest API. ArchUnitNET is attractive for teams that know Java ArchUnit or want its broader architecture object model.

Can the tests replace project references and internal visibility?

No. Use compile-time boundaries first where possible. Architecture tests add protection for rules the compiler does not naturally enforce.

Why keep a baseline in source control?

It allows a legacy solution to start green while preventing new violations. The team can then remove known exceptions gradually.

Primary References

  1. NetArchTest.eNhancedEdition repository and documentation
  2. ArchUnitNET repository
  3. ArchUnitNET documentation
  4. Microsoft: .NET source code analysis
  5. Microsoft: Roslyn SDK
  6. xUnit.net v3
Back to Tutorials