Skip to main content
SEMastery
Testingbeginner

5 Architecture Tests You Should Add to Your .NET Projects

Five simple architecture tests for .NET using NetArchTest. Protect layers, naming, and dependencies with code that fails the build when rules break.

12 min readUpdated May 16, 2026

The school bag rule

Imagine your little cousin packs her school bag every morning. There is a simple house rule: textbooks go in the big pocket, the water bottle goes in the side holder, and the lunch box goes in the front zip. Nobody minds which book she takes, but everything must sit in its proper place. If the water bottle ends up on top of the books, they get soaked.

For the first week, mum checks the bag by hand. But mum is busy. Some mornings she forgets, and one day the books really do get wet. So she sticks a small printed checklist on the door: books in big pocket, bottle in holder, lunch in front zip. Now the rule checks itself, every single day, without mum standing there.

A .NET solution is just like that school bag. You decide that the Domain code goes here, the database code goes there, and certain things must never touch each other. At first, you check these rules by hand in code reviews. But people get busy, deadlines arrive, and slowly the bottle ends up on the books. The fix is the same as mum's: write the rule down as a test that checks itself on every build.

These self-checking rules are called architecture tests. In this post you will learn five of them you can add to almost any .NET project today.

What an architecture test actually is

A normal unit test checks behaviour — given this input, do I get the right answer? An architecture test checks shape — is the code arranged the way we agreed?

It does this by loading your compiled assemblies and looking at the types inside: which namespace a class lives in, what it depends on, what it is named, whether it is sealed, and so on. If a class breaks a rule, the test fails, exactly like a broken unit test.

Figure 1: An architecture test reads your compiled types, checks them against a rule, and passes or fails like any unit test.

The whole point is to catch a slip early, while it is one line of code, instead of later, when it has spread through fifty files. People call this idea shifting left — moving the check as close to the moment of writing as you can.

We will use NetArchTest.Rules, the most popular and beginner-friendly library. To start, add it to your test project:

dotnet add package NetArchTest.Rules

Every rule begins from the static Types class. You load a set of types, narrow them down, ask a question, and check the result. Here is the simplest possible shape of a test:

using NetArchTest.Rules;
using Xunit;
 
public class ArchitectureTests
{
    // A handy reference to one type in each project,
    // so we can grab its assembly later.
    private const string DomainNamespace = "Shop.Domain";
    private const string InfrastructureNamespace = "Shop.Infrastructure";
 
    [Fact]
    public void Domain_Should_Not_Be_Empty()
    {
        var result = Types.InAssembly(typeof(Shop.Domain.Order).Assembly)
            .Should()
            .ResideInNamespaceStartingWith(DomainNamespace)
            .GetResult();
 
        Assert.True(result.IsSuccessful, "Domain types are not where we expect.");
    }
}

Read it almost like English: the types in this assembly should reside in the Domain namespace. Now let us write the five rules that matter most.

A quick map of the layers we will protect

Most of these tests assume a layered solution, like Clean Architecture. The golden rule there is that dependencies point inward: the core knows nothing about the outside.

Figure 2: The dependency rule. Arrows point inward. Domain depends on nothing; Infrastructure and Presentation depend on the core.

Keep this picture in mind. Three of our five tests simply turn one of these arrows into a rule that cannot be broken.

Test 1: The Domain must not depend on outer layers

This is the most important rule in a layered system. Your Domain holds the business rules — what an Order is, when it can be cancelled, how a price is calculated. It should not know that a database, a web framework, or an email service even exists. That keeps your core stable while everything around it changes.

The test reads almost like the sentence above:

[Fact]
public void Domain_Should_Not_Depend_On_OuterLayers()
{
    var result = Types.InAssembly(typeof(Shop.Domain.Order).Assembly)
        .ShouldNot()
        .HaveDependencyOnAny(
            "Shop.Application",
            "Shop.Infrastructure",
            "Shop.Api")
        .GetResult();
 
    Assert.True(
        result.IsSuccessful,
        BuildMessage("Domain must not depend on outer layers", result));
}

That helper BuildMessage turns the failing types into a friendly error so the developer knows exactly what broke:

