How do you stop architectural boundaries from eroding over time? What mechanisms actually enforce them, and how strong is each?
answer
- erosion is the default: shortcuts are locally cheaper
- docs → review → visibility → CI dep-rules → build modules → process+DB
- fitness functions with a shrinking baseline
- private schemas + per-module DB credentials
- systematic violations ⇒ wrong boundary, not lazy devs
basics
~20 sDocuments and good intentions don't hold. Enforce boundaries mechanically: language/module visibility so internals aren't importable, automated dependency-rule tests in CI that fail the build on violations, separate build modules, and — strongest and most costly — separate processes with separate databases.
solid answer
~60 sBoundary erosion is the default outcome, because every shortcut across a line is locally cheaper than respecting it. Enforcement forms a ladder of increasing strength and cost: (1) convention and documentation — zero cost, near-zero strength; (2) code review — catches some, depends on reviewer attention, doesn't scale; (3) language/module visibility (private, internal, package-private, module exports) — good, but weak where the boundary is coarser than the language's unit; (4) automated dependency-rule tests / fitness functions run in CI (architecture-test libraries, dependency linters, module frameworks that verify allowed dependencies and detect cycles) — the sweet spot: cheap, deterministic, blocks merges; (5) separate build artifacts, so a forbidden type is literally not on the classpath; (6) separate processes with private datastores — strongest, most expensive, and the only one that survives a determined shortcut. Complement these with contract tests, ownership (CODEOWNERS, team alignment per Conway's law), architecture decision records so rules have rationale, and a deliberate exception process with recorded, time-bounded, visible waivers rather than silent drift. Measure erosion (cross-boundary co-change, cycle count, violation counts trending) and fix the boundary when violations are systematic — persistent violations are usually evidence the line is wrong, not that people are careless.
code
pseudocode · 13 lines// A dependency-rule fitness function, run in CI like any test
test "domain must not depend on infrastructure" {
noClasses().thatResideIn("..domain..")
.should().dependOnClassesThat().resideIn("..infrastructure..")
.because("the domain must stay storage- and framework-agnostic")
}
test "vendor SDK is confined to the anti-corruption layer" {
noClasses().thatResideOutsideOf("..adapters.vendor..")
.should().dependOnClassesThat().resideIn("com.vendor..")
}
test "no package cycles" { slices().matching("..(*)..").should().beFreeOfCycles() }go deeper
Say that rules in a document don't hold; use language visibility (private/internal) and automated checks in CI so violations fail the build.
Lay out the ladder from convention to separate deployables, and name concrete rules: dependency-rule tests, cycle checks, module visibility, code ownership.
Add baselines and ratcheting, contract and schema-compatibility tests, per-module database credentials, and an explicit waiver process; discuss error-message quality and making the sanctioned path cheap.
Frame enforcement as a cost/strength portfolio chosen per boundary, tie it to ownership and Conway's law, instrument drift with metrics, and treat systematic violations as evidence to re-cut the boundary rather than to tighten the screws.
## Why boundaries erode Every violation is locally rational: reaching directly into another module's class or table is faster *today* than extending the contract, negotiating with its owner, and waiting a release. The cost is deferred and shared; the benefit is immediate and personal. Absent a mechanism, entropy wins. Add schedule pressure, staff turnover (nobody remembers *why* the rule exists), and 'temporary' exceptions, and a clean design degrades into a big ball of mud within a couple of years. ## The enforcement ladder Ordered by strength; cost generally rises with strength. **1. Documentation and convention.** A diagram, a wiki page, a README rule. Cost ~0. Strength ~0 by itself — but necessary as the *rationale* layer so mechanical rules aren't cargo-culted. **2. Code review.** Humans catch violations. Real but unreliable: depends on the reviewer knowing the rule, seeing the import, and being willing to push back under deadline. Doesn't scale across teams or time. **3. Language/platform visibility.** `private`, package-private, Kotlin `internal`, JPMS `exports`, Go's internal/ directories and lowercase identifiers, Rust `pub(crate)`, TypeScript package `exports` maps and index barrels. Cheap, compiler-enforced, and always your first tool. Limitation: language units rarely match architectural units — you often want 'this whole package tree is internal to the module', which most languages express poorly. **4. Automated dependency rules / fitness functions.** Executable checks that run in CI and fail the build: - 'no class in `domain` may depend on `infrastructure`' - 'only `adapters.vendor` may import `com.vendor.*`' - 'no package cycles' - 'module X may depend only on the declared list' - layered-architecture and onion/hexagonal rules Implemented with architecture-test libraries (ArchUnit-style tests on the JVM, dependency-cruiser or eslint import rules in JS, import-linter in Python, depguard in Go) or module frameworks that verify a declared allowed-dependency list. This is the best strength-per-cost rung: deterministic, versioned with the code, blocks merge, and self-documenting (the test *is* the rule). Introduce with a **baseline** of existing violations so you can ratchet down instead of blocking all work; the key discipline is that the baseline may only shrink. **5. Separate build modules/artifacts.** If module A's internals aren't on B's compile classpath, the violation is not expressible. Stronger than a test (no way to 'temporarily' bypass), at the cost of build complexity and slower refactoring across modules. **6. Separate processes with private datastores.** The physical wall. Nothing can import what it can't link, and no one can query a database they have no credentials for. Strongest, and by far the most expensive — you now pay all the distribution costs discussed elsewhere. Note that this only works if the *data* is private too: separate services on a shared schema are not enforced at all. ## Beyond dependency rules - **Contract tests** (consumer-driven or provider-published) prevent a boundary from breaking behaviourally even when the dependency graph stays clean. Compile-time rules can't catch 'we changed the semantics of `status`'. - **Schema/API compatibility gates** — automated backward-compatibility checks on published API/event schemas, plus explicit versioning and deprecation windows. - **Data-access enforcement** — per-module database schemas and per-module credentials with grants only on their own tables; this catches the sneakiest violation, cross-module SQL, which no code-level rule sees. - **Ownership** — CODEOWNERS on the contract files/directories so a boundary change requires the owner's approval. Aligning module ownership with teams (Conway's law) makes the social cost of violating match the architectural cost. - **ADRs (architecture decision records)** — short, dated records of what was decided and *why*, so future maintainers can distinguish a load-bearing rule from an accident. Rules without rationale get deleted the first time they're inconvenient. - **Visibility/metrics** — track violation counts, package cycles, cross-boundary co-change, and contract churn over time. Trends detect drift before it's a rewrite. ## Exceptions without drift A rigid rule with no escape hatch gets bypassed wholesale. Make exceptions explicit instead: a suppression/waiver that is (a) in code, (b) attributed, (c) justified in a comment, (d) counted, and ideally (e) expiring. A shrinking waiver list is a healthy architecture; a growing one is an early warning. ## When violations mean the *boundary* is wrong The most important judgment call. If violations are sporadic and diverse, enforce harder. If a *specific* rule is violated constantly by many people for the same reason, the design is fighting reality: the boundary probably splits a cohesive concern, or the contract is missing an operation everyone needs. The response is to fix the design — move the line, merge the modules, or extend the contract — not to escalate enforcement. Persistent enforcement pain is a diagnostic signal, not just a discipline problem. ## Organizational reinforcement - **Team alignment**: a boundary with no owning team decays. - **Onboarding**: new engineers should meet the rules through failing builds with good messages, not through folklore. - **Error messages matter**: a dependency-rule failure should say which rule, why it exists, and what the sanctioned alternative is. A rule you can't understand from its failure gets suppressed. - **Make the right thing easy**: if extending the contract takes three days of cross-team negotiation and the shortcut takes ten minutes, no amount of tooling will win. Reducing the cost of legitimate crossings is itself an enforcement strategy.
- You introduce dependency-rule tests into a legacy codebase and get 800 violations. What now?Record a baseline of the existing violations so the build goes green, then enforce that the baseline may only shrink — new violations fail immediately, old ones are burned down deliberately. Prioritize the rules protecting the boundaries you most want to keep movable, and never regenerate the baseline to make a failure go away.
- One rule is violated constantly by many different engineers. Is that an enforcement problem?Usually not. Systematic, repeated violation of the same rule for the same reason means the boundary splits something cohesive or the contract lacks an operation everyone needs. Fix the design — move the line, merge, or extend the contract — instead of escalating enforcement.
- Which boundary violation do code-level dependency rules typically miss entirely?Direct database access to another module's tables. No import exists, so static analysis sees nothing. Defend with per-module schemas and per-module credentials granted only on their own objects, plus review of any shared connection.
A 'staff only' sign, a lock, and a separate building. The sign costs nothing and stops the polite; the lock stops most people and needs key management; the separate building stops everyone and needs its own roads and utilities. And if staff keep propping the door open every single day, the answer isn't a bigger lock — the door is in the wrong place.