Index

Enforce Architecture at the Cheapest Reliable Layer

The rule says Domain must not depend on Infrastructure.

Code review misses this reference:

final class Order extends \Illuminate\Database\Eloquent\Model
{
}

A quick substring scan catches it. The same scan also rejects a harmless docblock which names EloquentOrderRepository while explaining what Domain must not do.

The first check is unreliable because a person must notice every occurrence. The second is unreliable because text is not a PHP dependency. A token-aware source check catches the executable reference and ignores the prose.

Then the service container binds the right application port to the wrong adapter. The source graph remains legal, so the architecture check passes. A composition test fails.

An architecture rule belongs at the cheapest layer which can observe its violation without unacceptable false results. The layer changes with the property.

Begin with one owned rule

The synthetic SVC-04 fixture uses a small PHP application:

Delivery       -> Application, framework
Infrastructure -> Application, Domain, framework
Application    -> Domain
Domain         -> PHP

Three rules have stable IDs, owners, scopes, and repair directions:

  • ARCH-DEP-01 keeps Domain independent of Application, Infrastructure, Delivery, Laravel, and Eloquent;
  • ARCH-DEP-02 lets Application use Domain but not Infrastructure, Delivery, Laravel, or Eloquent; and
  • ARCH-COMP-01 binds the application-owned OrderRepository port to the declared Eloquent adapter.

The first two describe source direction. The third describes runtime selection. Giving all three an ARCH prefix does not make them observable by one tool.

This is where architecture tests should start: not with a fashionable preset, but with a decision somebody owns. The rule needs an included namespace, a forbidden target, an explicit exception, and a reason to exist. “All classes must be final” is not an architecture decision unless the system has actually chosen and justified that constraint.

PHP tokens separate code from commentary

The fixture’s source check uses PHP’s token_get_all with TOKEN_PARSE. The PHP manual says the function uses the engine’s lexical scanner and returns identifiers for the language tokens it sees.

That distinction is enough for the declared rules. The check inspects qualified names in owned Domain and Application paths. It catches:

  • imports and aliased imports;
  • fully qualified class references;
  • inheritance and interface implementation;
  • trait use; and
  • attributes.

Comments, docblocks, and string literals arrive as different tokens and do not become dependencies. A documentation sentence can name Infrastructure without making Domain execute it.

The check also ignores one exact generated/ path. It does not ignore every file containing Generated, every proxy namespace, or the entire Domain layer. A narrow exception is visible debt. A broad exclusion is an architecture policy with the interesting parts removed.

Pest’s current architecture-testing documentation offers namespace, usage, inheritance, trait, and explicit ignoring mechanics. Deptrac’s documentation goes further with owned layers, rule sets, violations, exceptions, and uncovered dependencies. Either may be a better maintained choice in a real project. The fixture uses the lower-level tokenizer so its exact observation boundary remains inspectable without assuming a Pest version or adding a package.

A useful failure points toward repair

Seed an Infrastructure import in Domain and the fixture exits non-zero with:

ARCH-DEP-01 violation:
src/Domain/Order.php references App\Infrastructure\EloquentOrderRepository;
repair: move the dependency behind a Domain-owned contract

The message names the rule, source, target, and repair direction. A developer can disagree with the rule or fix the dependency without first reverse engineering a generic architecture test failed message.

The same contract catches a fully qualified Eloquent base class, an Infrastructure base class, a Delivery interface, an Infrastructure trait, and a framework attribute. Application receives its own message and repair: depend on an Application-owned port, then bind it at composition.

Legal outward dependencies remain legal. Delivery can extend a framework controller. Infrastructure can use a database connection. Architecture rules which reject adapters for adapting have confused inward policy with universal framework abstinence.

Cheap execution can still be expensive enforcement

The reproduction retains two deliberately naive scans.

An import-only regular expression is quick and quiet. It misses a fully qualified reference because no use statement exists. A raw substring scan finds that reference but rejects the same name in a harmless string.

