Software Design Reference System Architect · State, Invariants, and Components

Lesson 0056 · System Architect · Module 2

Carve Components as Deep Modules

The boundary map says where components live. Depth says how good each component is: a small interface hiding a powerful implementation — not a thin layer over someone else's work.

Reading ≈ 10 minPractice ≈ 25 min

Mission tie-in: components are where every earlier decision — responsibilities, boundaries, state machines, invariants — becomes code. Depth is the quality bar for that translation.

Knowledge: interface small, implementation powerful

A deep module has a small interface and a powerful implementation: callers see a handful of meaningful operations, and the module hides substantial complexity behind them. A shallow module is the opposite — an interface nearly as complex as its implementation, or worse, one that merely forwards: it adds a name but no leverage (glossary: information hiding).

Compare two shapes for the eligibility context:

Shallow vs deep · eligibility
ShallowDeep
get_policy(), get_evidence(), check_window(), check_evidence(), check_vip(), decide(), save_decision()… — the caller orchestrates every rule.assess(return_) → EligibilityDecision — one call; the module knows the rules, the evidence, the window, the overrides.
The principle behind the principle

Depth is a ratio: capability delivered divided by interface surface. The interface is the part you cannot change without coordinating with someone else; the implementation is the part you can change alone. So depth is not an aesthetic score — it literally measures how much of your own system you are still free to change. A module with seven public methods and one caller has seven change agreements outstanding; the same behaviour behind assess() has one.

The second mechanism is multiplicative. Every element of an interface is a channel through which a caller can couple to you, so the coupling in a system grows roughly as interface elements × callers. Halving an interface used by ten call sites removes far more complexity than halving one used by two — which is why depth work should follow call-site counts rather than intuition about which module “feels messy”.

The boundary is real, though. Depth stops paying when callers legitimately need variation you have hidden: the symptom is a growing tail of boolean flags and pass-through parameters, each one a caller asking for a door you closed. And some modules are properly wide by nature — a collections library, a numeric toolkit, a set of primitives. Those are mechanisms, not policies; a small interface over them would be a lie that forces every user to work around it.

Four Ousterhout moves make modules deep:

Then comes the assembly question: better together or better apart? Ousterhout's test is about change, not resemblance. Two things belong together when they change together and are used together; they belong apart when one can change without the other. The eligibility policy and its assess() guard live together; the eligibility module and the label printer change on different beats — apart, behind contracts.

Depth also pays for itself at the system level: a deep module has fewer seams to enforce, fewer contracts to version (lesson 0057), and more room to change internally without a ripple. When lesson 0059 weighs extracting a service, the extraction is only viable if the seam is already this deep.

# Deep: one call, powerful implementation. Callers never see the rules.
from dataclasses import dataclass
from datetime import timedelta


@dataclass(frozen=True)
class EligibilityDecision:
    accepted: bool
    refusal: str | None = None


class EligibilityService:
    """Hides the policy window, the evidence rules, and the override table."""

    def __init__(self, policy_repo, override_table, clock):
        self._policy = policy_repo
        self._overrides = override_table
        self._clock = clock

    def assess(self, return_) -> EligibilityDecision:
        if self._overrides.is_vip(return_.customer):
            return EligibilityDecision(True)
        if self._clock.today() - return_.purchased_on > timedelta(
            days=self._policy.window_days()
        ):
            return EligibilityDecision(False, "policy window passed")
        if not self._policy.evidence_satisfied(return_.evidence):
            return EligibilityDecision(False, "evidence incomplete")
        return EligibilityDecision(True)

Worked: the interface that leaked a rule

The way to judge a module is to read its callers, not its code. On the existing returns tooling, the eligibility module had eleven call sites. Six of them replicated the same five-step sequence: load the policy, check the window, check the evidence, check the VIP override table, then persist the decision. That duplication is not a style problem — it is the module telling you it never absorbed the orchestration.

Two of the six had dropped the VIP check. Not maliciously: they were written a year apart from the other four, by people copying whichever call site was nearest. Both sat on the support tool's path, which is exactly the path a VIP customer's escalated case travels. Twenty-four VIP returns were refused in the first month of the pilot before anyone connected the complaints to the code.

# Shallow: the module exposes steps, so every caller re-implements the policy.
def handle_return_request_shallow(eligibility, return_):
    policy = eligibility.get_policy(return_.category)
    if not eligibility.check_window(return_, policy):
        return eligibility.save_decision(return_, accepted=False,
                                         reason="policy window passed")
    if not eligibility.check_evidence(return_, policy):
        return eligibility.save_decision(return_, accepted=False,
                                         reason="evidence incomplete")
    # The line two of six call sites never had:
    #     if eligibility.check_vip(return_.customer): accept anyway
    return eligibility.save_decision(return_, accepted=True, reason=None)


