Mert Özen Further With Every Line
Software Architecture Modular Monolith — #07

Protecting Module Boundaries: Architecture Tests, Build Rules and Module-Level Testing

Mert Özen Nov 7, 2026 11 min 5 views
Protecting Module Boundaries: Architecture Tests, Build Rules and Module-Level Testing

Module boundaries are protected at four levels: the compiler (internal), the build (an MSBuild check in Directory.Build.targets that stops Core-to-Core references), tests (ArchUnitNET rules parameterised over the modules) and review (CODEOWNERS). With real output we show the rules catching a forgotten public type on their first run, a mutable DTO added to Contracts and a Core dependency inside a method body.

Version note: The code in this post was built and run with C# 14, .NET 10, ArchUnitNET 0.13.5 and xUnit v3.

Guess First

Three pull requests are opened in Bookstore in the same week. The first adds a project reference from Ordering's Bookstore.Ordering.Core to Bookstore.Catalog.Core. The second adds an ordinary BookDto class with get; set; properties to Catalog's Contracts project "so Ordering's screen can use it too". The third doesn't touch the DemoIds class written for demo data in the fourth post at all; that class has simply been public for months.

Which one does the compiler catch? Which one a test? Which one goes to production without anyone noticing? How many of the three does the internal and project reference setup we built in post 03 stop?

Guards at Four Levels

Module boundaries erode over time unless something protects them. In the Introduction to Architecture series post "Keeping Architecture Healthy Over Time: Architecture Erosion and Fitness Functions" we named this and explained the concept of a fitness function; the Clean/Onion/Hexagonal series post "Testing and Protecting Clean Architecture: Core Tests, Adapter Tests, and Architecture Tests" showed ArchUnitNET's basic usage and Dependency Rule tests. This post focuses only on module-specific rules. In a modular monolith the guards work at four levels:

LevelToolCatchesCannot catch
Compileinternal, project referencesAccess to another module's internal typesAdding the wrong project reference
BuildAn MSBuild check in Directory.Build.targetsA project reference from one Core to anotherDesign rules inside the same project
TestArchUnitNETType-level dependencies, public surface, the shape of ContractsChanges that need a human decision
ReviewCODEOWNERS and branch protectionContracts and rule changes reaching the right peopleAn approval nobody actually looked at

The Build Level: a Project Reference Rule

In post 03 we saw dotnet add reference add a reference from Ordering to Catalog's Core without any objection. To stop that in the build itself, we added a small MSBuild target to the Directory.Build.targets file at the solution root:

<Project>
  <!-- A module's Core project may not reference another module's Core project -->
  <Target Name="CheckModuleBoundaries" BeforeTargets="ResolveProjectReferences"
          Condition="$(MSBuildProjectName.StartsWith('Bookstore.')) And $(MSBuildProjectName.EndsWith('.Core'))">
    <ItemGroup>
      <ForbiddenCoreReference Include="@(ProjectReference)"
          Condition="$([System.String]::Copy('%(Filename)').EndsWith('.Core'))" />
    </ItemGroup>
    <Error Condition="'@(ForbiddenCoreReference)' != ''"
           Code="BOOKSTORE001"
           Text="$(MSBuildProjectName) references @(ForbiddenCoreReference->'%(Filename)'). Modules may only reference another module's Contracts project." />
  </Target>
</Project>

Like Directory.Build.props, Directory.Build.targets is found by searching up the folder tree and imported into every project, but at the end of the project file; by then all ProjectReference items are defined. The target runs in every project whose name starts with Bookstore. and ends with .Core, and stops the build if another .Core project is among its references. The first pull request in CI (Continuous Integration):

Directory.Build.targets(9,5): error BOOKSTORE001: Bookstore.Ordering.Core references Bookstore.Catalog.Core. Modules may only reference another module's Contracts project.

The error code is our own BOOKSTORE001. The message states the problem and the fix together: reference Contracts. This check runs without writing a test, on every dotnet build and every build in the IDE (Integrated Development Environment); the feedback arrives within seconds.

Module Rules with ArchUnitNET

Forbidding the project reference isn't enough: a boundary can also be breached from inside a project. We wrote the module rules in a test project, once for all modules in the solution:

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

namespace Bookstore.ArchitectureTests;

