TRT
Sign in
System DesignUpdated

Module Boundaries Should Follow Change

A module groups decisions that are likely to change together and hides details other modules do not need.

The Runtime Theory Team8 min read#modularity#coupling#cohesion

A module groups decisions that are likely to change together and hides details other modules do not need. That is two claims in one sentence: grouping (what belongs together) and hiding (what others must not know). A useful boundary reduces the number of places that must change for one product decision — which makes boundary quality a measurable property of your commit history, not a taste question.

Grouping by change, not by technology

If billing and email share a database table but change for different reasons — pricing rules move weekly, delivery templates move monthly — each is a better module even when both run in one process. The detector is in your VCS history:

text
co-change analysis over 90 days of commits (files changed together):
 
  billing/invoice.py  ↔  email/templates/*        12 co-changes  ← weak
  billing/invoice.py  ↔  billing/rates.py         78 co-changes  ← one module
  email/delivery.py   ↔  email/retry_policy.py    64 co-changes  ← one module
 
file touched by 61% of all PRs: shared/db_session.py
  → not a module — a queue every change passes through

Two files that change together 78 times and never apart are one module split by an accident of directory naming. Two files that change for unrelated reasons are two modules pretending to be one. Boundaries drawn around technical layers (controllers/, services/, repositories/) systematically fail this test, because a product change spans every layer.

Hiding behind a narrow interface

The public interface should express stable intent, not internal storage or control flow. The test: can the implementation change without touching a single caller?

go
// intent: "record that money moved" — survives a storage change
type Ledger interface {
    Post(ctx context.Context, entry Entry) error
    Balance(ctx context.Context, account string) (Money, error)
}
 
// callers depend on Ledger. The implementation can move from a local
// adapter (SQL in this process) to a remote service without rewriting
// them — that is what the boundary bought.

A narrow interface is what makes "local module today, remote service tomorrow" a configuration change instead of a rewrite. But the interface must express stable intent: if it mirrors your table schema (GetRowById, UpdateRowColumns), then a schema change breaks every caller and you have hidden nothing — the coupling moved to the contract.

The check question, answered

Feature: send a receipt after a purchase. Which module owns what?

text
payment state      → billing module
                     (money movement and its invariants are billing's alone;
                      email must never be able to cause a charge)
 
receipt formatting  → billing owns the *facts* (order id, total, line items)
                      as a typed event; presentation templates live in the
                      receipt/notification module
 
delivery retries    → receipt module, consuming events from an outbox
                      (a failed email must not roll back a completed payment)
 
dependency direction:
   checkout → billing → (outbox) → receipts
   receipts knows nothing about checkout or payments.
   billing knows nothing about SMTP or templates.

The decisive question is which failure may propagate. A payment that fails because an SMTP server was down is a bug in the boundary, not in the email server — dependencies must flow away from the money.

Costs and failure cases

More modules do not automatically mean less complexity:

  • Excessive fragmentation adds indirection (five hops to read one if) and coordination (three approvals for one change). A module boundary you cross ten times per feature is cheaper as one module.
  • A boundary around layers still couples product rules. Controllers/services/repos partitions are about technology; change-coupling is about the product. They rarely coincide.
  • Shared databases silently defeat module boundaries. Two modules writing the same tables share fate on every schema change regardless of what the interfaces say — ownership has to include the data.
  • Strong boundaries emerge from repeated change patterns and explicit ownership. Draw the first one where the history shows the pressure; do not pre-partition a system that has not yet revealed how it changes.

Key takeaways

  • Group by co-change, hide by intent. Use your commit history to find what actually changes together, and let interfaces describe decisions rather than schemas.
  • Measure boundaries: change fan-out per module, files touched per PR, and the share of PRs that touch any single file. One file at 61% is a smell, not a shared utility.
  • Ownership includes data. Modules sharing tables share fate; interfaces over a common schema hide nothing.
  • Dependencies flow away from the money and away from the failure — and the number of modules should be the smallest number the change history justifies.

Martin Fowler: Modularizing React Applications with Established UI Patterns works through extracting boundaries from exactly this kind of evidence.

Production cost model and latency numbers

To apply Module Boundaries Should Follow Change effectively in production systems, engineers must move beyond qualitative diagrams and quantify the exact hardware and network costs at each boundary:

Operation / BoundaryTypical LatencyDominant BottleneckProduction Mitigation
L1 / L2 Cache Access1 ns – 4 nsRegister dependency chains & pointer chasingContiguous memory layouts, cache-line alignment (64 B), and batch iteration
Main Memory (DRAM) Fetch60 ns – 100 nsTLB misses, random heap indirection, NUMA hopsHugePages (2 MB), arena/slab allocators, and pre-allocated ring buffers
Kernel Syscall / Context Switch1 µs – 5 µsUser-to-kernel mode switch, page-table flush, lock contentionNon-blocking readiness loops (epoll / io_uring), batching, and zero-copy I/O
NVMe SSD Random Read / fsync50 µs – 500 µsBlock I/O queue depth, write amplification, WAL flushesGroup commit, sequential append logs, and bounded buffer-pool pinning
Intra-Region RPC Roundtrip0.5 ms – 2.5 msSerialization, TCP/TLS framing, tail queueing ($L = \lambda W$)Connection pooling, deadline propagation, and p95 hedged requests

Failure modes, debugging, and operational checklist

In real incidents, systems rarely fail at average (p50) load—they fail when a slow dependency, skewed hot key, or resource leak pushes concurrency across a non-linear saturation cliff. When reviewing code or architecture that relies on Module Boundaries Should Follow Change, walk through three verification questions:

  1. What is the invariant under partial failure or contention? If a worker crashes mid-operation, a network packet is delayed and reordered, or two threads race on the same state transition, prove which atomic primitive, version number, or idempotency key prevents silent corruption.
  2. How is backpressure enforced when demand exceeds capacity? Unbounded in-memory queues, unthrottled retries, and blocking calls without explicit deadlines turn a localized 100 ms slowdown into a fleet-wide Out-Of-Memory (OOM) cascade. Always bound queue depths, propagate caller deadlines, and shed excess load early.
  3. Which metrics and traces isolate the bottleneck in under 60 seconds? Instrument saturation, queue wait time vs service execution time, cache hit/miss ratios, and error rates by failure reason so on-call engineers can immediately distinguish a compute bottleneck from an I/O or lock-contention stall.

Spotted a mistake or something broken? Report a problem

Not started

Sign in to save your learning progress.

Sign in to save