private static string BuildMessage(string rule, NetArchTest.Rules.TestResult result)
{
    if (result.IsSuccessful) return rule;
    var names = string.Join(", ", result.FailingTypeNames);
    return $"{rule}. Offending types: {names}";
}

If someone adds a sneaky using Shop.Infrastructure; inside a Domain class to "just quickly save something," this test goes red on the very next build. The water bottle never lands on the books.

How Test 1 protects the core

Bad using added
Test scans Domain
Finds outer reference
Build fails fast

Steps

1

Bad using added

Someone references Infrastructure inside Domain

2

Test scans Domain

NetArchTest loads every Domain type

3

Finds outer reference

A dependency points outward — not allowed

4

Build fails fast

Red test before the code is merged

A forbidden dependency is added, the test inspects Domain types, finds the bad reference, and stops the build.

Test 2: The Application layer must not touch Infrastructure directly

The Application layer holds your use cases — "place an order", "register a user." It is allowed to depend on the Domain. But it should talk to the outside world only through interfaces, not by reaching directly into Infrastructure classes.

Why? Because if your use cases call EF Core or SmtpClient directly, you can no longer swap the database or test the use case without a real one. The interface is the thin curtain between "what I need" and "how it is done."

[Fact]
public void Application_Should_Not_Depend_On_Infrastructure()
{
    var result = Types.InAssembly(typeof(Shop.Application.PlaceOrder).Assembly)
        .ShouldNot()
        .HaveDependencyOn("Shop.Infrastructure")
        .GetResult();
 
    Assert.True(
        result.IsSuccessful,
        BuildMessage("Application must not depend on Infrastructure", result));
}

Here is the contrast in a small table.

Without the testWith the test
Use case calls DbContext directlyUse case calls an IOrderRepository interface
Hard to unit test (needs a real DB)Easy to test with a fake repository
Database choice leaks into core codeDatabase choice stays in Infrastructure
A wrong dependency hides for monthsBuild fails the same hour it is added

The Domain or Application defines the interface; Infrastructure implements it. This idea is the Dependency Inversion Principle, and this single test keeps it honest.

Test 3: Naming conventions stay consistent

Names are a quiet form of documentation. When every repository ends in Repository and every command handler ends in Handler, a new teammate can guess what a file does just from its name. But naming slips easily — one person writes OrderRepo, another writes OrdersStore, and the pattern dissolves.

A naming test locks the pattern in place. Here we say: every class that implements IRepository must end with the word Repository.

[Fact]
public void Repositories_Should_Have_Repository_Suffix()
{
    var result = Types.InAssembly(typeof(Shop.Infrastructure.OrderRepository).Assembly)
        .That()
        .ImplementInterface(typeof(Shop.Application.IRepository))
        .Should()
        .HaveNameEndingWith("Repository")
        .GetResult();
 
    Assert.True(
        result.IsSuccessful,
        BuildMessage("Repositories must end with 'Repository'", result));
}

You can write the same kind of rule for many patterns. Here are common ones teams enforce.

PatternRuleExample name
RepositoryEnds with RepositoryOrderRepository
Command handlerEnds with HandlerPlaceOrderHandler
API controllerEnds with ControllerOrdersController
Domain eventEnds with EventOrderPlacedEvent
Options classEnds with OptionsEmailOptions

Consistent names will not make your code run faster, but they make it far easier to read, search, and trust — and that saves real time on every future change.

Test 4: No layer-skipping (controllers must not call repositories)

In a clean flow, a request travels through the layers in order: the controller asks the Application to do work, and only the Application reaches the repository. A controller that calls a repository directly is "skipping the queue." It might feel quick, but it scatters business logic into the web layer where it does not belong.

Figure 3: The allowed path versus the shortcut. The dotted red arrow is what Test 4 forbids.

The test says: presentation types must not depend on repository types.

[Fact]
public void Controllers_Should_Not_Depend_On_Repositories()
{
    var result = Types.InAssembly(typeof(Shop.Api.OrdersController).Assembly)
        .That()
        .HaveNameEndingWith("Controller")
        .ShouldNot()
        .HaveDependencyOn("Shop.Infrastructure.Repositories")
        .GetResult();
 
    Assert.True(
        result.IsSuccessful,
        BuildMessage("Controllers must not call repositories directly", result));
}