public sealed class ModuleBoundaryTests
{
    public static readonly TheoryData<string> Modules = ["Catalog", "Inventory", "Ordering", "Shipping", "Payments"];

    // All Bookstore assemblies in the solution, from the Host's output folder
    private static readonly Architecture Architecture = new ArchLoader()
        .LoadAssemblies([.. Directory.GetFiles(AppContext.BaseDirectory, "Bookstore.*.dll")
            .Where(f => !f.EndsWith("Tests.dll"))
            .Select(System.Reflection.Assembly.LoadFrom)])
        .Build();

    [Theory]
    [MemberData(nameof(Modules))]
    public void Core_does_not_depend_on_another_modules_core(string module) =>
        Types().That().ResideInNamespaceOrBelow($"Bookstore.{module}.Core").Should()
            .NotDependOnAny(Types().That().ResideInNamespaceMatching(@"^Bookstore\.\w+\.Core(\..*)?$")
                .And().DoNotResideInNamespaceOrBelow($"Bookstore.{module}.Core"))
            .Because("modules talk to each other only through Contracts")
            .Check(Architecture);

    [Theory]
    [MemberData(nameof(Modules))]
    public void Contracts_contain_only_interfaces_records_and_value_types(string module) =>
        Classes().That().ResideInNamespaceOrBelow($"Bookstore.{module}.Contracts").Should()
            .BeRecord().AndShould().BeSealed().AndShould().BeImmutable()
            .Because("a contract is a snapshot, not a model with behaviour")
            .WithoutRequiringPositiveResults()   // for modules whose Contracts have no classes yet
            .Check(Architecture);

    [Theory]
    [MemberData(nameof(Modules))]
    public void Contracts_do_not_know_frameworks(string module) =>
        Types().That().ResideInNamespaceOrBelow($"Bookstore.{module}.Contracts").Should()
            .NotDependOnAny(Types(true).That().ResideInNamespaceOrBelow("Microsoft.EntityFrameworkCore")
                .Or().ResideInNamespaceOrBelow("Microsoft.AspNetCore"))
            .WithoutRequiringPositiveResults()
            .Check(Architecture);

    [Theory]
    [MemberData(nameof(Modules))]
    public void Only_the_module_entry_point_and_migrations_are_public_in_core(string module) =>
        Types().That().ResideInNamespaceOrBelow($"Bookstore.{module}.Core").And().ArePublic()
            .And().DoNotResideInNamespaceOrBelow($"Bookstore.{module}.Core.Persistence.Migrations").Should()
            .HaveFullName($"Bookstore.{module}.Core.{module}Module")
            .Because("everything else inside a module is internal")
            .Check(Architecture);
}

Four rules, each running for five modules, 20 tests in all. The first checks at type level that a module's Core does not depend on another module's Core; because ArchUnitNET reads the compiled IL (Intermediate Language), it also sees usages inside method bodies. The dependencies our reflection tool in the first post missed are caught here. The second requires classes in Contracts to be sealed, immutable records. The third requires Contracts not to depend on EF (Entity Framework) Core or ASP.NET Core. The fourth requires that the only public types in Core are the module's entry point (CatalogModule, OrderingModule…) and the migration classes EF Core generates.

Two details. Instead of loading assemblies one by one with typeof, the test loads every Bookstore.*.dll in the Host's output folder; when a new module is added the rules cover it automatically, you only add its name to the Modules list. And because Shipping's and Payments' Contracts have no classes yet, we used WithoutRequiringPositiveResults(). By default ArchUnitNET treats a rule that matches no types as a failure; that is a good default, preventing a rule that checks nothing because of a misspelled namespace from passing silently, but it has to be turned off explicitly where emptiness is expected.

What Did the Rules Find on Day One?

The first time we ran the tests, one failed without us adding any violation:

failed Bookstore.ArchitectureTests.ModuleBoundaryTests.Only_the_module_entry_point_and_migrations_are_public_in_core(module: "Catalog") (0ms)
ArchUnitNET.xUnitV3.FailedArchRuleException : "Types that reside in namespace with full name "Bookstore.Catalog.Core" or below and are public and do not reside in namespace with full name "Bookstore.Catalog.Core.Persistence.Migrations" or below should have full name "Bookstore.Catalog.Core.CatalogModule" because everything else inside a module is internal" failed:
    Bookstore.Catalog.Core.DemoIds does have full name Bookstore.Catalog.Core.DemoIds

