skip to content

How would you enforce architectural structure rules — for example 'the domain layer must not depend on the persistence layer' and 'there are no cyclic dependencies between modules' — as automated checks in a build, and what are the practical pitfalls of doing so?

level: middleimportance: must knowfreq 50%

answer

  1. static graph of imports/references, assert predicates
  2. layers, cycles, encapsulation, naming, forbidden deps
  3. ArchUnit / NetArchTest / dependency-cruiser / import-linter
  4. freeze baseline + ratchet down, never up
  5. blind to reflection and DI wiring

basics

~20 s

Add a test that reads the code's dependency graph — from source, bytecode, or import statements — and asserts rules like 'domain must not reference persistence' and 'no cycles'. Run it in the build so violations fail like any other test.

solid answer

~50 s

You encode structure rules as executable tests that analyse the dependency graph rather than run the program. Tooling exists for every ecosystem — ArchUnit (JVM), NetArchTest (.NET), dependency-cruiser or eslint import rules (JavaScript/TypeScript), import-linter (Python), go-arch-lint (Go), plus module frameworks such as Spring Modulith that verify declared allowed dependencies. All work the same way: build a graph of types/files/modules and their references, then assert predicates — layer access rules, naming conventions, 'no cycles among packages', 'only the API package is public', 'no framework annotation outside the adapter layer'. They run in the normal test phase, are deterministic and fast because they need no running system, and they are the archetypal atomic triggered fitness function. Pitfalls: retrofitting onto an eroded codebase produces hundreds of failures, so you need a frozen baseline/allow-list that can only shrink; rules stated too broadly block legitimate change; reflection, dependency injection, and dynamic imports are invisible to static analysis; and rules must be written against stable concepts (module boundaries) rather than volatile package names.

code

pseudocode · 12 lines
pseudocode
// Runs in the normal test phase — no app started.
archTest("domain is persistence-free") {
  noClassesIn("app.domain..")
    .shouldDependOn("app.persistence..", "org.orm..")
}

archTest("modules are acyclic") {
  slicesOf("app.(*)..").should().beFreeOfCycles()
}

// Retrofit safely: fail only on NEW violations.
freeze(archTest("modules are acyclic"))  // baseline may shrink, never grow

go deeper

for a junior

Say you write a test that inspects the code's dependencies and asserts rules like 'domain must not import persistence' and 'no cycles', name one tool from your ecosystem, and note it runs in the build.

for a middle

Add the rule families (layers, cycles, encapsulation, naming, forbidden libraries), explain that analysis is static and fast, and mention the baseline approach for existing violations.

for a senior

Cover retrofit strategy with freezing and ratcheting, blind spots around reflection and DI, rule stability versus package renames, suppression governance, and the pipeline placement as a fast atomic triggered gate.

for a principal

Frame it as governance-as-code: each rule traceable to an architecture decision record, ownership and review cadence defined, suppression counts tracked as a metric, and the ruleset treated as the enabler for future modularisation and team-boundary changes.

