FoundationsGENERALLANGUAGE-SPECIFICFRAMEWORK-SPECIFIC

Local Reasoning

Whether you can understand one piece of code without loading the rest of the system into your head. It is the property that decides how a codebase feels to work in.

The requirement, the obvious build, and why it breaks

Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.

The question

Why does one codebase feel workable at 200,000 lines and another feel unmanageable at 20,000?

The requirement

A new engineer needs to change how refund eligibility is decided. They have the file open.

The obvious build

Good documentation and onboarding solve this. If people understand the system, they can change it.

Why it breaks

Nobody holds a large system in their head, including its authors. Onboarding raises the ceiling slightly and does not change the shape of the problem.

How it breaks as requirements change
  • Nobody holds a large system in their head, including its authors. Onboarding raises the ceiling slightly and does not change the shape of the problem.
  • The property that matters is not how much someone understands but how much they *need* to understand to make a specific change safely — and that is a property of the code, not of the reader.
  • Documentation describes the system as it was. Local reasoning is what lets someone verify the system as it is (Documentation Decay).
RequirementConstraintsInvariantsResponsibilitiesBoundariesInterfacesStateDependenciesFailureImplementationTestsFeedbackEvolution

What limits the solution, and what must never stop being true

This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.

Constraints
  • Human working memory holds a handful of things at once, and no amount of experience raises that limit much — it changes what counts as one thing.
  • The engineer cannot read the whole system, and never will be able to.
Invariants
  • A reader must be able to determine what a piece of code does from the code, its types, and its immediate dependencies — without needing to know who calls it.

Who owns what, and where the seams fall

Responsibilities decide boundaries; boundaries decide what an interface has to say.

Responsibilities
  • Every unit is responsible for being comprehensible from its own text plus its declared dependencies.
  • Anything that reaches outside that — global state, hidden mutation, action at a distance — is taking on a responsibility it cannot discharge.
Boundaries
  • The boundary is the unit of reasoning: how much you must read to be confident. Smaller is better only if the pieces are genuinely independent; ten files that must be read together are worse than one that need not be (Over-Decomposition).

What you must hold in your head

The concrete question is: to change this function safely, what else must I read? Every answer beyond "its own body and the types of what it takes" is reasoning cost, and each source of it has a name and a fix.

Sources of non-local reasoning
TriggerSymptomCauseResponse
A global mutable valueBehaviour depends on what ran earlierState reachable from everywhere and owned by nothingPass it explicitly, or give it one owner with a narrow interface (Hidden Global State).
Required call orderingCorrect code fails because init() was not calledA lifecycle the type system does not expressMake the unconfigured state unconstructable (Temporal Coupling).
A hidden side effectA getter writes to the databaseThe name and the type promise less than the body doesSeparate computation from effect, and name the effect (Side Effects).
An event with distant handlersYou cannot tell what happens when this firesIndirection with no static path from cause to effectUse events where decoupling is worth the traceability; not for a local call (Observer).
A boolean that means different thingsReading the call site tells you nothingA parameter with no domain meaning at the callA named type or an enum (Boolean Parameters).
Inheritance three levels deepYou must read the hierarchy to know what runsBehaviour assembled across files by the languageComposition, where the assembly is visible at the call (Composition Over Inheritance).

The same function, twice

CONTESTEDThreading four parameters through every layer is a real cost, and there is a serious argument that ambient context — request-scoped injection, effect systems, implicit clocks — buys more in brevity than it loses in locality, especially in large codebases where the parameter lists become unmanageable. The honest position is that the trade exists and that most codebases sit too far toward the implicit end, not that explicit is always right.

This is a small example on purpose. Neither version is badly written and the first is shorter, which is exactly why it is the one that gets written — the cost it imposes lands on readers later rather than on the author now.

Refund eligibility
Needs the rest of the system
function canRefund(orderId: string): boolean {
  const order = db.orders.find(orderId)          // which db?
  if (Config.get('refunds.enabled') === false)   // set where?
    return false
  if (order.createdAt < Clock.now() - WINDOW)    // WINDOW is global
    return false
  return !AuditLog.hasRefund(orderId)            // and this writes
}

// To trust this you must know: which db is bound, who sets
// that config, what WINDOW is today, whether AuditLog is
// populated yet, and that hasRefund has a cache side effect.
Answerable from its own text
type RefundPolicy = { windowDays: number; enabled: boolean }