In post 04 we made DemoIds public so the ChattyProbe tool could produce the demo book ids. The tool stayed in that post; the public keyword stayed in the code. The compiler doesn't see it as a problem, nobody noticed it in code review, and Catalog's public surface had grown by one type. We made it internal and all 20 tests passed. That is the answer for the third pull request from the opening, the thing nobody touched that was already wrong: only an automated rule can catch it.

Breaking the Rules

The second pull request, the mutable DTO (Data Transfer Object) added to Contracts:

namespace Bookstore.Catalog.Contracts;

// A mutable DTO added "so Ordering's screen can use it too"
public class BookDto
{
    public Guid Id { get; set; }
    public string Title { get; set; } = "";
    public decimal Price { get; set; }
}

The build passes and the MSBuild check stays silent, because the project references didn't change. The architecture test breaks:

failed Bookstore.ArchitectureTests.ModuleBoundaryTests.Contracts_contain_only_interfaces_records_and_value_types(module: "Catalog") (1ms)
ArchUnitNET.xUnitV3.FailedArchRuleException : "Classes that reside in namespace with full name "Bookstore.Catalog.Contracts" or below should be record and should be sealed and should be immutable because a contract is a snapshot, not a model with behaviour" failed:
    Bookstore.Catalog.Contracts.BookDto is not record and is not sealed and is not immutable
  total: 20
  failed: 1
  succeeded: 19

We also tried a world without the MSBuild check: we deleted Directory.Build.targets, added Catalog's Core reference to Ordering and called CatalogModule, Catalog's only remaining public type, from a method body:

using Bookstore.Catalog.Core;
using Microsoft.AspNetCore.Routing;

namespace Bookstore.Ordering.Core;

// A shortcut added because "Ordering's tests need Catalog's endpoints too"
internal static class CatalogWarmup
{
    public static void MapEverything(IEndpointRouteBuilder app) => app.MapCatalogEndpoints();
}
failed Bookstore.ArchitectureTests.ModuleBoundaryTests.Core_does_not_depend_on_another_modules_core(module: "Ordering") (1ms)
ArchUnitNET.xUnitV3.FailedArchRuleException : "Types that reside in namespace with full name "Bookstore.Ordering.Core" or below should not depend on any Types that reside in namespace with full name matching "^Bookstore\.\w+\.Core(\..*)?$" and do not reside in namespace with full n... because modules talk to each other only through Contracts" failed:
    Bookstore.Ordering.Core.CatalogWarmup does depend on "Bookstore.Catalog.Core.CatalogModule"

The two guards complement each other: the MSBuild check stops the reference the moment it is added, and ArchUnitNET catches the usage itself if the reference somehow gets in. If one is switched off, the other is still there.

NetArchTest or ArchUnitNET?

There are two well-known libraries for this in .NET. Kamil Grzybek's modular-monolith-with-ddd sample uses NetArchTest for its architecture tests. However, the latest version on NuGet, NetArchTest.Rules 1.3.2, dates from May 2021 and nothing has been released since. ArchUnitNET is actively developed: the 0.13.5 we use was published on 26 October 2026. We chose ArchUnitNET in the Clean/Onion/Hexagonal series for the same reason. If you have a project using NetArchTest it keeps working, but for a fresh start choosing the maintained library is less risky.

The comparison with the Java world is instructive too. Spring Modulith derives modules from the package structure itself and checks three rules with a call to ApplicationModules.of(...).verify(): no cycles between modules, access to another module only through its API (Application Programming Interface) package, and optionally only explicitly allowed dependencies. The four rules we wrote are the hand-built .NET version of the same need.

Module Ownership with CODEOWNERS

Automated rules say "what cannot be done", not "who decides". A change to a module's Contracts affects the module's consumers, and the team that wrote the module shouldn't evaluate it alone. GitHub's CODEOWNERS file defines which people or teams are automatically requested as reviewers on pull requests that touch a path:

