1
0 Comments

How to Prove Your Abstractions Won't Rot — Enforcing Ontological Integrity in Large Codebases

"We had 200 modules. The architecture diagram said they were layered. git log said they were spaghetti."


The Slow Death of a Clean Architecture

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?


Architecture as Executable Assertions

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:

  1. A dependency graph — a machine-readable manifest of which modules depend on which
  2. Layer rules — a set of assertions about what dependencies are allowed for each layer

Let's build both.


The Dependency Graph

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.


The Validator

Here's the validation script. It enforces four categories of rules:

  1. Dangling references: Every dependency must point to a module that actually exists
  2. Core integrity: Core modules must have zero dependencies (they are foundations)
  3. Layer grounding: Each layer must depend on the layer below it, never skip layers
  4. No dead code: Core modules must be used by something, or they're dead weight
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.


Catching Violations in Action

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.


CI Integration

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.


Designing Your Layer Rules

The four-layer model (core → service → api → ui) is just an example. Your architecture might have different layers. The pattern works for anything:

Microservice Architectures

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.

Package/Library Architectures

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.

Terraform / Infrastructure-as-Code

providers → modules → environments → stacks

Rules: providers are self-contained. Modules depend only on providers and other modules. Environments compose modules. Stacks compose environments.

Database Schema Design

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.


Advanced: Combining With Cycle Detection and Blast Radius

The layer validator catches structural violations. But for a complete architecture safety net, combine it with two more checks:

1. Cycle Detection

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.)

2. Blast Radius Computation

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?"


The Architecture Diagram Is Either a CI Check or a Wish

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:

  1. Enforce architecture as code. The rules are explicit, machine-checked, and impossible to violate without a deliberate, reviewed change to the rules themselves.

  2. 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/

on July 25, 2026
Trending on Indie Hackers
I built an AI that turns an idea into a live business in under 10 minutes. Here’s what 1,000 launches taught me User Avatar 89 comments Building a startup costs $0. Your tooling budget costs $500K. Here's why. User Avatar 45 comments Stop losing deals in the gap between "sounds good" and getting paid User Avatar 34 comments Building Noodle, a keyboard-first REST client for the terminal User Avatar 34 comments "Looks Good to Me" Is Quietly Killing Your Feedback Loop User Avatar 33 comments 787 tools for developers. 5 for nurses. Two weeks of tracking 14,000 indie launches. User Avatar 29 comments