CouplingGENERALLANGUAGE-SPECIFICCONTESTED

Temporal Coupling

When calls must happen in an order the type system does not know about, the ordering lives in someone's head — and heads leave.

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

How do I design an interface so that the invalid call order cannot be written, rather than merely documented?

The requirement

The bulk uploader must be used as initialize(), then configure(options), then run(). Calling run() first uploads to the wrong bucket; calling configure() after run() silently does nothing. Three teams use it and two have shipped the bug.

The obvious build

Document the order. A comment at the top of the class, a line in the README, and a runtime check inside run() that throws if configure has not been called. It is clear, it is cheap, and the exception makes the mistake obvious.

Why it breaks

The runtime check catches the ordering people get wrong loudly, and misses the one they get wrong quietly: configure() after run() throws nothing because by then there is nothing to check (Swallowed Errors).

How it breaks as requirements change
  • The runtime check catches the ordering people get wrong loudly, and misses the one they get wrong quietly: configure() after run() throws nothing because by then there is nothing to check (Swallowed Errors).
  • A conditional call site — if (isLargeBatch) uploader.configure(bulkOptions) — passes the check on the branch it was tested on and skips configuration on the other. The check made the failure rarer and therefore later.
  • The ordering knowledge lives in the caller. When someone extracts a helper and moves initialize() into a shared setup, the sequence breaks in a way neither file shows (Local Reasoning).
  • The comment ages. Two more phases arrive — authenticate() and selectRegion() — and now the document describes a five-step ordering with two conditional steps that nobody has verified is even accurate (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
  • The uploader is already public inside the monorepo with about thirty call sites, so the fix has to coexist with the old shape for a release or two (Expand and Contract).
  • One consumer configures conditionally in a branch, so any redesign has to keep conditional configuration expressible.
  • The team is in TypeScript. Encoding order in types is possible but every construct used has to be understandable by someone who has not read a type-level programming article.
Invariants
  • No upload ever runs against an unconfigured destination — not by mistake, not in a code path added next year, not in a test helper.
  • Any object that exists is in a state where every method on it is meaningful. A method that is a no-op in the current state is a design defect, not a convenience.

Who owns what, and where the seams fall

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

Responsibilities
  • The uploader owns knowing which operations are legal when. Delegating that to the caller is delegating a rule to the party least able to enforce it.
  • The caller owns supplying a destination and a batch. It owns no knowledge of phases.
  • The type system, where the language allows, owns rejecting the invalid sequence — because it is the only participant that never forgets and never leaves the company.
Boundaries
  • The seam is between "configured" and "not configured", and it should be a boundary between two *types*, not two moments in one object's life (Making Illegal States Unrepresentable).
  • Each type exposes only the operations legal in that state, so the surface area is the state machine (Designing a Module Interface).
  • Where the ordering genuinely cannot be encoded — a protocol driven by external events — the boundary moves to an explicit state field with a single transition function, and the check becomes exhaustive rather than ad hoc (State Machines).

The machine, including the transitions that must not exist

Temporal coupling is a state machine that somebody declined to write down. Writing it down is most of the work, and the forbidden list is the part that turns a diagram into a specification — a transition table without it describes what should happen and says nothing about what must not.

Notice that two of the forbidden transitions fail silently in the original design. Those are the expensive ones: a loud failure is a bug report, a silent one is a support ticket six weeks later about files in the wrong bucket (Swallowed Errors).

BulkUploader, made explicit
UnconfiguredConfiguredRunningCompleted ·Failed ·
FromOnToGuardEffect
Unconfiguredconfigure(options)Configureddestination resolves and credentials validatereturns a ConfiguredUploader; the unconfigured value is consumed
Configuredrun(batch)Runningbatch is non-emptyopens the multipart session
Runningall chunks acknowledgedCompletedfinalises the object and emits an upload-completed event
Runningchunk error or timeoutFailedaborts the multipart session so no partial object is visible (Partial Failure)
Failedretry()Runningthe failure was transient and the batch is unchangedstarts a new session with a fresh idempotency key (Idempotency by Design)
Completedrun(nextBatch)Runningthe configured uploader is reused; configuration is unchanged
must be impossible
  • Unconfigured → RunningThis is the original bug. run() before configure() uploaded to a default bucket — it did not fail, it succeeded against the wrong destination, which is why nobody noticed for a month.
  • Running → ConfiguredReconfiguring mid-flight. In the old design configure() during a run returned normally and changed nothing, so the caller believed a chunk size that was not in effect. Silent, and it made every latency measurement wrong.
  • Completed → CompletedRe-running the same batch without a new idempotency key duplicates objects. Making this unrepresentable is cheaper than deduplicating afterwards (Idempotent Is a Property of the Whole Effect, Not the Write).
  • Failed → CompletedMarking a failed batch complete because the last chunk eventually acknowledged. The partial upload is still visible unless the session was aborted, so "complete" would be a lie the rest of the system trusts.

The forbidden list is where the ordering requirement actually lives. Everything the original comment said — "call configure first" — is one row of it, and the other three were never written down anywhere.

Make the wrong order unwritable

Once the machine is explicit, the design question is narrow: can the language reject the forbidden transitions, and at what cost in readability? In TypeScript the answer for this shape is yes and the cost is one extra type name.

The move is small — each transition returns a different type, and the previous type has no run on it. There is no cleverness, no phantom generic, and a reader who has never heard the word typestate can follow it.

Three phases as one type, and as three
1// Ordering in a comment. Thirty call sites, two shipped bugs.
2class BulkUploader {
3 /** Call initialize(), then configure(), then run(). */
4 initialize(): void { /* ... */ }
5 configure(o: Options): void { /* no-op if already running */ }
6 run(batch: Batch): Promise<Result> { /* uses default bucket if unconfigured */ }
7}
8
9// Ordering in the types. The wrong sequence does not compile.
10export function uploader(): Unconfigured {
11 return { configure }
12}
13
14interface Unconfigured { configure(o: Options): Configured }
15interface Configured { run(batch: Batch): Promise<Running> }
16interface Running { result(): Promise<Result<Completed, Failed>> }
17
18// call site — there is no run() to call until configure() has been called
19const done = await uploader().configure(opts).run(batch)

The conditional case is the one to check before committing to this, because it is where the encoding usually breaks: isLarge ? base.configure(bulk) : base.configure(std) still works because both branches produce the same type, but if (isLarge) base.configure(bulk) — configure sometimes — does not, and cannot. That is the design telling you an unconfigured uploader was never meant to be runnable; if a genuine default exists, the honest fix is a default in uploader() rather than an optional phase (Optional Values and Absence).

Where the ordering is not yours to control

Typestate works when the caller drives the sequence. It stops working when the environment does — a socket that can drop, a device that can be unplugged, a payment terminal that can be cancelled by the customer mid-transaction. There the object cannot hand you a new type on every transition, because the transition happens without a call.

The right design there is not a retreat to comments. It is one explicit state field, one transition function, and an exhaustive switch so a new state produces a compile error rather than a fall-through (State Machines).

Temporal coupling, how it surfaces, and what actually fixes it
TriggerSymptomCauseResponse
A required setup call is skipped on one branchCorrect behaviour in tests and staging; wrong destination or default config in production on the untested pathThe ordering is knowledge in the caller, and the caller has two pathsMake the configured object a distinct type, or fold configuration into construction so there is no unconfigured object to hold
A phase is called after it can take effectNo error at all; a setting the caller believes is in effect quietly is notThe method is legal on the object in every state, so "too late" has no representationFreeze the value at transition time and make the late call a compile error or an explicit failure — never a no-op (Swallowed Errors)
Someone extracts a helper and moves one call into shared setupA different consumer breaks, in a file the refactorer never openedThe sequence spanned files, so no single file showed it was a sequenceReturn the next state from each transition, so moving a call moves its result and the compiler follows (Local Reasoning)
The environment ends the sequence — disconnect, cancel, timeoutAn object stuck in a state whose methods all throw, or worse, all succeed meaninglesslyTypestate assumed the caller drives every transition; here it does notExplicit state field, one transition function, exhaustive handling of every state including the ones only the environment can cause (Designing for Failure)
A new phase is added by someone who does not know the whole sequenceA fifth step documented in a fifth place, and two consumers that never call itThe sequence has no single definition, so additions are appended rather than integratedOne state machine, in code, that a new phase has to be added to before it can be used (Docs Close to Code)

How to build it

Most important first.

  • Draw the state machine first, including the transitions that must not exist. The forbidden list is the requirement (Invalid Transitions).
  • Make each state a distinct type and have transitions return the next type. An unconfigured uploader simply has no run method to call (Making Illegal States Unrepresentable).
  • Collapse the phases if they do not need to be separate: if initialize and configure always happen together, one constructor taking the options removes the ordering problem entirely and is the better answer whenever it fits.
  • Where a phase is genuinely optional, give it a default rather than a required call — an optional step nobody can forget is better than a documented one they can (Optional Values and Absence).
  • If the object must be long-lived and event-driven, keep one explicit state field, one transition function, and a compile-time exhaustive switch — not a scattering of booleans (Boolean Flag Explosion).
  • Keep a runtime guard as well when the language cannot enforce the order across a serialization boundary; belt and braces is right here because the failure is silent (Enforcing Invariants).

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
  • Adding a fourth phase — selectRegion() — costs one new type and a compiler error at every call site that must now supply a region. The compiler produces the migration list; the work is mechanical and complete.
  • Under the documented-order design, the same change costs a comment update, thirty call sites nobody enumerates, and a bug in whichever consumer configures conditionally.
  • What did not get cheaper: changing the *meaning* of a phase — configuration now needing credentials — still crosses every call site, because that is a contract change rather than an ordering one.
  • And the ongoing cost: every future phase must be expressible in the type encoding. A protocol that becomes genuinely dynamic will eventually break the encoding, and unwinding it is more work than unwinding a comment.
What the recommended approach costs
  • Typestate makes conditional and dynamic sequences awkward, and awkwardness in a common case is how escape hatches get added (Optional Values and Absence).
  • More types means more names, more files and a steeper first hour for a new reader — who now has to learn UnconfiguredUploader, ConfiguredUploader and RunningUpload to do one thing.
  • Collapsing the phases into a constructor, which is usually the best answer, gives up the ability to reuse a configured uploader across batches — a real cost if configuration is expensive (Allocation and Copies).

What can go wrong

Failure modes
  • The types are introduced and one escape hatch remains — a getRawUploader() for an awkward test — and the invariant is back to being a convention (Exposing Too Much).
  • The typestate encoding gets clever: phantom types, conditional types, four generic parameters. The ordering is now enforced and nobody on the team can add a phase (The Complexity Budget).
  • The states are encoded but the transitions are not exhaustive, so a new state is added and three switches silently fall through to the default (State Machines).
  • The mitigation fails on its own terms: splitting into types makes conditional configuration awkward, so a caller reaches for as any on the branch that needed it — reintroducing exactly the path that was failing before.
Dependencies, and their direction
  • Thirty call sites depend on the uploader's sequence today; after the change they depend on its types, which the compiler checks for them.
  • The dependency moved from documentation to the type system, which is the cheapest possible place for a dependency to live.
  • The uploader itself gains no dependencies — this is a shape change, not a new collaborator, which is what makes it feasible to do incrementally.
Misreads
  • "Add a runtime check and it is fixed." A check turns a silent failure into a loud one on the paths that execute. It does nothing for the conditional path, and nothing at all for the transition whose failure mode is doing nothing (Enforcing Invariants).
  • "So never use builders." Builders are fine; builders where an incomplete object is usable are not. A builder whose build() is the only way to get a usable object has no temporal coupling at all (Factory).
  • "This is just about initialization." Any required ordering is temporal coupling: acquire before release, begin before commit, subscribe before publish, validate before persist. Initialization is only the most familiar case (Where the Transaction Boundary Goes).
  • "Encode everything in types." In a language without the facilities, or for a sequence driven by external events, the honest answer is an explicit state field with exhaustive transitions — and that is a good design, not a fallback (State Machines).
Smells this explains
  • boolean-flag-explosion
  • long-parameter-list

Testing it, and how it ages

What to test, and at which boundary
  • The best test here is a compile failure. A type-level test — a snippet asserted not to compile — is the check that the ordering is enforced (Testing as Design Feedback).
  • Where the order is enforced at runtime instead, test each forbidden transition explicitly, including the silent ones: assert that configure after run fails rather than doing nothing (Invalid Transitions).
  • Test the conditional path specifically. The bug in this lesson lives on the branch that was not exercised (Property-Based Testing).
  • Do not test that the happy sequence works and call it covered; the whole failure class is in the sequences nobody wrote a test for.
How this design ages
  • Phases accumulate. Every uploader, client and builder that lives long enough grows an authentication step, a region step and a retry-policy step, and each is added by someone who does not know the whole sequence (API Stability).
  • The typed version ages well precisely because each addition is a compiler-enforced migration rather than a documentation update.
  • It stops being right when the protocol becomes externally driven — a device that can be disconnected at any point — because then the state is not under the caller's control and the encoding must move to an explicit machine with a runtime state field (State Machines).

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.

  • GENERALThat an undocumented required ordering is knowledge living outside the code holds anywhere; what differs is whether the language can reject the wrong order, which changes the fix rather than the diagnosis.
  • LANGUAGE-SPECIFICRust's ownership makes typestate natural and cheap — each transition consumes the previous value, so an unconfigured uploader cannot be used after configure and cannot be used before it either. In TypeScript the same encoding is possible but only advisory, since a cast defeats it; in Python it is not practically expressible and the honest design is an explicit state attribute plus a guard on every method.
  • CONTESTEDThe strongest opposing view is that typestate encodings optimise for a mistake that a single well-placed runtime assertion plus one test would catch, while imposing a permanent readability cost on everyone — and that in most codebases the sequence is called from a handful of places, all reviewed. That is persuasive when the object is constructed in one place; it weakens sharply when the sequence is conditional or spread across files, which is exactly when the bug actually occurs.

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 — whether an ordering can be enforced at compile time is a property of the type system, and linear or affine types are the feature that makes typestate cheap rather than clever.