# The last matching line wins: write from general to specific
*                                                  @bookstore/platform
/src/Modules/Catalog/                              @bookstore/catalog-search
/src/Modules/Ordering/                             @bookstore/ordering-payment
/src/Modules/Payments/                             @bookstore/ordering-payment
/src/Modules/Inventory/                            @bookstore/fulfillment
/src/Modules/Shipping/                             @bookstore/fulfillment
# Contracts affect consumers: module team and platform together
/src/Modules/Catalog/Bookstore.Catalog.Contracts/  @bookstore/catalog-search @bookstore/platform
/src/Modules/Ordering/Bookstore.Ordering.Contracts/ @bookstore/ordering-payment @bookstore/platform
# The rules themselves
/Directory.Build.targets                           @bookstore/platform
/tests/Bookstore.ArchitectureTests/                @bookstore/platform
/.github/CODEOWNERS                                @bookstore/platform

Two rules from GitHub's documentation shape this file. First, the last line matching a file wins, so we wrote it from general to specific. Second, owners written on separate lines for the same path do not combine, only the last line counts; that is why the module team and the platform team share a single line for Contracts. The platform team also owns the file itself, Directory.Build.targets and the architecture tests: loosening the rules should not be a decision one module team can make alone. For this file to have any effect, "Require review from Code Owners" must be turned on in branch protection or a ruleset. The team names match the four stream-aligned teams Bookstore will form within 12–18 months; in the Introduction to Architecture series post "Conway's Law: How Team Structure Shapes Architecture" we discussed why team and module boundaries should line up.

Testing a Module on Its Own

Architecture tests protect structure, not behaviour. Testing a module's behaviour has an advantage specific to a modular monolith: because a module knows only its neighbours' contracts, you can boot it on its own. In the ASP.NET Core Web API series post "Integration Testing an ASP.NET Core Web API: End-to-End Tests with WebApplicationFactory" we tested the whole application with WebApplicationFactory. Here we use the same infrastructure, TestServer, for Ordering alone:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="xunit.v3" Version="4.0.2" />
    <PackageReference Include="Microsoft.AspNetCore.TestHost" Version="10.0.14" />
  </ItemGroup>
  <ItemGroup>
    <!-- Only Ordering and the other modules' Contracts: no Catalog.Core or Inventory.Core -->
    <ProjectReference Include="..\..\src\Modules\Ordering\Bookstore.Ordering.Core\Bookstore.Ordering.Core.csproj" />
  </ItemGroup>
</Project>
public sealed class OrderingModuleHost : IAsyncLifetime
{
    private readonly string _connectionString =
        $"{Environment.GetEnvironmentVariable("ORDERING_TEST_SQL")};Database=ordering_tests_{Guid.NewGuid():N}";
    private WebApplication _app = null!;

    public FakeCatalog Catalog { get; } = new();
    public FakeInventory Inventory { get; } = new();
    public HttpClient Client { get; private set; } = null!;

    public async ValueTask InitializeAsync()
    {
        WebApplicationBuilder builder = WebApplication.CreateBuilder();
        builder.WebHost.UseTestServer();
        builder.Logging.ClearProviders();
        builder.Services
            .AddInProcessMessaging()
            .AddOrderingModule(_connectionString, useOutbox: true)
            .AddSingleton<ICatalogModuleApi>(Catalog)
            .AddSingleton<IInventoryModuleApi>(Inventory);

        _app = builder.Build();
        await _app.Services.InitializeOrderingModuleAsync();
        _app.MapOrderingEndpoints();
        await _app.StartAsync();
        Client = _app.GetTestClient();
    }

    public async Task<int> OutboxCountAsync(Guid orderId)
    {
        using IServiceScope scope = _app.Services.CreateScope();
        OrderingDbContext db = scope.ServiceProvider.GetRequiredService<OrderingDbContext>();
        return await db.OutboxMessages.CountAsync(m => m.Payload.Contains(orderId.ToString()));
    }

    public async ValueTask DisposeAsync()
    {
        using (IServiceScope scope = _app.Services.CreateScope())
            await scope.ServiceProvider.GetRequiredService<OrderingDbContext>().Database.EnsureDeletedAsync();
        await _app.DisposeAsync();
    }
}

The test host calls only AddOrderingModule(); Catalog and Inventory are represented by fake implementations of their contracts. Ordering's own database is real: for each test class a database with a random name is created on SQL (Structured Query Language) Server, Ordering's migrations are applied, and the database is deleted when the class finishes. The other modules' schemas don't exist in this database at all and the test still passes; that is the most concrete proof that Ordering doesn't secretly depend on another module's table.

