"We had 200 modules. The architecture diagram said they were layered. git log said they were spaghetti."
Every codebase starts clean.
Day one: a beautiful layered architecture. Core modules at the bottom. Business logic in the middle. API handlers on top. UI at the surface. Clean arrows pointing downward. The architect's diagram matches reality. Everyone is proud.
Day 365: a developer adds a "quick" import from the UI layer directly to a database utility. It's faster than routing through the service layer. Code review is light — the feature is urgent. Nobody notices.
Day 730: three more layer violations have crept in. The core module now imports a service-layer helper "just for this one config thing." An API handler directly queries the database to avoid a "slow" service call. The UI component imports a shared utility that itself imports a core module — creating a 4-layer skip.
Day 1095: the architecture diagram on Confluence bears zero resemblance to the actual codebase. New engineers look at the diagram, look at the code, shrug, and add whatever imports feel convenient. The layered architecture is now a decorative fiction.
This is abstraction rot — the gradual dissolution of intentional architectural boundaries through accumulated small violations. And it's universal. It happens in microservice systems, monoliths, Terraform modules, database schemas, package managers, and API gateways.
The root cause is simple: nobody validates the architecture itself. We lint code syntax. We test business logic. We enforce code style. But we almost never enforce that the dependency relationships between modules satisfy the structural invariants we claim they should.
What if you could lint your architecture?
The core idea: treat your architecture diagram not as documentation, but as a test suite. Define the layer rules as code. Run them in CI. Fail the build if any module violates its dependency contract.
This requires two things:
Let's build both.
First, you need a source of truth for your module dependencies. The simplest version is a JSON manifest that lives in your repository:
{
"auth-core": {
"layer": "core",
"depends_on": []
},
"crypto-utils": {
"layer": "core",
"depends_on": []
},
"user-service": {
"layer": "service",
"depends_on": ["auth-core", "crypto-utils"]
},
"billing-service": {
"layer": "service",
"depends_on": ["auth-core", "payments-api"]
},
"payments-api": {
"layer": "api",
"depends_on": ["billing-service"]
},
"admin-dashboard": {
"layer": "ui",
"depends_on": ["payments-api", "user-service"]
}
}
This is versionable, reviewable in PRs, and parseable by a validation script. Every time someone adds a new dependency, it must be declared here — and the declaration is validated.
Here's the validation script. It enforces four categories of rules:
import json
import sys
def validate_architecture(graph):
"""
Validates that every module in a layered architecture obeys
its dependency contracts.
Layer rules enforced:
- 'core' modules have zero dependencies and must be used
- 'service' modules must depend on at least one core or service
- 'api' modules must depend on a service (no skipping to core)
- 'ui' modules must depend on an API (no direct service imports)
This is the architectural equivalent of a type system —
but for dependency relationships instead of data types.
"""
all_ids = set(graph.keys())
# Build reverse lookup: "who depends on me?"
dependents = {node_id: [] for node_id in graph}
for node_id, data in graph.items():
for dep in data["depends_on"]:
if dep in dependents:
dependents[dep].append(node_id)
errors = []
for node_id, data in graph.items():
layer = data["layer"]
# --- Rule 1: No dangling references ---
for dep in data["depends_on"]:
if dep not in all_ids:
errors.append(
f"DANGLING REF: '{node_id}' depends on "
f"'{dep}' which doesn't exist."
)
# --- Rule 2: Core modules must be self-contained ---
if layer == "core":
if data["depends_on"]:
errors.append(
f"CORE VIOLATION: '{node_id}' has dependencies "
f"{data['depends_on']}. Core modules must be "
f"dependency-free foundations."
)
# Core modules must be used by at least one other module
if not dependents[node_id]:
errors.append(
f"DEAD CODE: Core module '{node_id}' is not "
f"used by any other module. Delete it or wire it in."
)
# --- Rule 3: Services must be grounded in core ---
elif layer == "service":
valid_parents = [
p for p in data["depends_on"]
if p in graph and graph[p]["layer"] in ("core", "service")
]
if not valid_parents:
errors.append(
f"UNGROUNDED: Service '{node_id}' doesn't depend "
f"on any core module. It's floating without a foundation."
)
# --- Rule 4: APIs must go through services ---
elif layer == "api":
valid_parents = [
p for p in data["depends_on"]
if p in graph and graph[p]["layer"] in ("service", "api")
]
if not valid_parents:
errors.append(
f"LAYER SKIP: API '{node_id}' bypasses the service "
f"layer. APIs must depend on services, not raw core."
)
# --- Rule 5: UI must only talk to APIs ---
elif layer == "ui":
valid_parents = [
p for p in data["depends_on"]
if p in graph and graph[p]["layer"] == "api"
]
if not valid_parents:
errors.append(
f"LAYER SKIP: UI module '{node_id}' doesn't go "
f"through an API. Direct service/core imports "
f"from UI are forbidden."
)
return errors
# --- Main execution ---
def main():
with open("architecture.json", "r") as f:
graph = json.load(f)
errors = validate_architecture(graph)
if errors:
print("❌ ARCHITECTURE VALIDATION FAILED:\n")
for e in errors:
print(f" • {e}")
print(f"\n {len(errors)} violation(s) found.")
sys.exit(1)
else:
print("✅ All architectural layer contracts verified.")
sys.exit(0)
if __name__ == "__main__":
main()
Let's see what happens when we run this against a graph with violations.
Here's a graph with three common violations mixed in:
{
"auth-core": { "layer": "core", "depends_on": [] },
"dead-util": { "layer": "core", "depends_on": [] },
"user-service": { "layer": "service", "depends_on": ["auth-core"] },
"billing-service": { "layer": "service", "depends_on": [] },
"payments-api": { "layer": "api", "depends_on": ["auth-core"] },
"admin-dashboard": { "layer": "ui", "depends_on": ["user-service"] }
}
Running the validator:
❌ ARCHITECTURE VALIDATION FAILED:
• DEAD CODE: Core module 'dead-util' is not used by any other module. Delete it or wire it in.
• UNGROUNDED: Service 'billing-service' doesn't depend on any core module. It's floating without a foundation.
• LAYER SKIP: API 'payments-api' bypasses the service layer. APIs must depend on services, not raw core.
• LAYER SKIP: UI module 'admin-dashboard' doesn't go through an API. Direct service/core imports from UI are forbidden.
4 violation(s) found.
Four violations caught. In a code review, each of these individual dependencies looked reasonable. But the validator sees the structural picture: a dead module nobody uses, an ungrounded service, and two layer skips that will become spaghetti pathways.
Make this a build gate. No PR merges if the architecture is violated:
# .github/workflows/architecture-lint.yml
name: Architecture Lint
on: [pull_request]
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout[@v4](/v4)
- uses: actions/setup-python[@v5](/v5)
with:
python-version: '3.12'
- name: Validate Architecture Contracts
run: python scripts/validate_architecture.py
- name: Detect Uncommitted Graph Changes
run: |
git diff --exit-code || \
(echo "Architecture graph has undeclared changes. Update architecture.json." && exit 1)
The second step — Detect Uncommitted Graph Changes — catches a subtle failure mode: someone updates the architecture graph to add a new dependency, but the validator script also generated changes (like cleaning up metadata). If those changes aren't committed, the PR is out of sync.
The four-layer model (core → service → api → ui) is just an example. Your architecture might have different layers. The pattern works for anything:
infrastructure → data-stores → business-services → api-gateways → clients
Rules: clients never touch data stores. API gateways never bypass business services. Infrastructure modules have zero dependencies.
primitives → data-structures → algorithms → public-api
Rules: primitives depend on nothing. Each layer only imports from the layer directly below. The public API is the only layer external consumers see.
providers → modules → environments → stacks
Rules: providers are self-contained. Modules depend only on providers and other modules. Environments compose modules. Stacks compose environments.
base-tables → junction-tables → views → materialized-views
Rules: base tables have no foreign keys to views. Views depend on tables and other views. Materialized views depend on views.
The power of the pattern is that the validation logic is identical regardless of what your layers represent. Only the layer names and rules change.
The layer validator catches structural violations. But for a complete architecture safety net, combine it with two more checks:
Even within a valid layer structure, circular dependencies can form (Service A → Service B → Service A). Add cycle detection as a separate check that runs alongside layer validation. (See the DFS-based approach in the companion article on circular dependencies.)
When a PR modifies a module, compute the full set of transitively affected modules using BFS over the reverse dependency graph. Display this in the PR comment so reviewers understand the scope of the change:
📊 Blast Radius for this PR:
Modified: auth-core
Directly affected: user-service, billing-service
Transitively affected: payments-api, admin-dashboard, notification-service
Total impact: 5 modules (42% of system)
This transforms code review from "does this file look correct?" to "do we understand the full architectural impact of this change?"
Here's the uncomfortable truth: if your architecture constraints aren't enforced in CI, they aren't constraints. They're suggestions. And suggestions rot.
Every large codebase eventually faces a choice:
Enforce architecture as code. The rules are explicit, machine-checked, and impossible to violate without a deliberate, reviewed change to the rules themselves.
Enforce architecture as culture. The rules exist in a wiki, in tribal knowledge, in the memories of senior engineers who joined early. They work until those engineers leave, or until a deadline makes someone cut a corner "just this once."
Option 1 costs a few hundred lines of Python and a CI workflow. Option 2 costs you your architecture — slowly, invisibly, and irreversibly.
The dependency graph you don't validate is the one that rots. Make your architecture diagram an executable assertion. Fail the build when it's violated. Treat your layer rules with the same seriousness as your type system.
Because your architecture diagram is either a CI check or a wish.
There is no middle ground.
All code in this article uses only Python's standard library (json, sys). No external packages required. The architecture graph is a plain JSON file — adapt the schema to match your module structure and start validating today.
Join the waitlist: https://metareignity.com/