Codebase Design
Design deep modules: A small interface behind which lies a large amount of behavior, placed at a clean seam, testable through that interface. Use this language and principles everywhere you design and refactor code. Goals: leverage for callers, locality for maintainers, testability for everyone.
Glossary
Use the following terms precisely — do not substitute with "component", "service", "API", or "boundary". Consistent language is the point.
Module —— Anything with an interface and implementation. Intentionally agnostic to scale: a function, class, package, or cross-layer slice. Avoid: unit, component, service.
Interface —— Everything a caller needs to know to use the module correctly: type signatures, plus invariants, ordering constraints, error patterns, required configuration, and performance characteristics. Avoid: API, signature (too narrow — they only refer to the type-level surface).
Implementation —— What's inside the module, its code body. Different from Adapter: something can be a small adapter with a large implementation (like a Postgres repository), or a large adapter with a small implementation (like an in-memory fake). Use "adapter" when the focus is on seams; otherwise use "implementation".
Depth —— Leverage at the interface: the amount of behavior a caller (or test) can invoke per unit of interface learned. A module is deep when it carries a lot of behavior behind a small interface; it is shallow when the interface is almost as complex as the implementation.
Seam (Michael Feathers) —— A place where you can change behavior without modifying something; where a module's interface lives. Where to place a seam is itself a design decision, separate from what goes behind it. Avoid: boundary (overloaded with DDD's bounded context).
Adapter —— A concrete implementation that satisfies an interface at a seam. Describes a role (what slot it fills), not substance (what's inside it).
Leverage —— The benefit callers get from depth: more capability per unit of interface learned. One implementation pays off across N call sites and M tests.
Locality —— The benefit maintainers get from depth: changes, defects, knowledge, and validation are concentrated in one place instead of scattered across callers. Fix once, fix everywhere.
Deep vs Shallow
Deep module = Small interface + Large implementation:
┌─────────────────────┐
│ Small Interface │ ← Few methods, simple parameters
├─────────────────────┤
│ │
│ Deep Implementation│ ← Hidden complex logic
│ │
└─────────────────────┘
Shallow module = Large interface + Thin implementation (Avoid):
┌─────────────────────────────────┐
│ Large Interface │ ← Many methods, complex parameters
├─────────────────────────────────┤
│ Thin Implementation │ ← Only pass-through
└─────────────────────────────────┘
When designing an interface, ask yourself:
- Can I reduce the number of methods?
- Can I simplify the parameters?
- Can I hide more complexity internally?
Principles
- Depth is a property of the interface, not the implementation. A deep module can be composed internally of small, mockable, replaceable parts — they just don't belong to the interface. A module can have internal seams (private to its implementation, used by its own tests) as well as an external seam at its interface.
- Delete the test. Imagine deleting this module. If complexity disappears, it's a pass-through. If complexity re-emerges across N callers, it's valuable.
- The interface is the test surface. Callers and tests go through the same seam. If you want to test across the interface, the module's shape is probably wrong.
- One adapter implies a hypothetical seam. Two adapters imply a real seam. Don't introduce a seam unless something actually varies at it.
Designing for Testability
Good interfaces make testing natural:
-
Accept dependencies, don't create them.
typescript
// Testable
function processOrder(order, paymentGateway) {}
// Hard to test
function processOrder(order) {
const gateway = new StripeGateway();
}
-
Return results, don't produce side effects.
typescript
// Testable
function calculateDiscount(cart): Discount {}
// Hard to test
function applyDiscount(cart): void {
cart.total -= discount;
}
-
Small surface area. Fewer methods = fewer tests needed. Fewer parameters = simpler test setup.
Relationships
- A Module has exactly one Interface (the surface it presents to callers and tests).
- Depth is a property of a Module, measured relative to its Interface.
- A Seam is where a Module's Interface lives.
- An Adapter sits at a Seam and satisfies an Interface.
- Depth produces Leverage for callers and Locality for maintainers.
Rejected Frameworks
- Depth as ratio of implementation lines to interface lines (Ousterhout): Rewards bloated implementations. We use depth-as-leverage instead.
- "Interface" as TypeScript's keyword or a class's public methods: Too narrow — here, interface includes every fact a caller must know.
- "Boundary": Overloaded with DDD's bounded context. Say seam or interface instead.
Further Reading
- Deepen a cluster given dependencies — see DEEPENING.md: Dependency classification, seam discipline, and substitution instead of layered testing.
- Explore alternative interfaces — see DESIGN-IT-TWICE.md: Spin up parallel sub-agents to design interfaces in multiple distinct ways, then compare on depth, locality, and seam placement.