How do you keep an architecture from eroding: dependency rules as executable checks, and what belongs in a decision record?

15.9.3 · L5 · Interview

Predict first

A layer rule and a no-sibling-feature rule run over six files:

let layers = ["Domain": 0, "Data": 1, "Feature": 2, "App": 3]       // a layer may import only lower layers
let sources: [(file: String, layer: String, imports: [String])] = [
    ("Money.swift", "Domain", ["Foundation"]),
    ("Repo.swift", "Data", ["Domain", "Foundation"]),
    ("Cart.swift", "Feature", ["Domain", "Data"]),
    ("Search.swift", "Feature", ["Cart"]),                          // feature imports a sibling feature
    ("Tax.swift", "Domain", ["Data"]),                              // domain reaches up into data
    ("Main.swift", "App", ["Feature", "Search"]),
]
let features = ["Cart", "Search"]
var violations: [String] = []
for s in sources {
    for i in s.imports {
        if let to = layers[i], let from = layers[s.layer], to > from { violations.append("\(s.file): \(s.layer) imports upward (\(i))") }
        if features.contains(i), s.layer == "Feature" { violations.append("\(s.file): feature imports another feature (\(i))") }
    }
}
print(violations.isEmpty ? "clean" : violations.joined(separator: "\n"))
print("violations:", violations.count)
  • Search.swift: feature imports another feature (Cart) / Tax.swift: Domain imports upward (Data) / violations: 2
  • clean / violations: 0
  • violations: 3

Mental model. A building code is enforced by inspectors at the gate, not by a poster saying “please build safely”. Exceptions are written permits with an expiry.

Diagrams and wiki pages do not stop a Friday-evening import. An architecture survives year three when its rules are executable and fail the build. State the rules as data: layers with an order (Domain below Data below Feature below App), “a layer imports only lower layers”, “a feature never imports another feature”. A script reads each source file’s import lines (or, better, the package manifest’s target dependencies and the compiler’s module graph) and reports violations. The check runs the rule over six sample files and reports exactly two: Tax.swift in the Domain layer importing Data (upward), and Search.swift importing the sibling feature Cart. In real projects the strongest enforcement is structural, not textual: separate SwiftPM targets whose declared dependencies are the only imports that compile (an undeclared one is a build error, and a cycle is rejected), plus internal import and member import visibility so nothing leaks; a script or SwiftLint custom rule covers what targets cannot, such as “no UIKit in a view model file” or “no singleton access in the Domain layer”.

Make the rules reviewable and changeable. Keep an architecture decision record (ADR) per significant choice: context, the decision, alternatives considered, consequences and the date, in the repository next to the code, so a newcomer learns why Coordinators exist before removing them, and a later change supersedes the record instead of rewriting history. Allow explicit exceptions as a list in the rule file with an owner and a reason, so violations are visible debt rather than silent drift; add a test that the exception list only shrinks. Run the check in CI and locally in pre-commit for fast feedback, report the dependency graph as a build artefact, and track a few numbers (cycles, number of exceptions, module fan-in). The rules should describe the architecture you actually have: start with the rules the code already obeys and ratchet, rather than failing a hundred existing violations on day one. See Q356 for module boundaries and Q368 for why the graph must stay acyclic.

The script finds the upward import and the sibling-feature import; it is a sketch of a CI rule.

Common traps

  • Rules that exist only in a document. Without a failing check they erode with every deadline.
  • Turning on a rule that fails on hundreds of existing files. Record the current violations as exceptions and ratchet them down.

They might follow up with

  • Targets or scripts? Targets enforce dependencies by construction; scripts cover conventions inside a target.
  • What goes in an ADR? Context, decision, alternatives, consequences, status and date.

Related questions

  • 15.9.2 What do internal imports and MemberImportVisibility change, and how do you stop a dependency leaking through…
  • 15.3.1 What is a feature module, how do you draw module boundaries, and how does access control enforce them?
  • 15.6.2 How do you run a shared mobile platform for many apps and teams: layering, ownership, versioning and…
  • 16.3.2 Why must the module dependency graph be acyclic, how do you layer it, and what does its shape do to build…

← What do internal imports and MemberImportVisibility change, and how…