function canRefund(
  order: Order,
  alreadyRefunded: boolean,
  now: Instant,
  policy: RefundPolicy,
): boolean {
  if (!policy.enabled) return false
  if (now.minus(order.createdAt).days > policy.windowDays) return false
  return !alreadyRefunded
}

// Everything it depends on is in the signature. It is pure,
// so it is trivially testable, and a reader is done reading.

The second version can be understood, tested and trusted without opening another file, and its dependencies are visible to a reviewer rather than discoverable by grep. It costs a longer signature and someone upstream must now fetch the order and the audit flag — which is real work moved, not removed. That upstream code is the imperative shell, and pushing effects there is the whole point (Functional Core, Imperative Shell).

How to build it

Most important first.

  • Make dependencies explicit, so what a unit can touch is visible in its signature rather than discoverable by grep (Dependency Injection).
  • Eliminate action at a distance. Global mutable state, implicit ordering requirements and hidden side effects each force the reader to consider code they cannot see (Hidden Global State).
  • Prefer types that make the invalid case unrepresentable, so a reader does not have to check whether a caller got it right (Making Illegal States Unrepresentable).
  • Keep functions honest about their effects. A function that says it computes and also writes forces every reader to check (Side Effects).
  • Name things in the domain's vocabulary, so a reader can use domain knowledge instead of tracing code (Ubiquitous Language).

What the next change costs

The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.

Cost of the next change
  • Local reasoning does not reduce the number of files a change touches. It reduces the number a person must *read and hold* to make it confidently, which is the part that actually takes the time.
  • Its absence compounds: each new implicit dependency raises the reading cost of every future change to that area, which is why codebases degrade gradually and then suddenly.
What the recommended approach costs
  • Explicit dependencies are more verbose. A function taking clock, logger and repository is longer than one reaching for globals, and that verbosity is a genuine cost that some teams reasonably decline to pay everywhere.
  • Some ambient context is worth it — a request id threaded through everything explicitly is noise. The judgement is which invisibility is worth the reasoning cost.

What can go wrong

Failure modes
  • A function that is correct only when called after another, with nothing in the type system saying so (Temporal Coupling).
  • A value mutated by something far away, so the code in front of you is correct and the behaviour is not.
  • An abstraction so thin that understanding it requires reading its implementation anyway — the indirection cost without the hiding benefit (Leaky Abstractions).
Dependencies, and their direction
  • Every implicit dependency — a global, an ambient context, a required call order — is a dependency the reader must discover rather than read.
  • Explicit dependencies are longer to write and shorter to understand, which is the trade.
Misreads
  • "So make everything small." Small units with tangled dependencies are worse than large ones with none: you now read ten files instead of one, and still need all ten.
  • "This is just readability." Readability is about a single unit's text. Local reasoning is about how much *other* code you need alongside it, and a perfectly readable function can fail it completely.
  • "Frameworks give us this." Frameworks frequently take it away — dependency injection by annotation, lifecycle hooks and implicit context are all action at a distance with a good reputation (What a Framework Charges).

Testing it, and how it ages

What to test, and at which boundary
  • Testability is a good proxy. Code that needs elaborate setup to test usually needs elaborate context to understand, and for the same reason (Testing as Design Feedback).
  • If a test needs a global reset between runs, the code has state a reader cannot see.
How this design ages
  • Local reasoning degrades quietly. No single shortcut breaks it; the twentieth one does, and by then each individual instance looks too small to fix.
  • The practical defence is review: a reviewer who cannot understand a change without asking the author has found a local-reasoning problem, not a documentation gap (A Review Checklist Worth Reading).

Where this applies

This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.

  • GENERALThe constraint is human working memory, so it does not vary by language — though what a language lets you make explicit changes how much help you get.
  • LANGUAGE-SPECIFICA language with sum types, exhaustive matching and controlled mutation lets a reader rule out cases from the types alone; in one without, the same confidence requires reading callers or trusting a comment. The goal is identical and the achievable ceiling is not.
  • FRAMEWORK-SPECIFICFrameworks built on annotations, conventions and lifecycle callbacks trade local reasoning for brevity — a class with no visible wiring is shorter and requires knowing framework rules to understand. That is often a good trade and it is always a trade.

Where the depth lives

This domain teaches the codebase-level structure and hands the rest off.

Domains that do not exist yet
  • Programming Languages & Runtime Internals — what a type system can and cannot let a reader rule out without leaving the file, which is the ceiling on how much local reasoning is achievable at all.