50%

الدرس 2 من 4

Quality Attribute Scenarios and Fitness Functions

Turning "it should be maintainable" into something a build can check. This is how architecture stops degrading the moment you look away.

قراءة 10 دقيقة

An architecture decays not through a single bad decision but through a hundred small ones, each locally reasonable, none of which anyone noticed. The defence is not vigilance — vigilance fails under deadline. The defence is automation: express the architectural property as something a machine checks on every commit.

Step one: write the scenario

A quality attribute scenario has six parts. The structure matters because each part is somewhere a vague requirement usually hides: without a stimulus you cannot test it, without an environment you do not know whether it applies under load, and without a response measure it is an opinion.

PartExample
SourceA user of the mobile application
StimulusSubmits a booking search
ArtefactThe availability search service
EnvironmentNormal operation, 500 concurrent searches
ResponseReturns matching availability
Response measureWithin 300ms at p99, measured at the API gateway
The same requirement, before and after being written as a scenario.

Step two: make it executable

A fitness function is any automated check that a system still exhibits an architectural characteristic. The term sounds exotic; most fitness functions are ten lines long and run in the same pipeline as the unit tests. Their value comes from running on every commit, not from sophistication.

A modifiability fitness function: the domain layer must not depend on infrastructure.typescript
// Runs in CI alongside the tests. Fails the build, not a report nobody reads.
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { globSync } from 'node:fs';

const FORBIDDEN = [/from '@prisma/, /from 'next/, /from 'react/, /@\/server\//];

describe('architecture: domain purity', () => {
  it('domain layers import no framework or infrastructure', () => {
    const violations = globSync('src/features/*/domain/**/*.ts')
      .filter((file) => !file.endsWith('.test.ts'))
      .flatMap((file) => {
        const source = readFileSync(file, 'utf8');
        return FORBIDDEN.filter((pattern) => pattern.test(source))
          .map((pattern) => `${file} imports ${pattern.source}`);
      });

    // The message names the file, so a failure is actionable without digging.
    expect(violations).toEqual([]);
  });
});

This platform runs exactly this class of check: `npm run boundaries` uses dependency-cruiser to fail the build if a domain layer imports React, Prisma or another slice. The rule is not a paragraph in a wiki that a new engineer may or may not read — it is a build failure with the offending import named, five seconds after the mistake.

Two ways to protect an architectural property

Documented and reviewed

The rule lives in a document; code review is expected to catch violations.

  • Costs nothing to set up
  • Handles nuance a mechanical check cannot
  • Depends on reviewer attention, which is scarcest near a deadline
  • New joiners violate it before they have read the document
  • Erodes invisibly — nobody can tell you how many violations exist

Choose when: The property genuinely requires judgement, such as "aggregate boundaries should follow business invariants".

Executable fitness function

The rule is a check in CI that fails the build.

  • Cannot be forgotten, skipped or deprioritised
  • Teaches the rule at the moment it is broken
  • Gives you a live count rather than a guess
  • Survives every change of personnel
  • Some effort to write, and it needs maintaining
  • A badly-scoped check produces false failures and gets disabled

Choose when: The property can be expressed mechanically — layering, dependency direction, bundle size, p99 latency, public API compatibility.

  • Layering: no import from domain into infrastructure. Checked by a dependency rule.
  • Performance: p99 latency under 300ms at 500 concurrent users. Checked by a load test in the pipeline.
  • Security: no endpoint reachable without passing through the authorisation helper. Checked by a route-table assertion.
  • Compatibility: the public API schema is backwards-compatible with the last release. Checked by a schema diff.
  • Coupling: no service reads another service's tables. Checked by database grants, which is stronger than any test.
  • Bundle size: the first-load JavaScript budget for the marketing page. Checked by a size assertion on the build output.

In practice

Catching decay before anyone felt it

A team maintaining a modular monolith agreed that feature modules would communicate only through published interfaces, never by importing each other's internals. Everyone agreed. Nine months later, a routine review found 34 direct internal imports.

Constraints

  • Seven engineers, three of whom joined after the rule was agreed
  • Rule documented in the team wiki
  • No mechanical enforcement
  • Delivery pressure through two quarters

Decision

Add a dependency-cruiser rule to CI, fix the 34 violations over two sprints, and keep the rule permanently.

Why

Nobody had violated the rule deliberately. Two of the three new engineers had never read the wiki page, and the others had made locally reasonable choices at 6pm before a release. Documentation had failed in exactly the way documentation fails: silently, over months, with no signal.

What it cost

Two sprints of unplanned remediation, and some genuine friction where the rule was too strict and needed narrowing twice in the first month. The cost of the same drift discovered two years later — by which time the modules could not be separated at all — would have been a rewrite. The rule has failed the build 60 times since, each one a violation caught in five seconds instead of nine months.

Which architectural properties of a system you work on could be checked mechanically today, and are not?Reveal

Almost every codebase has three or four available cheaply: dependency direction between layers, that no route bypasses the authorisation helper, that no module imports another module's internals, and that the production bundle has not silently doubled. The reason they are not checked is rarely difficulty — it is that nobody has framed the architectural rule as a testable assertion. That reframing is the skill; the code is usually ten lines.

Key takeaways

  • Architecture decays through many small locally-reasonable decisions, not one bad one.
  • A quality attribute scenario has six parts; the environment and response measure are where vague requirements hide.
  • A fitness function is any automated check that an architectural property still holds.
  • Enforced rules survive deadlines and personnel changes; documented rules do not.
  • Start with narrow checks that always hold — a check that cries wolf will be deleted.