Skip to content
fullstackhero

Guide

Architecture tests

Reflection + NetArchTest assertions that enforce module boundaries, contracts purity, naming conventions, and architectural invariants as compile-time rules.

views 0 Last updated

The architecture suite checks structural rules at build time. The kit ships around 50 tests across 14 rule files in src/Tests/Architecture.Tests/ that enforce module boundaries, contracts purity, layering, naming conventions, and the domain conventions vertical slices rely on. Break a rule, fail the build. The suite mixes NetArchTest’s fluent API with plain reflection and even .csproj parsing — whatever expresses the rule most directly.

The rule files

Test fileEnforces
ModuleArchitectureTestsModule runtime projects never reference another module’s runtime project (parses .csproj references — only .Contracts is allowed)
ContractsPurityTests*.Contracts assemblies don’t depend on EF Core, FluentValidation, Hangfire, or module implementations; no DbContext/repository types; commands and queries are records or sealed
BuildingBlocksIndependenceTestsBuilding blocks don’t depend on modules or hosts; Core is dependency-free; the blocks layer correctly among themselves
CircularReferenceTestsNo circular project references — across the solution, the modules, or the building blocks
LayerDependencyTestsCore doesn’t depend on EF/ASP.NET; domain types don’t reach into persistence or infrastructure
HandlerValidatorPairingTestsEvery command handler — and every paginated query handler — has a matching validator
DomainEntityTestsDomain events implement IDomainEvent and are sealed; entities implement IEntity; aggregate roots don’t reference other aggregates directly; value objects are immutable
EndpointConventionTestsEndpoint classes are static, live under Features, expose a Map… method that takes IEndpointRouteBuilder and returns RouteHandlerBuilder, contain no business logic, and follow the naming convention
ApiVersioningTestsEvery feature lives under a versioned namespace (Features.v{N}); v1 never depends on higher versions; commands/queries sit in the same version as their handlers
FeatureArchitectureTestsFeature versions never depend on newer versions
HostArchitectureTestsModules don’t depend on hosts; hosts don’t reach into module internals
NamespaceConventionsTestsBuilding-block namespaces match folder structure
TenantIsolationTestsEvery entity in a BaseDbContext is tenant-isolated or explicitly marked IGlobalEntity
ModuleAssemblyDiscoveryGuard: the discovery helper found at least one module assembly (prevents the whole suite silently no-opping)

How modules are discovered

Most rules iterate ModuleAssemblyDiscovery.GetModuleAssemblies(), which scans the test output directory for FSH.Modules.*.dll (excluding .Contracts). Adding a new module to the suite only requires adding its project reference to Architecture.Tests.csproj — no rule file changes. A guard test fails if discovery ever comes back empty.

An example rule

The module-boundary rule doesn’t even need reflection — it reads each runtime module’s .csproj and asserts no ProjectReference points at another module’s runtime project:

src/Tests/Architecture.Tests/ModuleArchitectureTests.cs
[Fact]
public void Modules_Should_Not_Depend_On_Other_Modules()
{
var runtimeProjects = Directory
.GetFiles(modulesRoot, "Modules.*.csproj", SearchOption.AllDirectories)
.Where(path => !path.Contains(".Contracts", StringComparison.OrdinalIgnoreCase));
foreach (string projectPath in runtimeProjects)
{
var references = XDocument.Load(projectPath)
.Descendants("ProjectReference")
.Select(x => (string?)x.Attribute("Include") ?? string.Empty);
// any reference to Modules.* that is not *.Contracts fails the test
// with the offending project + reference named in the message
}
}

One Fact, every module under test, and the failure message names the offending project so you know exactly where to look.

Handler / validator pairing

The rule the kit leans on most: every command handler must have a matching validator, and every query handler whose query has PageNumber/PageSize properties must too. The test reflects over ICommandHandler<>/IQueryHandler<,> implementors in every module assembly and looks for an AbstractValidator<TCommand>.

