Skip to content

Architectural Unit Testing

Key Points

  • Architectural unit tests enforce structural rules (layering, naming, dependencies) as fast unit tests — no app startup, no PR review needed for routine violations.
  • NetArchTest is the most popular .NET option (fluent, simple). ArchUnitNET (port of Java's ArchUnit) is more expressive — supports cycle detection, slice rules, deeper queries.
  • Typical rules: "domain doesn't reference infra", "controllers end in 'Controller'", "no layer skips", "events live only in event projects", "no Console.WriteLine outside Program.cs".
  • Speed: very fast (reflection over assemblies — no DI container, no app startup). Runs in seconds; perfect for the unit suite.
  • Living documentation: rules are enforceable specs of architecture, not Confluence pages that drift. New developers see the rule violation in CI immediately.
  • Anti-pattern: an "approved violations" exception list that grows over time. The rules become advisory; the value evaporates.
  • Pair with Roslyn analyzers: analyzers for line-level rules at compile time; arch tests for assembly/layer-level rules in CI. Different scopes, complementary.

Concepts (deep dive)

What architectural tests catch

// Bad: domain code reaching into infrastructure
namespace MyApp.Domain;
public class Order
{
    public void Save() => SqlConnectionFactory.Get().Execute("INSERT ..."); // ❌
}

This compiles. Coverage tools say it's covered. But it violates clean architecture: domain depends on infrastructure. An architectural test catches it in CI in 30 ms, no PR needed.

[Fact]
public void Domain_must_not_reference_infrastructure()
{
    var result = Types.InAssembly(typeof(Order).Assembly)
        .That().ResideInNamespace("MyApp.Domain")
        .Should().NotHaveDependencyOn("MyApp.Infrastructure")
        .GetResult();

    Assert.True(result.IsSuccessful, string.Join("\n", result.FailingTypeNames ?? Array.Empty<string>()));
}
dotnet add package NetArchTest.Rules

Fluent API on top of System.Reflection:

public class ArchitectureTests
{
    private static readonly Assembly _domain = typeof(MyApp.Domain.Order).Assembly;
    private static readonly Assembly _infra  = typeof(MyApp.Infrastructure.OrderRepo).Assembly;
    private static readonly Assembly _api    = typeof(MyApp.Api.Program).Assembly;

    [Fact]
    public void Domain_does_not_depend_on_infrastructure()
    {
        var result = Types.InAssembly(_domain)
            .Should().NotHaveDependencyOn("MyApp.Infrastructure")
            .GetResult();

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

    [Fact]
    public void Controllers_end_with_Controller()
    {
        var result = Types.InAssembly(_api)
            .That().Inherit(typeof(ControllerBase))
            .Should().HaveNameEndingWith("Controller")
            .GetResult();

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

    [Fact]
    public void Repositories_are_sealed()
    {
        var result = Types.InAssembly(_infra)
            .That().HaveNameEndingWith("Repository")
            .Should().BeSealed()
            .GetResult();

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

    private static string BuildMessage(TestResult r) =>
        r.IsSuccessful ? "" : "Violations:\n  " + string.Join("\n  ", r.FailingTypeNames ?? Array.Empty<string>());
}

NetArchTest's API: Types.InAssembly(...) → .That()... → .Should()... → .GetResult(). Predicates and conditions are chainable.

ArchUnitNET — the more powerful option

dotnet add package ArchUnitNET.xUnit

Inspired by Java's ArchUnit — broader query model, cycle detection, slice rules.

using ArchUnitNET.Loader;
using ArchUnitNET.Fluent;
using static ArchUnitNET.Fluent.ArchRuleDefinition;

public class ArchUnitTests
{
    private static readonly Architecture _arch = new ArchLoader()
        .LoadAssemblies(typeof(MyApp.Domain.Order).Assembly,
                        typeof(MyApp.Infrastructure.OrderRepo).Assembly,
                        typeof(MyApp.Api.Program).Assembly)
        .Build();

    [Fact]
    public void Domain_does_not_depend_on_infrastructure()
    {
        IArchRule rule = Classes()
            .That().ResideInNamespace("MyApp.Domain", true)
            .Should().NotDependOnAny(Classes().That().ResideInNamespace("MyApp.Infrastructure", true));

        rule.Check(_arch);
    }

    [Fact]
    public void Layers_form_a_DAG()
    {
        IArchRule rule = Layers()
            .Match(Layer("Domain", Classes().That().ResideInNamespace("MyApp.Domain", true)))
            .Match(Layer("Application", Classes().That().ResideInNamespace("MyApp.Application", true)))
            .Match(Layer("Infrastructure", Classes().That().ResideInNamespace("MyApp.Infrastructure", true)))
            .Should().BeFreeOfCycles();

        rule.Check(_arch);
    }
}

ArchUnitNET shines when you need cycle detection ("no namespace cycles"), slice rules ("each feature folder owns its types"), or complex predicates that NetArchTest's fluent grammar can't express compactly.

Common rule catalog

Layering

// Domain: pure — no refs to anything except itself + .NET base
Types.InAssembly(_domain)
    .Should().NotHaveDependencyOnAny("MyApp.Application", "MyApp.Infrastructure", "MyApp.Api");

// Application: may use Domain, not Infrastructure
Types.InAssembly(_application)
    .Should().NotHaveDependencyOn("MyApp.Infrastructure");

Naming conventions

// Controllers
Types.InAssembly(_api).That().Inherit(typeof(ControllerBase))
    .Should().HaveNameEndingWith("Controller");

// Async methods (heuristic — not perfect)
Types.InAssembly(_app).That().HaveDependencyOn("System.Threading.Tasks")
    /* ... */ ;

// Interface naming
Types.InAssembly(_app).That().AreInterfaces()
    .Should().HaveNameStartingWith("I");

Sealing and visibility

// Repositories must be sealed
Types.InAssembly(_infra).That().HaveNameEndingWith("Repository")
    .Should().BeSealed();

// Domain entities not public outside domain assembly
Types.InAssembly(_domain).That().ResideInNamespace("MyApp.Domain.Internal")
    .Should().NotBePublic();

Domain purity

// No EF Core in domain
Types.InAssembly(_domain)
    .Should().NotHaveDependencyOn("Microsoft.EntityFrameworkCore");

// No HTTP client in domain
Types.InAssembly(_domain)
    .Should().NotHaveDependencyOn("System.Net.Http");

Project layout

Single project: Architecture.Tests (or MyApp.Architecture.Tests).

src/
  MyApp.Domain/
  MyApp.Application/
  MyApp.Infrastructure/
  MyApp.Api/
tests/
  MyApp.Architecture.Tests/    ← all arch rules here
  MyApp.UnitTests/
  MyApp.IntegrationTests/

References every project under inspection. Runs in seconds; runs in the unit-test phase of CI.

Speed

ArchitectureTests.Domain_does_not_depend_on_infrastructure   12 ms
ArchitectureTests.Controllers_end_with_Controller             8 ms
ArchitectureTests.Repositories_are_sealed                     7 ms
ArchitectureTests.Layers_form_a_DAG                          34 ms
ArchitectureTests.No_Console_outside_entry_point             11 ms

Reflection-only — no app startup. The whole suite typically clocks under a second. Always-green CI if rules are clear and codebase compliant.

Where these tests fit

[ Roslyn analyzers (section 02) ]
  → Compile-time, line-level, IDE squiggles, PR diff annotations
  → Examples: "use TimeProvider not DateTime.Now", "use ConfigureAwait(false)"

[ Architectural unit tests ]
  → CI test phase, assembly/layer/namespace level
  → Examples: "domain doesn't reference infra", "controllers end in Controller"

[ Manual review ]
  → Design intent, naming taste, complex tradeoffs

Different tools, different scopes. Use both.

Maintenance

Rules drift. Common decay:

  1. New developer adds a using that violates a layering rule. CI fails.
  2. They add the violating type to an "ignored" list rather than fix it.
  3. The ignore list grows. The rule becomes advisory.
  4. Within a year, the architecture is mush.

Senior practice: fix the violation, never the rule. If a rule is genuinely wrong, delete or rewrite it intentionally — don't bypass via ignore list. An exception log of "approved violations" is an anti-pattern over time.

// ❌ Anti-pattern
private static readonly string[] _allowedExceptions = new[]
{
    "MyApp.Domain.Order",      // ticket #4521 — fix later
    "MyApp.Domain.LegacyShim", // historical — leave alone
    "MyApp.Domain.Hack",       // performance hack
    // ... 50 more entries ...
};

Each entry is a rule erosion. Rather than maintain this list, suppress the type via a per-violation justification with a tracking ticket and an expiry date — and pursue the cleanup.

Diagnostics — making failures readable

[Fact]
public void Domain_does_not_depend_on_infrastructure()
{
    var result = Types.InAssembly(_domain)
        .Should().NotHaveDependencyOn("MyApp.Infrastructure")
        .GetResult();

    Assert.True(result.IsSuccessful,
        $"Domain types depend on Infrastructure:\n  {string.Join("\n  ", result.FailingTypeNames ?? Array.Empty<string>())}");
}

When the rule fails, the assertion message must list the offending types. Otherwise developers see "false != true" and have to dig.

Discovery vs enforcement

In practice you build the suite in two waves:

  1. Discovery — write rules; let them fail; learn what the codebase actually looks like. Surprising results are common.
  2. Enforcement — fix offenders, then commit the rules. Now CI gates.

You can also inverse: ratchet — initial baseline allows current violations, but no new ones may appear. Useful for legacy code where full cleanup isn't feasible.


How it works under the hood

NetArchTest (and ArchUnitNET) are reflection-based static analyzers in test form. At test time they:

  1. Load assemblies via Assembly.LoadFrom / MetadataReader.
  2. Walk types and members, collecting Type.Module.GetReferencedAssemblies(), MethodBody.LocalVariables, attributes, base classes, interfaces, generic args, etc.
  3. Apply predicate filters (That().ResideInNamespace(...)).
  4. Apply assertion filters (Should().NotHaveDependencyOn(...)).
  5. Return passing/failing types.

The libraries don't run code. They examine metadata only. That's why they're fast (no DI, no DB) and why they're limited at the statement level — they can see "this type references that namespace" but not "this method calls DateTime.Now" without parsing IL. For statement-level checks, use Roslyn analyzers.

ArchUnitNET pre-builds an Architecture graph (one-time load) and runs every rule against it — slightly faster on large suites with many rules.


Code: correct vs wrong

❌ Wrong: rule with no diagnostic

[Fact]
public void Rule()
{
    var result = Types.InAssembly(_a).Should().BeSealed().GetResult();
    Assert.True(result.IsSuccessful);   // 💀 "False != True" — useless
}

✅ Correct: list offenders

[Fact]
public void All_repositories_are_sealed()
{
    var result = Types.InAssembly(_infra)
        .That().HaveNameEndingWith("Repository")
        .Should().BeSealed()
        .GetResult();

    Assert.True(result.IsSuccessful,
        "Unsealed repositories: " + string.Join(", ", result.FailingTypeNames ?? Array.Empty<string>()));
}

❌ Wrong: ever-growing ignore list

private static readonly string[] _exceptions = new[] { /* 30 entries */ };

var result = Types.InAssembly(_a)
    .That().DoNotHaveName(_exceptions)
    .Should().NotHaveDependencyOn("...")
    .GetResult();

✅ Correct: fix violators, keep rule clean

If a violation must persist (legacy or external constraint), document it outside the rule with a tracking ticket and a date.

❌ Wrong: no rules — everyone's "supposed to know"

Architecture decisions in Confluence drift faster than code.

✅ Correct: codify rules as tests

[Fact] public void Layer_rule_1() { /* ... */ }
[Fact] public void Layer_rule_2() { /* ... */ }
[Fact] public void Naming_rule_1() { /* ... */ }

Design patterns for this topic

Pattern 1 — "Single Architecture.Tests project"

  • Intent: all rules in one place; runs in unit phase.

Pattern 2 — "Diagnostic-rich assertions"

  • Intent: every rule lists offending types in its failure message.

Pattern 3 — "Pair with Roslyn analyzers"

  • Intent: analyzers for statement-level rules; arch tests for layer-level.

Pattern 4 — "Discover then enforce"

  • Intent: baseline the rules in dry mode; fix offenders; switch to gating.

Pattern 5 — "No ignore lists"

  • Intent: if a rule is wrong, delete or rewrite it; don't accumulate exceptions.

Pattern 6 — "Rule per behavior, not per file"

  • Intent: one [Fact] per architectural concern; descriptive name.

Pros & cons / trade-offs

Aspect Pros Cons
Speed Sub-second suite Reflection only — no statement-level rules
Feedback CI fails immediately No IDE squiggle until tests run
Documentation Rules are executable specs Rules can ossify if outdated
Coverage Layer/naming/dependency Limited at expression level
Maintenance Forces architectural discipline Pressure to add ignore-list exceptions

When to use / when to avoid

  • Use for layered architecture enforcement (clean architecture, hex, onion).
  • Use for naming conventions across many types.
  • Use for forbidding deprecated APIs at the assembly level.
  • Use in a monorepo to keep modules independent.
  • Avoid as the only architectural enforcement — pair with reviews and analyzers.
  • Avoid for rules that need expression-level inspection — use Roslyn analyzers instead.
  • Avoid if the team won't fix violations — rules become advisory, then theater.

Interview Q&A

Q1. What problem do architectural unit tests solve? Catch architectural violations in CI without needing a senior reviewer to spot them. Rules become executable, fast, repeatable.

Q2. NetArchTest vs ArchUnitNET? NetArchTest: simple fluent API, easy onramp. ArchUnitNET: more powerful — cycle detection, slice rules, richer queries. Choose based on rule complexity.

Q3. How do they compare to Roslyn analyzers? Different scopes. Analyzers run at compile time on syntax/semantics — line-level, IDE feedback. Arch tests run at test time over assembly metadata — assembly/layer-level, CI feedback. Use both.

Q4. Why are arch tests fast? Reflection only — they walk metadata, no app startup, no DB, no DI container. A whole suite usually finishes in under a second.

Q5. Where do you put architectural tests? A dedicated Architecture.Tests project that references all production projects. Runs in the unit-test CI phase.

Q6. What's the failure-mode anti-pattern? Accumulating an "ignore list" of approved violations. Each entry erodes the rule. Better: fix violations or delete the rule.

Q7. Give five common rules. 1) Domain doesn't reference infrastructure. 2) Controllers end in Controller. 3) No Console.WriteLine outside entry point. 4) Repositories are sealed. 5) No layer cycles.

Q8. How do you write good failure messages? Always include the failing type names (or namespaces) in the assertion. result.FailingTypeNames is the typical source.

Q9. Statement-level rules — how? NetArchTest can't reach into method bodies — it sees type references, not specific calls. For "no DateTime.Now", use a Roslyn analyzer.

Q10. How do you onboard arch tests on a legacy codebase? Run rules in "discover" mode, list violators, prioritize cleanup, then turn rules into gates. Or use a ratchet: allow current violations, forbid new ones.

Q11. Can arch tests catch cycles? ArchUnitNET — yes (.Should().BeFreeOfCycles()). NetArchTest — limited; would need custom logic.

Q12. Why do rules drift? Codebase evolves; rules don't. Senior teams audit rules quarterly; orphan rules get pruned, missing rules get added, ignore lists get cleaned.


Gotchas / common mistakes

  • ⚠️ No diagnostic in failure → "False != True" mystery.
  • ⚠️ Ignore list growth → rule decays into advisory.
  • ⚠️ Trying to enforce statement-level rules → use Roslyn analyzers instead.
  • ⚠️ Tests that require app startup → not arch tests; restructure.
  • ⚠️ Loading wrong assembly (test assembly vs production) → false negatives.
  • ⚠️ Rules without owners → rot.
  • ⚠️ Conflicting rules in the same suite → confusing failures.
  • ⚠️ Asserting both positive and negative in one rule → split into two.
  • ⚠️ Hard-coded namespace strings → fragile to refactors; use typeof(X).Namespace.
  • ⚠️ Rules that test the test framework (e.g., asserting on test assemblies) → exclude test projects from queries.

Further reading