Skip to content
ExponaBLOG
.NET & ASP.NET CORE · DAY 095 / 100

Architecture tests in .NET: patterns, pitfalls and fixes

Make your architecture rules executable.

Expona Admin6 min read

The case for executable architecture rules

We've all inherited codebases where the architecture had drifted so far from the original design that it barely resembled the diagram on the wiki. A handler reaches into the database layer directly. A domain model imports from the API framework. Infrastructure concerns leak everywhere. Architecture tests in .NET: patterns, pitfalls and fixes is about making those rules executable—turning loose conventions into failing tests that stop bad code before it ships.

Architecture tests sit in your test project and verify that your codebase respects dependency boundaries, naming conventions, and visibility rules. Tools like NetArchTest and ArchUnitNET let you write assertions over types, namespaces, and assembly references. When someone violates a rule, the build fails. No sneaky refactoring sessions. No drift.

NetArchTest or ArchUnitNET in a test project

Both tools scan your compiled assemblies and let you query the type graph. NetArchTest is lightweight and focused; ArchUnitNET is more verbose but powerful. We typically see teams reach for NetArchTest first because the syntax is fluent and readable.

Start by creating a dedicated test project—something like YourProject.ArchitectureTests—separate from unit tests. This keeps architecture concerns distinct and lets you run them on every build without slowing down fast test suites.

Here's a minimal example using NetArchTest:

csharp
using NetArchTest.Rules;
using Xunit;

public class ArchitectureTests
{
    [Fact]
    public void Domain_should_not_reference_infrastructure()
    {
        var result = Types
            .InNamespace("YourProject.Domain")
            .ShouldNot()
            .ReferencedBy(Types.InNamespace("YourProject.Infrastructure"))
            .GetResult();

        Assert.True(result.IsSuccessful, 
            string.Join(Environment.NewLine, result.FailingTypes ?? Array.Empty()));
    }
}

Run this test on every build, and you've got a guard rail. If someone adds a domain reference to an infrastructure type, the test fails and blocks the merge.

Domain must not reference Infrastructure

This is the most fundamental rule in layered architecture. Your domain—the core business logic—should be an island. It knows nothing about databases, HTTP, message queues, or frameworks. When domain types depend on infrastructure types, you've inverted the dependency graph.

A common mistake: a domain entity references Microsoft.EntityFrameworkCore attributes.

csharp
// ❌ Domain class with infrastructure concern
namespace YourProject.Domain.Orders
{
    using Microsoft.EntityFrameworkCore;

    public class Order
    {
        [Precision(18, 2)]
        public decimal Total { get; set; }
    }
}

The domain now depends on EF Core. If you ever swap to Dapper or a different ORM, you're rewriting the domain layer. That's backwards.

The fix is to keep domain types clean and let infrastructure map them:

csharp
// ✅ Domain class, infrastructure-agnostic
namespace YourProject.Domain.Orders
{
    public class Order
    {
        public decimal Total { get; set; }
    }
}

// ✅ Infrastructure mapping (EF Core)
namespace YourProject.Infrastructure.Persistence
{
    using Microsoft.EntityFrameworkCore;
    using Microsoft.EntityFrameworkCore.Metadata.Builders;
    using YourProject.Domain.Orders;

    public class OrderConfiguration : IEntityTypeConfiguration
    {
        public void Configure(EntityTypeBuilder builder)
        {
            builder.Property(x => x.Total)
                .HasPrecision(18, 2);
        }
    }
}

Now domain is portable. Your architecture test:

csharp
[Fact]
public void Domain_must_not_reference_infrastructure_or_persistence()
{
    var result = Types
        .InNamespace("YourProject.Domain")
        .ShouldNot()
        .HaveDependencyOn("YourProject.Infrastructure")
        .GetResult();

    Assert.True(result.IsSuccessful);
}

Handlers must be sealed and internal

