Skip to content

Architecture Tests

Architecture tests use NetArchTest to enforce the layer dependency rules that keep your modular monolith clean. Every module generated by Modulus includes a Tests.Architecture project with a pre-built LayerDependencyTests class that catches violations at build time, long before they cause problems in production.

Why Architecture Tests?

In a modular monolith, the value proposition depends entirely on maintaining strict boundaries between layers and modules. Without enforcement, it is only a matter of time before someone adds a direct reference from Domain to Infrastructure, or imports types from another module's internals.

Architecture tests solve this by turning architectural rules into executable assertions:

  • They run with dotnet test alongside your other tests
  • They fail fast in CI before a violation merges
  • They document your architectural decisions in code rather than in a wiki page that nobody reads

The Generated LayerDependencyTests Class

When you run modulus add-module Catalog, the Tests.Architecture project includes a LayerDependencyTests class with the following structure:

csharp
using NetArchTest.Rules;
using Shouldly;

namespace EShop.Modules.Catalog.Tests.Architecture;

public class LayerDependencyTests
{
    // Assembly references for each layer
    private static readonly Assembly DomainAssembly =
        typeof(Product).Assembly;

    private static readonly Assembly ApplicationAssembly =
        typeof(CreateProduct).Assembly;

    private static readonly Assembly InfrastructureAssembly =
        typeof(CatalogDbContext).Assembly;

    private static readonly Assembly ApiAssembly =
        typeof(CatalogModuleRegistration).Assembly;

    // Namespace constants
    private const string DomainNamespace = "EShop.Modules.Catalog.Domain";
    private const string ApplicationNamespace = "EShop.Modules.Catalog.Application";
    private const string InfrastructureNamespace = "EShop.Modules.Catalog.Infrastructure";
    private const string ApiNamespace = "EShop.Modules.Catalog.Api";
}

Rules Enforced

1. Domain Must Not Depend on Application, Infrastructure, or Api

The Domain layer is the innermost ring. It contains business rules and entities with zero knowledge of how they are persisted, served, or orchestrated.

csharp
[Fact]
public void Domain_Should_Not_Depend_On_Application()
{
    var result = Types.InAssembly(DomainAssembly)
        .ShouldNot()
        .HaveDependencyOn(ApplicationNamespace)
        .GetResult();

    result.IsSuccessful.ShouldBeTrue();
}

[Fact]
public void Domain_Should_Not_Depend_On_Infrastructure()
{
    var result = Types.InAssembly(DomainAssembly)
        .ShouldNot()
        .HaveDependencyOn(InfrastructureNamespace)
        .GetResult();

    result.IsSuccessful.ShouldBeTrue();
}

[Fact]
public void Domain_Should_Not_Depend_On_Api()
{
    var result = Types.InAssembly(DomainAssembly)
        .ShouldNot()
        .HaveDependencyOn(ApiNamespace)
        .GetResult();

    result.IsSuccessful.ShouldBeTrue();
}

2. Application Must Not Depend on Infrastructure or Api

The Application layer defines commands, queries, handlers, and interfaces. It depends on Domain for entities and business rules, but never reaches into Infrastructure (EF Core, external services) or Api (HTTP endpoints).

csharp
[Fact]
public void Application_Should_Not_Depend_On_Infrastructure()
{
    var result = Types.InAssembly(ApplicationAssembly)
        .ShouldNot()
        .HaveDependencyOn(InfrastructureNamespace)
        .GetResult();

    result.IsSuccessful.ShouldBeTrue();
}

[Fact]
public void Application_Should_Not_Depend_On_Api()
{
    var result = Types.InAssembly(ApplicationAssembly)
        .ShouldNot()
        .HaveDependencyOn(ApiNamespace)
        .GetResult();

    result.IsSuccessful.ShouldBeTrue();
}

3. Infrastructure Must Not Depend on Api

Infrastructure implements the abstractions defined in Application. It should never reference the Api layer.

csharp
[Fact]
public void Infrastructure_Should_Not_Depend_On_Api()
{
    var result = Types.InAssembly(InfrastructureAssembly)
        .ShouldNot()
        .HaveDependencyOn(ApiNamespace)
        .GetResult();

    result.IsSuccessful.ShouldBeTrue();
}

Module registration exception

The CatalogModuleRegistration class lives in the Api layer and references Infrastructure to wire up services. This is intentional -- the registration class is the composition root for the module. The architecture test above validates that Infrastructure does not reach back into Api.

4. Cross-Module References Are Forbidden

Modules must not reference each other's Domain, Application, Infrastructure, or Api projects. The only cross-module reference allowed is to another module's Integration project, which contains only shared event contracts.