Both commands are cheaper to run than a parser. Neither is cheaper to operate. One allows the forbidden dependency. The other creates false alarms which teach developers to add exclusions or ignore the check.

“Cheapest” therefore includes:

  • whether the observer can see the defect;
  • whether legal code stays green;
  • how much source and configuration it scans;
  • whether its failure explains the repair; and
  • which properties still require another check.

Runtime alone is not a useful comparison. A ten-millisecond check which cannot observe the violation costs ten milliseconds and leaves the rule unenforced.

The fixture changes the composition map from EloquentOrderRepository to NullOrderRepository.

Both dependency rules stay green. Application still owns the port and Infrastructure still supplies an adapter. Static direction cannot decide which adapter the running application selected.

The focused composition check fails instead:

ARCH-COMP-01 violation:
App\Application\OrderRepository expected
App\Infrastructure\EloquentOrderRepository,
selected App\Infrastructure\NullOrderRepository;
repair: bind the application port to the declared infrastructure adapter

Laravel’s service-container documentation makes this runtime concern explicit: service providers register bindings, and the container can map an interface to a chosen implementation. A source dependency test can validate the direction of that interface. It cannot prove the binding loaded for this environment.

The narrow integration test is cheaper than booting an end-to-end request and more reliable than reading the provider by eye. It asks the container one composition question and reports the actual selection.

Each property needs an observer which can see it

The final matrix is short because it refuses to make architecture testing mean everything:

PropertyCheapest reliable observerDeclared limitation
Source dependency directionTokenizer architecture testRuntime selection
Container compositionFocused integration testBusiness behavior
Accepted business behaviorBehavior testProduction conditions
Production resilienceOperational signalSource direction

An architecture test can prove that Domain source does not name Eloquent under the syntax and paths its parser covers. It cannot prove that saving an order is transactional, that a queue retries safely, that a query meets its latency budget, or that production fails over correctly.

A dynamic class name assembled from configuration creates the same boundary. The tokenizer cannot infer the resulting runtime target. The fixture records that limitation and routes the property to composition. Calling it statically verified would make the report more impressive and less true.

Behavior and operational checks work in the opposite direction. A passing request may show that one configured path works. It does not prove that Domain source stayed independent. A production metric can reveal saturation. It does not identify the forbidden import which made a later extraction harder.

The layers complement one another because they observe different failures.

Enforcement should retire with the decision

The rule inventory includes reversal conditions. A source rule changes when the architecture changes, the owned scan moves, supported syntax outgrows the analyzer, or false results show that the observer is no longer reliable.

That is not permission to delete a failing test until the build turns green. It is a requirement to keep policy and enforcement in the same conversation. A test which survives after its decision disappears becomes ceremony. A decision which survives after its test stops observing reality becomes wishful documentation.

Review remains useful for the parts which resist a reliable binary check: whether a boundary is still valuable, whether an exception is justified, and whether the repair cost exceeds the risk. Automation preserves the narrower decision between those reviews.

Make one violation executable

The synthetic reproduction retains three rules, 31 passing tests, four verified primary sources, legal framework and generated-code controls, a wrong-binding integration failure, an observer matrix, and 18 isolated counterfactuals. The counterfactuals hide supported syntax, naive-scan errors, broad exclusions, legal entry points, runtime composition, dynamic limits, actionable output, or replace the matrix with one universal layer and a score. All eighteen fail.

The clean export reproduces from commit abdc3bbb5c01aef14dc185c24068e4ab231c8791. It proves the declared fixture, analyzer, CI, and failure controls. It does not prove every PHP syntax form, framework configuration, runtime behavior, or production property in Brian’s systems.

Take one architecture sentence from the next review and finish four fields:

rule:
owned scope and exception:
representative violation:
cheapest observer which reliably fails:

If the violation cannot be made to fail without rejecting legal code, keep the decision in review and say why. If it can, make the failure readable and put it in CI. The architecture document can then spend its words on the decision, instead of hoping somebody remembers to police the syntax.