In CQRS or MediatR patterns, handlers are implementation details. They should be internal to your Application layer and sealed to prevent inheritance. If someone outside the application service is reaching for a handler directly, that's a smell.

Sealing prevents accidental subclassing. Internal visibility keeps handlers out of public APIs. Together, they enforce the contract: handlers are consumed through the mediator interface, not directly.

csharp
// ❌ Handler is public and unsealed
namespace YourProject.Application.Orders
{
    public class CreateOrderHandler : IRequestHandler
    {
        public async Task Handle(CreateOrderCommand request, CancellationToken ct)
        {
            // ...
        }
    }
}

// ✅ Handler is internal and sealed
namespace YourProject.Application.Orders
{
    internal sealed class CreateOrderHandler : IRequestHandler
    {
        private readonly IOrderRepository _repository;
        
        public CreateOrderHandler(IOrderRepository repository) => _repository = repository;

        public async Task Handle(CreateOrderCommand request, CancellationToken ct)
        {
            // ...
        }
    }
}

Your architecture test enforces this:

csharp
[Fact]
public void All_handlers_must_be_internal_and_sealed()
{
    var result = Types
        .InNamespace("YourProject.Application")
        .That()
        .ImplementInterface("MediatR.IRequestHandler`2")
        .Should()
        .BeInternal()
        .And()
        .BeSealed()
        .GetResult();

    Assert.True(result.IsSuccessful, 
        string.Join(Environment.NewLine, result.FailingTypes ?? Array.Empty()));
}

This isn't pedantic. It's about controlling your API surface. Handlers are internal because they're framework mechanics, not domain abstraction.

Fails the build before a bad dependency ships

The real power of architecture tests is in CI/CD. Run them on every commit. When they fail, the build fails, and the PR is blocked. No exceptions, no review-time discussions about whether this violation is "just this once."

In your pipeline:

csharp
// In your .csproj or test runner
dotnet test --filter "Category=Architecture" --no-build

Or integrate with your test framework to tag architecture tests:

csharp
[Trait("Category", "Architecture")]
[Fact]
public void Domain_must_not_reference_infrastructure_or_persistence()
{
    // ...
}

Once a violation makes it to main, it's exponentially harder to fix. Every new developer inherits the wrong pattern as precedent. Architecture tests stop that at the source.

We've seen teams go from "we'll tighten this up later" to zero violations in main by running architecture tests on every PR. The initial friction—adding the tests, fixing the legacy violations—pays for itself in weeks.

Common mistakes

  • Writing tests that are too strict too fast. If your codebase has existing violations, write tests that document the ideal state first. Add grandfathering rules for current violations, then enforce the rest. Gradually tighten as you refactor.
  • Testing implementation details instead of contracts. Don't write tests that care about private methods or internal wiring. Test boundaries: what namespace A can reference, what visibility B should have. Leave the rest to unit tests.
  • Forgetting to include framework types in your rules. If you want to prevent a specific ORM from leaking into the domain, name it explicitly: ShouldNot().HaveDependencyOn("EntityFrameworkCore") or ShouldNot().HaveDependencyOn("Dapper").
  • Running architecture tests in isolation from the build. If they don't fail the CI pipeline, developers will ignore failures locally. Make them non-negotiable.
  • Over-testing trivial things. You don't need architecture tests for every naming convention. Focus on the rules that matter: dependency direction, visibility, framework isolation. Leave cosmetics to code review or linters.

Key takeaways

  • Architecture tests convert loose conventions into executable rules that fail the build.
  • Use NetArchTest or ArchUnitNET in a dedicated test project; run them on every commit.
  • Enforce unidirectional dependencies: domain must not see infrastructure, handlers must not be public.
  • Seal handlers and mark them internal to control your API surface and enforce mediator-only consumption.
  • Run architecture tests in CI/CD so violations block PRs before they reach main.
  • Start with the most critical boundaries (domain isolation), then gradually add visibility and naming rules.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles