Architecture tests are useful because they turn a design decision into executable feedback. They can stop old failure modes from quietly returning as a codebase grows.
But there is a subtle failure mode in the tests themselves: the detector can be correct while discovery is incomplete.
I recently reviewed a .NET guard added after an async background job disposed a dependency-injection scope synchronously. The guard used syntax parsing to find the unsafe shape. It had self-tests. It asserted it had discovered many files. It passed.
A background job with the same risk still sat outside the scanned tree.
That experience sharpened a rule I now find useful: an architecture guard has three separate contracts—detector, discovery, and boundary. Green means little unless all three are credible.
1. The detector contract
The detector asks, “Can I recognise the thing I intend to prevent?”
Consider a generalized C# example:
public async Task RunAsync()
{
using var scope = scopeFactory.CreateScope();
await ProcessAsync(scope.ServiceProvider);
}
Enter fullscreen mode Exit fullscreen mode
If a service resolved from that scope supports asynchronous disposal only, disposing the scope synchronously can fail during teardown. In an isolated background loop, that failure may be logged poorly or mistaken for an ordinary item failure.
The safer shape is explicit:
public async Task RunAsync()
{
await using var scope = scopeFactory.CreateAsyncScope();
await ProcessAsync(scope.ServiceProvider);
}
Enter fullscreen mode Exit fullscreen mode
A good detector needs tests for more than the obvious bad case. In the reviewed guard, the useful cases included:
- rejecting both forms of synchronous
using; - accepting
await using; - accepting a synchronous scope in a genuinely non-async method;
- finding the pattern inside methods, local functions, and lambdas.
These are tests of the rule’s semantics. Without them, changing the parser can silently weaken the guard.
Still, a perfect predicate applied to zero files is perfect at preventing nothing.
2. The discovery contract
Discovery asks, “Did I actually find the population I claim to govern?”
The guard included a minimum-count assertion. That was a sound choice. If repository discovery broke completely after a directory move, the test would fail instead of reporting an empty success.
But a count is necessary, not sufficient.
Imagine a repository with 30 jobs in one source tree and one job in another. A threshold of 20 still passes when the second tree is omitted. The number looks healthy while a legitimate ownership area remains invisible.
Useful discovery checks include:
- a credible minimum count;
- at least one known sentinel from each expected root;
- exclusions limited to generated and build-output directories;
- failure messages that print the roots and discovered totals;
- a periodic repository-wide inventory to compare with the guarded set.
Sentinels should represent architectural areas, not specific business features. Their purpose is to detect topology drift, not freeze filenames forever.
3. The boundary contract
Boundary asks the hardest question: “Where can this behaviour legally live?”
The original scan assumed background jobs belonged under one top-level tree. That was mostly true, which made the assumption difficult to notice. A host-side project provided another valid home, and the architecture test knew nothing about it.
The follow-up did two things: it moved the outlying job onto the shared async-safe execution seam, and it widened discovery to both legitimate roots.
The general lesson is that scan roots should come from ownership rules, not convenience. Before finalising a guard:
- Search the whole repository for the structural pattern and naming convention.
- Group matches by architectural owner.
- Document why each root is included or excluded.
- Make repository topology changes trigger a guard review.
This is particularly important in modular monoliths. Modules may own most behaviour, while hosts still contain composition jobs, adapters, or schedulers. “Most” is not a safe enforcement boundary.
Source scan or compiler analyzer?
A source-level architecture test can be a very good engineering choice. It is fast, local, easy to place in an existing test suite, and can enforce a narrow convention without introducing references between production projects.
Its limitations should be explicit:
- it reasons primarily about syntax, not full type semantics;
- it depends on file discovery and naming conventions;
- it runs in CI rather than guiding the developer while typing;
- a repository restructure can change its field of view.
A compiler analyzer can use the semantic model, provide IDE diagnostics, and follow symbols rather than textual shapes. That is stronger for a rule that is widespread, safety-critical, or likely to appear in many projects.
The trade-off is real: analyzers need packaging, versioning, configuration, diagnostics, suppressions, and maintenance. They also only protect projects that load them. An analyzer distributed inconsistently has the same kind of boundary problem in a different form.
Start with the lightest mechanism that credibly enforces the risk. Promote the rule when its reach or complexity outgrows the source scan.
A practical review checklist
When an architecture test goes green, ask:
- Have we proved bad examples fail and safe examples pass?
- Would discovery fail if it found nothing?
- Does each legitimate source root contribute a sentinel?
- Did we inventory the whole repository before choosing those roots?
- Are exclusions visible and justified?
- Is the guard part of a required CI path?
- Would a compiler analyzer now be proportionate?
Architecture tests are executable boundaries. Like production boundaries, they deserve threat modelling: what can bypass them, what can drift, and how would we know?
The most useful outcome from the review was not simply a wider glob. It was a more complete definition of “covered.”
Where in your codebase might a green guard be looking in the right way—but in the wrong places?
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.