# Deep: one call. The rule cannot be forgotten because it is not exposed.
def handle_return_request_deep(eligibility, return_):
    return eligibility.assess(return_)

The question the architect asked at the review was the diagnostic one: what does every caller have to know that it should not? The answer named the interface exactly — the order of the checks, the existence of an override table, and the fact that a refusal must carry a reason. All three are policy, and policy belongs inside the module that owns the policy.

The alternative the team proposed first was an EligibilityFacade wrapping the existing seven methods, leaving both available so nothing had to be migrated at once. It was rejected on a specific ground rather than a purist one: two doors into the same state means the rules can still be bypassed, so the incident stays possible while the code grows a layer that only forwards. What shipped instead was assess() as the single public entry, the seven methods made private, and an import rule in the build (lesson 0059) that fails if anything outside the module reaches past it. Migration took two days because there were only eleven call sites; deferring it would have meant migrating whatever the number had grown to.

The payoff arrived fourteen months later, when the policy moved out of a YAML file into a table with an editing UI for the policy owner. That change touched one module and zero callers. Under the shallow shape it would have touched eleven call sites in three modules, each needing its own review, and at least one would have been missed — the same way the VIP check was.

Field notes · shallowness smells
You seeThe defectThe move
Callers need five calls per operationOrchestration leaked out.Add one method that does the sequence.
Every method forwards one levelThe layer adds no abstraction.Delete the layer or make it mean something.
Callers must know the order of callsTemporal coupling at the interface.Fold the sequence into one operation.
The interface mirrors vendor typesForeign vocabulary leaked in.Translate at the boundary.
What people get wrong after reading this
“Deep means few methods.”
It means little for callers to know. Three methods that each take a twelve-key options dictionary expose more surface than eight methods with meaningful names — the dictionary is an interface with no type and no documentation.
“A thin wrapper around a vendor SDK is an adapter, so it is fine.”
An adapter that mirrors the vendor's shape has relocated the coupling, not removed it. When the vendor's model changes, every caller changes with it. The adapter earns its place only by speaking your domain's language on its own side.
“Deep modules are harder to test.”
Usually the reverse. A deep interface is a domain capability, so its tests are business cases you can read. A shallow one pushes the interesting logic into callers, where you end up testing orchestration in five places and the rule in none.
“Hide everything, including failures.”
Hide the mechanism; do not hide a decision the caller must make. Defining an error out of existence is deepening; swallowing a failure the caller needed to know about is a leak in the opposite direction, and it usually surfaces as a support ticket rather than a stack trace.

When this is the wrong move. A general-purpose mechanism library is meant to be wide: hiding a numeric routine behind one “do the right thing” call makes it useless for the caller who needed the other thing. When a single caller genuinely needs three orchestration variants and folding them in produces assess(return_, strict=False, skip_evidence=True, audit=False), the depth is fake — the complexity moved into flags. Split into two honest modules, or expose a small explicit sequence and document why. And do not deepen a module whose shape you do not yet know: an interface invented for hypothetical callers is speculative generality (lesson 0028), and it will be wrong in a way that is expensive to unwind.

Skill: judge a component's depth

A deep module has…

Information hiding protects…

Two classes belong together when…

Practice: deepen one component

Take the most important component in your boundary map, or use the supplied eligibility component. Write its current interface. Mark every caller that must know an internal detail — a call order, policy rule, vendor type, or config knob. Redesign toward a small set of capability-level operations, hiding those details; “five or fewer” is a prompt, not a rule. Finally, list what changes together inside it and what changes separately, and decide whether anything should move across the seam.

In the lab and the capstone: the companion lab's phase-5 checkpoint gives you components whose callers do most of the work. Count the call sites of the riskiest one, list what each of them has to know, and deepen the interface until that list is empty — then check whether any caller behaves differently from its neighbours, because that difference is usually a bug rather than a requirement. The worked capstone shows the resulting component shape in its architecture views.

Reveal: the one-caller test

A method used by one caller is a signal to inspect, not an automatic defect. If it exposes an orchestration step or incidental detail, fold it in. If it represents a coherent capability with one legitimate consumer, keep it. Interface size is a budget, but meaning matters more than a method count.

Teach it back
  1. Explain to a senior engineer, in two sentences, why interface size is a measure of how much of the system you are free to change.
  2. Take the module you change most often this week: count its callers, and name one thing they all have to know that they should not.
  3. Predict the damage: if an orchestration sequence stays outside a module for a year, name the defect that shows up and which call site produces it.

Your win

You can judge a component's depth, hide what changes, pull complexity inward, and decide what belongs together — the interface discipline that makes later contracts cheap.

Read and watch deeper

Give your agent-teacher one of your component interfaces and ask it to call the module the way a naïve consumer would — every question it asks reveals a hidden complexity that belongs inside.