Back to Field Notes

Architectural Tests: Encode the Decision Once, Enforce It Forever

A PR looked fine and quietly broke a module boundary anyway. Nobody caught it, because the boundary lived in my head. So now the build catches it.

TL;DRBehaviour tests check what the code does; architectural tests check what it is. I run a small reflection-based rule engine in CI that asserts structural rules: module isolation, every endpoint authorized, no ambient clock (inject TimeProvider), CancellationToken on async actions, entities implement IAuditable, controllers sealed. Each rule carries a KnownMax baseline so existing debt is capped and ratcheted down instead of blocking you on day one, the same fail-forward idea as CI. It matters more with AI in the loop: the assistant writes correct-looking code at volume and has no concept of your boundaries, so the model suggests and the build decides.

A PR came through last month that looked completely fine. Sensible names, tests green, did exactly what the ticket asked. It also quietly added a project reference from the Events module straight into Registrations, reaching into another module's internals to grab a value.

One line, buried in a diff of forty files. Easy to miss. Six months of those and the modular monolith I'd carefully drawn boundaries around is a big ball of mud with a nicer folder structure.

Nobody did anything wrong. The reviewer had a finite attention budget and spent it on the logic, not the project file. The boundary lived in my head and a Confluence page nobody reads. That's the thing about architecture: a set of decisions that everything conspires to erode, and the only thing guarding them is whatever attention a reviewer has left after the other thirty-nine files.

Behaviour tests check what the code does. These check what it is.

Normal unit tests assert behaviour: given this input, return that output. Architectural tests assert structure, the shape of the codebase. They're still just tests that run in CI or locally, but instead of "given X return Y" they say "no Modules.* assembly may reference another Modules.* assembly" and turn the build red if one does.

I use a small reflection-based rule engine: each rule is a class that knows how to find its own violations:

internal sealed class ModuleIsolationRule : IArchitectureRule
{
    public string Id => "ModuleIsolation";
    public string Title => "Modules Must Not Reference Other Modules";
    public Severity Severity => Severity.Critical;
    public string FixHint =>
        "Expose the capability as a contract in Shared and depend on that interface instead.";
    public int KnownMax => 0;

    public IEnumerable<Violation> Detect(ScanContext ctx) =>
        ctx.Modules.SelectMany(m =>
            ctx.ModuleReferences(m).Select(r => new Violation(m.Name, $"references {r}")));
}

The rules get discovered from the assembly and each runs as its own test, so a failure tells you exactly which rule broke and where. That Events → Registrations reference? It doesn't merge anymore. The build goes red the moment the reference appears, not six months later when someone's trying to extract the module and can't.

The rules I actually run

Nothing abstract. These are the ones earning their keep in my current project:

  • Module isolation. Modules talk through Shared contracts, never a direct reference (above).
  • Every endpoint is authorized. Every action carries [Authorize] or an explicit [AllowAnonymous]. An action with neither is an accidental open endpoint and I mark that Critical because "we forgot to protect that route" is exactly how things leak.
  • No ambient clock. No DateTime.UtcNow / .Now / .Today anywhere in src or tests. Inject TimeProvider instead. Ambient clock reads are untestable and for local-time reads flip date assertions around midnight. I learned that one the annoying way.
  • Async actions take a CancellationToken and it has to be the last parameter so work stops when the client disconnects instead of holding a database connection open for a request nobody is waiting for.
  • Persisted entities implement IAuditable so the audit interceptor fills the created / modified / deleted columns automatically. Miss the interface, silently miss the audit trail.
  • Then a pile of smaller ones: controllers sealed, mappers static, no DbContext injected straight into a controller, routes versioned, route parameters constrained.

None of these are clever. Every single one is a decision I would otherwise be re-explaining in review comments until I retire (that's still very far off).

The part that makes it survivable: baselines

Here's the trick that stops this from being miserable to adopt in a codebase that already exists. When you bolt architectural tests onto an existing codebase everything is red on day one. You've got forty CancellationToken violations and zero appetite to fix them all before shipping the feature you're actually here for.

For this, each rule carries a KnownMax, a baseline count of tolerated existing violations:

public int KnownMax => 24; // baseline: pre-existing debt only

The test fails if violations exceed the known max and not if any exist at all. New debt is blocked immediately, while old debt is capped and paid down on your own schedule. Every time you clean up a batch you lower the number and it can only ever go down. This is the ratchet, and the useful part is that the number only ever has to move in one direction, which is a far easier thing to get agreement on than a cleanup sprint.

The suite also emits an HTML debt report (rules, counts, severities), so "how's our architecture holding up" has an actual answer.

Why I bother more now than I used to

I'll be honest: I built most of this tooling because of AI. Back when I was hand-typing every line, architecture drifted slowly at the speed of one tired developer. A lot was copy pasted so you automatically took over the existing pattern. Now the assistant produces correct-looking code at volume, with no idea that Events isn't allowed to touch Registrations and no opinion about it either way. It'll wire that reference up helpfully and the diff will look clean and the tests it writes will pass. A failing test is what forces it back into the consistency the project deserves.

I've written before about guardrails around AI-generated code, about keeping the model inside the conventions of the project in the first place. Architectural tests are the other half of that: the assistant is told the rules up front and the build enforces them whether it listened or not (a model will cheerfully ignore both the prompt and the markdown file you pointed it at). The more of a project's rules I can make deterministic, the more work I can hand the model without standing over it.

This is the honest version of a thing I keep saying, that an AI can't tell you your architecture is wrong. It can't, because it has no concept of your boundaries, only of the many other codebases it was trained on. So I stopped hoping it would notice and started encoding the decision once, in a test that runs on every push. I do this in every project now. It costs some setup and one conversation with the other devs, and after that it catches the things that would otherwise sail through review, like a project reference sitting in file thirty-eight of forty.


Timothy De Bock

Timothy De Bock

Full-stack .NET platform engineer specializing in government, healthcare & security sectors.