using System.Net;
using System.Net.Http.Json;
using Xunit;

namespace Bookstore.Ordering.Tests;

public sealed class PlaceOrderTests(OrderingModuleHost host) : IClassFixture<OrderingModuleHost>
{
    private sealed record Confirmation(Guid OrderId, int Lines, decimal Total, string Currency);

    [Fact]
    public async Task Order_is_saved_with_its_outbox_message()
    {
        host.Inventory.Available = 5;
        HttpResponseMessage response = await host.Client.PostAsJsonAsync("/api/ordering/orders",
            new { lines = new[] { new { bookId = FakeCatalog.Dune.Value, quantity = 2 } } }, TestContext.Current.CancellationToken);

        Assert.Equal(HttpStatusCode.Created, response.StatusCode);
        Confirmation? confirmation = await response.Content.ReadFromJsonAsync<Confirmation>(TestContext.Current.CancellationToken);
        Assert.Equal(25.00m, confirmation!.Total);
        Assert.Equal(1, await host.OutboxCountAsync(confirmation.OrderId));
    }

    [Fact]
    public async Task Order_is_rejected_when_inventory_has_no_stock()
    {
        host.Inventory.Available = 0;
        HttpResponseMessage response = await host.Client.PostAsJsonAsync("/api/ordering/orders",
            new { lines = new[] { new { bookId = FakeCatalog.Dune.Value, quantity = 1 } } }, TestContext.Current.CancellationToken);

        Assert.Equal(HttpStatusCode.Conflict, response.StatusCode);
    }
}

The output for all the tests in the solution:

tests/Bookstore.ArchitectureTests Bookstore.ArchitectureTests.dll (net10.0|arm64) passed (725ms)
tests/Bookstore.Ordering.Tests Bookstore.Ordering.Tests.dll (net10.0|arm64) passed (1s 618ms)
Test run summary: Passed!
  total: 22
  failed: 0
  succeeded: 22

This test has a limit too: it checks that Ordering interprets Inventory's contract correctly, not that Inventory honours that contract. The latter is the job of Inventory's own tests. If you want to see the fit between two modules in one place, add a small number of end-to-end tests that boot the whole Host with WebApplicationFactory.

Common Pitfalls

Writing rules for one module and copying them

Ordering_does_not_depend_on_Catalog_core, Ordering_does_not_depend_on_Inventory_core… As the number of modules grows the rules grow quadratically, and someone who forgets to add the new module leaves it unprotected. Write the rules parametrically over the list of modules.

Not noticing rules with empty results

A rule with a misspelled namespace checks no types. ArchUnitNET's default treats that as a failure; use WithoutRequiringPositiveResults() only where emptiness is genuinely expected.

Running architecture tests in Release

ArchUnitNET's README recommends running the tests in the Debug configuration, because optimisations in a Release build can remove some dependencies from the IL. In CI, leave dotnet test on its default Debug configuration.

Exempting violations "temporarily"

Adding an exception to the list to silence a broken rule erodes the rule's meaning over time, as we saw in Shopify's experience. If an exception is needed, it should be a change with an owner and an end date, one that CODEOWNERS sends to the platform team.

Using real neighbours in a module test

Adding Catalog's real module to Ordering's test host "because it works anyway" ties Ordering's tests to Catalog's database and data. A module test should fake its neighbours at contract level.

A Small Experiment

★☆☆ Add another rule: the names of interfaces in Contracts projects must end with ModuleApi. Does the current code follow it?

★★☆ Extend the check in Directory.Build.targets: Contracts projects may only reference Bookstore.SharedKernel, Bookstore.BuildingBlocks and other Contracts projects. See the error message with a deliberate violation.

★★★ Write a test host like OrderingModuleHost for Inventory. Test the ReserveStockWhenOrderPlaced handler by handing a real OrderPlaced event to the bus, and confirm that giving it the same event twice makes the reservation only once.

In this post we handed the module boundaries to automated guards. In the last post of the series we use the biggest option those boundaries give us: extracting a module into a separate service when it is truly needed. When, in what order, and in a way that can be reversed at every step.