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:
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 throughTwo 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?
// 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?
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 / Boundary | Typical Latency | Dominant Bottleneck | Production Mitigation |
|---|---|---|---|
| L1 / L2 Cache Access | 1 ns – 4 ns | Register dependency chains & pointer chasing | Contiguous memory layouts, cache-line alignment (64 B), and batch iteration |
| Main Memory (DRAM) Fetch | 60 ns – 100 ns | TLB misses, random heap indirection, NUMA hops | HugePages (2 MB), arena/slab allocators, and pre-allocated ring buffers |
| Kernel Syscall / Context Switch | 1 µs – 5 µs | User-to-kernel mode switch, page-table flush, lock contention | Non-blocking readiness loops (epoll / io_uring), batching, and zero-copy I/O |
| NVMe SSD Random Read / fsync | 50 µs – 500 µs | Block I/O queue depth, write amplification, WAL flushes | Group commit, sequential append logs, and bounded buffer-pool pinning |
| Intra-Region RPC Roundtrip | 0.5 ms – 2.5 ms | Serialization, 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:
- 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.
- How is backpressure enforced when demand exceeds capacity? Unbounded in-memory queues, unthrottled retries, and blocking calls without explicit deadlines turn a localized
100 msslowdown into a fleet-wide Out-Of-Memory (OOM) cascade. Always bound queue depths, propagate caller deadlines, and shed excess load early. - 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.