This keeps the controller thin: receive the request, hand it to the Application, return the result. All the real thinking stays in one place, which makes the code easier to test and to change.

Stopping a layer-skip

Shortcut written
Test checks controllers
Bad path found
Refactor through handler

Steps

1

Shortcut written

Controller injects a repository directly

2

Test checks controllers

Rule scans every *Controller type

3

Bad path found

Direct repository dependency detected

4

Refactor through handler

Call goes via the Application use case

A controller tries to call a repository directly. The test catches the shortcut and forces the request back through the Application layer.

Test 5: Handlers should be sealed (and other "shape" rules)

The last test is a small but powerful kind: enforcing a structural shape rather than a dependency. A common example is sealing your handler classes.

Marking a class sealed says "nobody should inherit from this." For things like command handlers, that is usually exactly what you want — they are end-of-the-line classes, not base classes. Sealing also gives the runtime a tiny performance hint and stops surprising inheritance bugs.

[Fact]
public void Handlers_Should_Be_Sealed()
{
    var result = Types.InAssembly(typeof(Shop.Application.PlaceOrderHandler).Assembly)
        .That()
        .HaveNameEndingWith("Handler")
        .Should()
        .BeSealed()
        .GetResult();
 
    Assert.True(
        result.IsSuccessful,
        BuildMessage("All handlers must be sealed", result));
}

NetArchTest can check many such shapes. A few you might find useful:

  • BeSealed() — the class cannot be inherited.
  • BePublic() / NotBePublic() — controls who can see the type.
  • BeImmutable() — useful for value objects and records.
  • HaveCustomAttribute(...) — every handler must carry a certain attribute.
  • OnlyHaveDependenciesOn(...) — a strict allow-list of references.

Pick the ones that match the rules your team already cares about. A good architecture test simply writes down a decision you have already made in your head.

A small note on libraries and licences

When you set up these tests, you will often see MediatR mentioned for command handlers. It is worth knowing that MediatR and MassTransit moved to a commercial licence for newer versions. They are still fine to use, but check the licence and pricing before you adopt them in a paid product. Architecture tests themselves do not need either library — you can hand-write tiny handler interfaces and keep everything free and simple. NetArchTest is open source and has no such cost.

This is also a good moment to mention versions: as of today, .NET 10 is the LTS release, C# 14 has shipped, and C# 15 union types are arriving in the .NET 11 previews. None of these change how architecture tests work — the Types API stays the same — but it is nice to know you are building on current ground.

Putting it all together in CI

A test that nobody runs protects nothing. The real magic happens when these five tests run automatically on every push. Then a broken rule cannot be merged at all.

Figure 4: Architecture tests in a CI pipeline. A push triggers the build and tests; a broken rule blocks the merge.

Because these tests have no database and no web server, they run in a flash. You add them once, and they guard your design forever — like mum's printed checklist on the door, doing its quiet job every single morning.

A simple plan to adopt them:

  1. Create one test project, for example Shop.ArchitectureTests.
  2. Add the NetArchTest.Rules package.
  3. Copy the five tests above and change the namespaces to match your solution.
  4. Run them locally until they are green.
  5. Make sure your CI runs dotnet test so they cannot be skipped.

Quick recap

  • Architecture tests check the shape of your code, not its behaviour — the way mum's checklist guards the school bag.
  • Test 1: the Domain must not depend on outer layers, keeping your business rules stable and pure.
  • Test 2: the Application must talk to the outside only through interfaces, never Infrastructure directly.
  • Test 3: naming conventions (like the Repository suffix) stay consistent so code is easy to read and search.
  • Test 4: no layer-skipping — controllers go through the Application, never straight to a repository.
  • Test 5: structural rules like sealed handlers lock in decisions you already made.
  • Run them in CI so a broken rule fails the build the same hour it is written. They are fast, free with NetArchTest, and pay you back on every future change.

References and further reading

Related Posts