csharp
[Fact]
public void Module_Should_Not_Depend_On_Other_Module_Internals()
{
    var otherModuleNamespaces = new[]
    {
        "EShop.Modules.Orders.Domain",
        "EShop.Modules.Orders.Application",
        "EShop.Modules.Orders.Infrastructure",
        "EShop.Modules.Orders.Api",
        "EShop.Modules.Identity.Domain",
        "EShop.Modules.Identity.Application",
        "EShop.Modules.Identity.Infrastructure",
        "EShop.Modules.Identity.Api",
    };

    var result = Types.InAssembly(DomainAssembly)
        .ShouldNot()
        .HaveDependencyOnAny(otherModuleNamespaces)
        .GetResult();

    result.IsSuccessful.ShouldBeTrue();

    result = Types.InAssembly(ApplicationAssembly)
        .ShouldNot()
        .HaveDependencyOnAny(otherModuleNamespaces)
        .GetResult();

    result.IsSuccessful.ShouldBeTrue();
}

Update cross-module checks when adding modules

When you add a new module to the solution, update the otherModuleNamespaces array in each existing module's architecture tests. This ensures the new module's boundaries are enforced from the start.

Reading Test Failures

When an architecture test fails, NetArchTest reports which types violated the rule. The Shouldly assertion provides a clear message:

Shouldly.ShouldAssertException: result.IsSuccessful
    should be
True
    but was
False

Failing types:
- EShop.Modules.Catalog.Domain.Products.Product

This tells you exactly which type introduced the forbidden dependency, making it straightforward to fix.

Adding Custom Architecture Rules

Beyond layer dependencies, you can enforce any structural convention that matters to your team.

All Handlers Must Be Sealed

Prevent accidental handler inheritance by requiring all handlers to be sealed:

csharp
[Fact]
public void Handlers_Should_Be_Sealed()
{
    var result = Types.InAssembly(ApplicationAssembly)
        .That()
        .ImplementInterface(typeof(ICommandHandler<>))
        .Or()
        .ImplementInterface(typeof(ICommandHandler<,>))
        .Or()
        .ImplementInterface(typeof(IQueryHandler<,>))
        .Should()
        .BeSealed()
        .GetResult();

    result.IsSuccessful.ShouldBeTrue();
}

All Entities Must Inherit Entity<T>

Ensure every entity in the Domain layer extends the base Entity<T> class:

csharp
[Fact]
public void Domain_Entities_Should_Inherit_Entity()
{
    var result = Types.InAssembly(DomainAssembly)
        .That()
        .ResideInNamespace(DomainNamespace)
        .And()
        .AreClasses()
        .And()
        .AreNotAbstract()
        .And()
        .DoNotHaveNameMatching(".*Event$")
        .And()
        .DoNotHaveNameMatching(".*Exception$")
        .Should()
        .Inherit(typeof(Entity<>))
        .GetResult();

    result.IsSuccessful.ShouldBeTrue();
}

All Commands Must Be Records

Enforce immutability by requiring all commands to be records:

csharp
[Fact]
public void Commands_Should_Be_Records()
{
    var result = Types.InAssembly(ApplicationAssembly)
        .That()
        .ImplementInterface(typeof(ICommand))
        .Or()
        .ImplementInterface(typeof(ICommand<>))
        .Should()
        .BeSealed()
        .GetResult();

    result.IsSuccessful.ShouldBeTrue();
}

All Validators Must Reside in the Application Layer

Ensure validators are not accidentally placed in other layers:

csharp
[Fact]
public void Validators_Should_Reside_In_Application()
{
    var result = Types.InAssembly(ApplicationAssembly)
        .That()
        .Inherit(typeof(AbstractValidator<>))
        .Should()
        .ResideInNamespace(ApplicationNamespace)
        .GetResult();

    result.IsSuccessful.ShouldBeTrue();
}

Domain Events Must Be Sealed Records

csharp
[Fact]
public void Domain_Events_Should_Be_Sealed()
{
    var result = Types.InAssembly(DomainAssembly)
        .That()
        .ImplementInterface(typeof(IDomainEvent))
        .Should()
        .BeSealed()
        .GetResult();

    result.IsSuccessful.ShouldBeTrue();
}

Best Practices

  • Run architecture tests in CI. They are fast (milliseconds) and catch violations early. Never skip them.
  • Add rules incrementally. Start with the generated layer dependency tests. Add custom rules as your team agrees on conventions.
  • Treat failures as blockers. An architecture test failure is not a warning -- it represents a real violation that will erode your modularity over time.
  • Document intent in test names. Use descriptive method names like Domain_Should_Not_Depend_On_Infrastructure rather than generic names. The test name is the documentation.

See Also

Released under the MIT License.