Deliberate exceptions go in explicit allowlists at the top of the file (KnownMissingCommandHandlers, KnownMissingQueryHandlers) — so an exemption is a visible, reviewable code change rather than a silent gap.

Endpoint conventions

Minimal-API endpoints in the kit are static classes under a Features namespace, exposing a Map… extension method on IEndpointRouteBuilder that returns RouteHandlerBuilder. EndpointConventionTests enforces all of it — shape, namespace, signature, naming, and a “no business logic in endpoints” rule. That’s what keeps 165+ endpoint classes consistent; new endpoints that drift get caught by CI.

Feature folder convention

ApiVersioningTests.Feature_Folders_Should_Follow_Version_Convention requires every type under Features to live under Features.v{N}. This is why the kit has Features/v1/Users/RegisterUser/ and not Features/Users/RegisterUser/. The version segment is mandatory; older versions stick around as v1/ while you build v2/ — and a companion rule guarantees v1 never depends on v2.

Running the architecture suite

Terminal window
# Run just the architecture tests — fast, no containers
dotnet test src/Tests/Architecture.Tests/

There’s no infrastructure to spin up — assembly scanning + reflection is all the suite needs, so it runs in seconds.

Adding a new rule

When a new architectural convention emerges (or you’ve fixed a recurring code-review nit), encode it:

src/Tests/Architecture.Tests/MyNewRuleTests.cs
namespace Architecture.Tests;
public sealed class MyNewRuleTests
{
/// <summary>
/// Specification: every Mediator handler must be public sealed.
/// Why: source-gen requires concrete types; sealed prevents accidental
/// inheritance which would break the generator.
/// </summary>
[Fact]
public void Mediator_Handlers_Should_Be_Public_Sealed()
{
var failures = new List<string>();
foreach (var module in ModuleAssemblyDiscovery.GetModuleAssemblies())
{
var handlers = module.GetTypes()
.Where(t => t.IsClass && !t.IsAbstract)
.Where(t => t.GetInterfaces().Any(i => i.IsGenericType &&
(i.GetGenericTypeDefinition() == typeof(ICommandHandler<,>) ||
i.GetGenericTypeDefinition() == typeof(IQueryHandler<,>))));
failures.AddRange(handlers
.Where(h => !h.IsPublic || !h.IsSealed)
.Select(h => h.FullName!));
}
failures.ShouldBeEmpty($"Handlers must be public sealed: {string.Join(", ", failures)}");
}
}

Three essentials, all visible in the shipped rules:

  • XML doc comment stating the rule and why. The comment is the readable specification; the assertion is the enforcement. Without the why, future contributors won’t know whether the rule is still relevant.
  • Specific failure message — name the offending types.
  • An explicit allowlist if there are legitimate exceptions, so exemptions are code-reviewed, not invisible.

What architecture tests don’t enforce

  • Runtime behaviour. A class can satisfy every structural rule and still have a logic bug. Architecture tests are structural; behaviour belongs in unit / integration tests.
  • Authorization. “Every endpoint requires the right permission” is a runtime guarantee — the integration suite’s authorization tests cover it end-to-end (401 without a token, 403 without the permission).
  • Performance. A rule that “no handler can be slow” can’t be expressed; benchmarks belong in a separate suite (the kit doesn’t ship benchmarks; you’d run BenchmarkDotNet against your fork).
  • Security. Architecture tests check “does this code obey the architectural invariants,” not “is this code injection-vulnerable.” Security testing is a separate effort (SAST tools, penetration tests).

Why this matters

Architectural rules drift slowly. One PR adds a temporary cross-module reference “just for testing”; another developer copies the pattern; a year later, the modular monolith has cyclic dependencies. Architecture tests stop this in the first PR.

These rules cost almost nothing — seconds per run, no infrastructure. Use them generously. Every rule you’ve ever had to enforce in code review is a rule you could encode here.