## What these tests actually do A structural fitness function does **static analysis**: it builds a graph whose nodes are compilation units (classes, files, modules, packages) and whose edges are references (imports, inheritance, field/parameter types, calls), then evaluates assertions over that graph. Nothing is executed, so results are deterministic and fast — typically seconds even on large codebases. Typical rule families: 1. **Layer/access rules** — 'classes in `domain` may be accessed only by `application` and `domain`'; 'nothing outside `adapter.web` may import the HTTP framework'. 2. **Acyclicity** — no dependency cycles between packages or modules. Cycles are the single strongest predictor of a codebase that cannot be modularised later. 3. **Encapsulation** — only types in a module's `api` package may be public/exported; internals stay internal. 4. **Naming and shape conventions** — every class named `*Controller` lives in the web adapter; every `*Repository` is an interface. 5. **Forbidden dependencies** — no usage of a deprecated library, no `java.util.Date`, no direct SDK calls outside the infrastructure layer. 6. **Metric thresholds** — afferent/efferent coupling, instability, or per-module fan-out below a limit. ## Tooling landscape (language-agnostic idea, many implementations) - **JVM**: ArchUnit — a plain test library with a fluent rule DSL over bytecode; Spring Modulith verifies each module's declared `allowedDependencies`. - **.NET**: NetArchTest. - **JavaScript/TypeScript**: dependency-cruiser, `eslint-plugin-import` with boundary rules, Nx module boundaries. - **Python**: import-linter (contracts: layers, forbidden, independence). - **Go**: go-arch-lint, `depguard`. - **Any**: a custom script over the import graph, or a build-system module graph with explicit visibility rules. The DSL varies; the concept does not. ## Where they run In the normal test phase of the build, so a violation is an ordinary red build with a stack-trace-quality message naming the offending class and the rule. Because they need no database, network, or browser, they are ideal for the *fastest* pipeline stage and for pre-commit/local gates. In the fitness-function taxonomy they are **atomic** (one characteristic: modularity) and **triggered** (by commit or PR). ## Pitfalls **1. Retrofitting onto an eroded codebase.** Turning on 'no cycles' in a five-year-old system yields hundreds of failures. The workable path is a **frozen baseline** (or allow-list) capturing today's violations, a rule that fails only on *new* violations, and a ratchet: the baseline may shrink, never grow. Tools such as ArchUnit's `FreezingArchRule` and dependency-cruiser's known-violations file support this directly. **2. Rules too broad or too narrow.** 'No class may depend on more than five others' sounds tidy and blocks legitimate composition roots. Rules should encode *decisions the team actually made* and be justified in an architecture decision record; otherwise engineers learn to see them as arbitrary and route around them. **3. Static analysis blind spots.** Reflection, runtime dependency injection, service locators, dynamic `import()`, code generation, and configuration-driven wiring create real dependencies that no static graph sees. Structural tests give *necessary* not *sufficient* confidence; complement them with runtime checks (module frameworks that verify wiring at startup, or integration tests). **4. Coupling rules to volatile names.** Rules matching on package/path strings break on every rename. Prefer stable anchors: module descriptors, marker annotations/attributes, or explicit metadata files that name the layer. **5. Bypass culture.** If a rule can be silenced with a one-line suppression and no review, it will be. Require suppressions to carry a reason and be visible in review; watch their count as its own metric. **6. Performance on huge repos.** Scanning the whole classpath repeatedly can dominate build time. Limit the analysed scope to your own artifacts and cache the imported graph. **7. Rules without owners.** When a rule fails and nobody can explain why it exists, it gets deleted. Every rule should carry a human-readable description and a link to the decision it enforces — the rule text *is* the documentation, which is the main reason this technique beats a wiki page. ## Why this is high-value Structure is the characteristic most prone to silent erosion and the cheapest to verify. Encoding layering and acyclicity converts 'we are a clean hexagonal architecture' from a claim into a property that is true on every commit, which in turn is what makes later incremental change — extracting a module, splitting a service — feasible instead of archaeological.

  • How do you introduce a strict dependency rule into a legacy codebase that already violates it hundreds of times?
    Freeze a baseline of current violations and configure the rule to fail only on new ones, then ratchet: the baseline file is allowed to shrink but never grow, and reducing it becomes ordinary refactoring work. This gives immediate protection against further erosion without a big-bang cleanup.
  • What kinds of dependencies do these static tests miss?
    Anything resolved at runtime: reflection, dependency injection by name, service locators, dynamic imports, generated code, and configuration-driven wiring. Complement static rules with runtime verification — module frameworks that validate the wiring graph at startup, or integration tests that fail when a forbidden component is actually reachable.
  • Why not just enforce these rules in code review instead?
    Review is inconsistent, depends on who is available, scales poorly, and loses knowledge when people leave. An executable rule enforces uniformly on every commit, documents itself with a failure message, and frees reviewers to discuss design intent rather than policing imports.

saying these in an interview costs you the question

  • Believing static dependency tests catch reflection, name-based DI, or dynamic imports
  • Enabling strict rules on a legacy codebase with no baseline, then deleting the rules when the build stays red
  • Writing rules against volatile package-name strings so every rename breaks the suite
  • Allowing silent, unreviewed suppressions of architecture rules
  • Treating these tests as a substitute for design — they enforce decisions, they do not make them
  • Assuming this is a JVM-only technique; equivalents exist for .NET, TypeScript, Python, and Go

context