Software Architecture · Architecture Evaluation

Fitness Functions as Build-Time Architectural Constraints

How an architectural decision becomes a check that runs on every commit: dependency rules, thresholds with baselines, and the ratchet that stops drift instead of freezing it.

A layering rule that exists only in a review checklist is enforced about as often as the review happens, by whoever remembers it, with whatever understanding of the code they had at the time. A layering rule expressed as a test runs on every commit, applies the same interpretation to everyone, and fails loudly the moment a violation lands. That conversion — from a decision that people remember to a check that the build computes — is what a fitness function is, and the difficulty is not writing the check. It is choosing checks whose failure means something.

Properties a check needs before it counts

An architectural constraint that qualifies has four properties, and dropping any of them produces something that looks like governance and is not.

It is objective. Pass or fail follows from the artefact, not from judgement. “Services communicate asynchronously” is objective if the check inspects imports or configuration; it is not objective if a reviewer reads a pull request.

It runs without human scheduling. A check that someone remembers to run after a big refactor catches drift months late, when the diff that caused it is unfindable.

It is cheap enough to run per commit. Structural rules over an import graph finish in seconds on a repository of thousands of files; a load test does not, and belongs on a different cadence, as discussed below.

It is connected to a decision. The check exists because someone chose a structure and needs to know when the structure changes. Rules adopted because a tool offers them, with no decision behind them, are the ones that get muted.

The last property is the one that decides whether the set of checks survives contact with a team. A rule nobody can justify is a rule whose first false positive becomes a permanent exception.

Encoding the dependency decision

The most common fitness function is a dependency rule over the module graph from dependency structure matrices. Two forms dominate.

In JVM projects, ArchUnit expresses rules against the compiled bytecode, which means the rules see real types, generics, and annotations rather than text. A rule that the domain layer must not depend on the web layer is two lines of fluent API, and a violation message names the offending class and the exact line of source that created the dependency.

@AnalyzeClasses(packages = "com.example.app")
class LayerRules {

    @ArchTest
    static final ArchRule domain_does_not_see_web =
        noClasses().that().resideInAPackage("..domain..")
            .should().dependOnClassesThat().resideInAPackage("..web..");

    @ArchTest
    static final ArchRule services_are_entered_only_from_adapters =
        classes().that().resideInAPackage("..domain.service..")
            .should().onlyBeAccessed().byAnyPackage("..domain..", "..adapter..");
}

In Python, Import Linter works on the static import graph and states layering as a contract in a configuration file. Python’s dynamic imports mean the graph is a lower bound, so a passing contract is a claim about import statements rather than about all runtime coupling.

[importlinter]
root_package = myapp

[importlinter:contract:layers]
name = Layered architecture
type = layers
layers =
    myapp.api
    myapp.services
    myapp.domain

Both tools share a design assumption worth stating: the rule is written once, in the same place as the code, reviewed like code, and versioned with it. A rule maintained in a separate wiki drifts from the code it describes.

Cycles are the other natural target. Any of the graph algorithms in Tarjan’s family can be wrapped in a test that fails when a new strongly connected component appears above a size threshold, which is more useful than failing on all cycles if a few are known and intentional.

Behavioural rules and their statistical problem

Structural rules are deterministic: the same source produces the same verdict. Behavioural rules are not. A check that the p99 latency of an endpoint stays under 300 ms depends on the machine, the parallel test load, the JIT state, and the dataset. Run the same commit twice and the number moves.

That does not make behavioural fitness functions useless; it changes what they must be. Three practices make them stable enough to gate a build:

Compare against a recorded baseline rather than an absolute threshold, and allow a percentage band. An absolute number inevitably fails on the developer laptop and passes on the build machine, or the reverse.

Take the median of several runs rather than a single sample. Latency distributions have long tails, and one sample from the tail is not a performance regression.

Fail on a trend rather than an instance. A rule that fires when the median of the last five runs exceeds the median of the last twenty by more than a set margin detects slow drift and ignores a single noisy day. This requires storing history, which is the same infrastructure a change coupling analysis needs.

The alternative, common in practice, is to move behavioural checks off the per-commit path entirely and onto a nightly or per-release run, where a slower job can afford repeated measurement. The trade is detection latency: a regression noticed at the next release instead of the next commit, in exchange for a build that does not cry wolf.

Budgets, baselines, and the ratchet

Strict rules fail on legacy code immediately, and a rule that fails on the first run either gets deleted or gets an exemption list that grows. The mechanism that avoids both outcomes is the ratchet: fix the current violation count as a budget, fail when the count increases, and lower the budget when it decreases.

Concretely, a check might assert that at most 12 files import the deprecated persistence module; a change that adds a thirteenth fails, and a change that removes two lets the budget drop to 10. Over a year the number goes to zero without a single heroic cleanup, because every change has to at least not make things worse.

The same shape works for metrics. Rather than demanding that every package satisfy D < 0.3, freeze the packages currently outside that bound and fail when a new package joins the set. The list becomes a to-do with a cost attached, and it cannot grow silently.

Two details keep the ratchet honest. The budget lives in the repository next to the rule, so a change to it is visible in review. And the failure message states the new count and the current budget, because a message that says only “architecture test failed” gets triaged as noise.

How the checks lose their meaning

Fitness functions fail in four recognisable ways.

Weak assertions. A rule that no class may depend on java.util.Date is easy to satisfy and says nothing about the architecture. Rules written to pass on the current code tend to encode the current code, which is circular; the value is in encoding the intended structure, which is usually not the current one.

False positives from legitimate edge cases. Generated code, test fixtures, and framework-required constructors all violate clean rules. The fix is a narrow exclusion for a specific package, not a broad one, and never a global disable.

Escape hatches with no expiry. Most rule tools allow an annotation or comment to exempt a class. Without a date or an issue reference attached, exemptions accumulate into the shape of the original problem, now invisible.

Runtime cost. A rule engine that scans bytecode for every class on every build adds minutes to a build that people run dozens of times a day; at that point the check gets moved to a nightly job and stops preventing the violation. Keeping the per-commit set under a minute is a design constraint on the rule set, not an afterthought.

Choosing the small set that earns its keep

The productive way to select rules is to invert the usual question. Instead of asking which architectural properties can be checked, ask which decisions would be violated silently and expensively if nobody were watching. A boundary that two teams maintain by convention and conversation does not need a check. A boundary that a new service, a new dependency, or a refactor with good intentions will quietly cross does.

That reasoning puts a handful of things at the top: the direction of dependency between the layers that carry policy and the layers that carry detail; the prohibition on one deployable reading another’s database; the set of modules allowed to depend on an external SDK, so that replacing it stays a bounded job; and the presence of a contract test at each service boundary. Each is narrow, each traces to a scenario someone wrote down, and each fails with a message that names a file and a decision.

The count matters less than the coverage of decisions. Ten weak rules that pass on any code teach a team that the architecture tests are decoration, and the eleventh rule, the one that matters, is treated the same way.

References

  1. ArchUnit User Guide — ArchUnit project
  2. Import Linter documentation — Import Linter project
  3. Building Evolutionary Architectures — Neal Ford, Rebecca Parsons, and Patrick Kua, O'Reilly Media