DependenciesGENERALDOMAIN-SPECIFICCONTESTED

Volatile Dependencies

Only some dependencies are worth inverting: the ones that change, that are slow, that have side effects, or that are non-deterministic. The rest should be called directly.

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

Which of the things this module depends on actually deserve an interface, and which should I just call?

The requirement

A review comment says "this should be behind an interface". The module in question depends on a date library, an in-house money type, a Postgres repository, Math.random, an S3 client and a string formatter. The comment does not say which of the six.

The obvious build

Apply the rule uniformly — either everything gets an interface, or nothing does. Both are defensible-sounding positions, both are simple to apply, and both are simple precisely because they refuse to make the judgement the situation requires.

Why it breaks

"Everything gets one" produces hundreds of one-implementation interfaces, and the ones that matter are invisible among them — the port around the payment gateway looks exactly like the port around the string formatter (How SOLID Gets Misused).

How it breaks as requirements change
  • "Everything gets one" produces hundreds of one-implementation interfaces, and the ones that matter are invisible among them — the port around the payment gateway looks exactly like the port around the string formatter (How SOLID Gets Misused).
  • "Nothing does" leaves the clock and the random source hardwired, and the reproducibility invariant becomes untestable: you cannot write a test that a past calculation still produces the same number.
  • Under either rule, the first time an interface is genuinely needed the team argues about the rule rather than about the dependency, which is the real cost of having a rule instead of a criterion.
  • Both rules also fail to notice the dependency that becomes volatile later: the in-house money type that was stable for three years until the company added a second currency.
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
  • Every interface added is a file, a name and an indirection that the whole team pays for on every read.
  • The team has been burned before by a codebase where everything had a port, and there is now a reflexive resistance to any new one.
  • Two of these dependencies are genuinely impossible to use in a unit test as they stand.
  • The service is in a regulated domain where reproducing a past calculation exactly is a legal requirement, which changes the answer for at least one of the six.
Invariants
  • A calculation must be reproducible: given the same inputs and the same recorded time, it must produce the same output, this year and in five years.
  • No unit test may depend on wall-clock time, network availability or a random seed it did not choose.

Who owns what, and where the seams fall

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

Responsibilities
  • The module owns deciding what it needs; it does not own deciding how the volatile parts are implemented.
  • The clock and the random source, once injected, are owned by the composition root, which is also where "in tests, this is fixed" becomes a stated fact rather than an accident (Wiring and the Composition Root).
  • Nothing owns a port around a stable, pure dependency, because that port should not exist.
Boundaries
  • The line is volatility, not layer membership. A domain module calling a pure date-arithmetic function is not crossing a boundary in any sense that costs anything.
  • Four things make a dependency volatile, and they are worth naming separately because they justify different responses: it changes on someone else's schedule, it is slow, it has side effects, or it is non-deterministic (A Deterministic Core).
  • A fifth, weaker reason gets used a lot and deserves scepticism: "we might replace it". That is a prediction, and most such predictions are wrong (Speculative Generality).

Four reasons a dependency is worth a seam

Run each dependency past four questions. Does it change on a schedule you do not control? Is it slow enough that having it in a unit test changes how often the test runs? Does it have side effects — writes, sends, charges? Is it non-deterministic? A dependency that answers no to all four should be called directly, and that describes most of them.

The value of the list is that it disagrees with the intuitive answer in both directions. A third-party library can be entirely stable; a two-line function in your own codebase can be volatile because it reads the clock.

  • Two no answers and a yes on side effects still means invert: effects are the criterion that most often stands alone.
  • A yes on non-determinism is the cheapest to act on — the seam is a function type, not an interface — and the most frequently skipped.
  • The four questions are also a good review comment. "Which of the four is this?" is more productive than "should this be behind an interface?".
DependencyChanges?Slow?Effects?Non-det?Verdict
Date arithmetic library (pure functions)NoNoNoNoCall it directly. A port here buys nothing and costs a file.
In-house Money value typeRarelyNoNoNoCall it directly. Revisit if a second currency arrives — that is the trigger, and it should be written down.
Date.now() / system clockNoNoNoYesInject it. Cheapest high-value seam in most systems; makes reproducibility testable at all (Time as a Dependency).
Math.random() / UUID generationNoNoNoYesInject it. A seeded source turns an unreproducible bug report into a failing test (Randomness as a Dependency).
Postgres repositorySchema movesYesYesNoInvert it — three of four, and the slowness alone decides how often the module's tests get run.
S3 / third-party HTTP clientYesYesYesYesInvert it. All four, and it is also the one that fails in production in ways a fake will not reproduce (What Changes at the Network Boundary).
String formatter, collections, mathNoNoNoNoCall directly. Inverting these is the visible signature of a rule applied without judgement.

The clock, priced

The clock is the best worked example because the seam is almost free and the requirement it enables is one that arrives in most systems eventually: reproduce what this calculation produced on a specific past date, exactly.

It is also the case where the "invert nothing until forced" position is weakest. The forcing change here is not a future refactor — it is a test you cannot write today.

Audit asks: reproduce the interest calculation as it ran on 2024-03-31
The change

A regulator requires that any past interest calculation can be re-run and produce byte-identical output, including for accounts whose terms have since changed.

`Date.now()` called directly inside the interest rules
InterestRulesAccrualJobStatementBuilderRateSchedule
testsinterest_test (passes today, fails on 29 February)accrual_test
4 modules · 2 test files

There is no way to ask the rules what they would have computed on a past date without changing them. The usual workaround — a global time-freezing library in tests — makes tests pass and does nothing for the production replay the regulator asked for. Existing tests are also quietly time-dependent, so some of them fail one day a year.

The rules take an `asOf: Instant` parameter; the clock is injected at the edge
InterestRulesAccrualJob
testsinterest_test (deterministic)replay_test
2 modules · 2 test files

Replay is calling the same function with a different argument. The rules become pure, so a property test can assert that the same inputs always produce the same output, and the leap-day flakiness disappears as a side effect.

what it cost Every caller now has to supply a time, which means asOf appears in signatures all the way to the HTTP handler — real parameter noise on paths that do not care. There is also a new way to be wrong: passing the wrong asOf is a silent bug where Date.now() was at least consistently wrong. And the seam does nothing for the harder half of replay, which is that the *rate schedule* also changed and must be versioned too (Versioned Interfaces).

Make the seam as small as the volatility

LANGUAGE-SPECIFICGo's implicit satisfaction means the consumer can declare a narrow interface without the implementer cooperating, so consumer-sized seams are the path of least resistance. In Java or C# the implementing class must name the interface, which pushes teams toward one shared interface per dependency and therefore toward wider seams. In Rust the same seam is usually a trait bound, resolved at compile time with no dynamic dispatch, so the runtime cost argument disappears while the vocabulary-freezing cost remains. In TypeScript structural typing gets you Go's property; the friction is convention, not the language.

When a dependency does deserve a seam, the size of the seam should match the size of what varies. The reflex is to mirror the vendor's surface — an S3Client interface with fourteen methods — which reproduces the coupling and adds a file.

Go's idiom makes this concrete because interfaces there are declared by the consumer and satisfied implicitly, so the natural size of a seam is one or two methods. The same discipline is available in any language; the difference is that Go makes the large version awkward and most others make it easy.

The seam is what this code needs, not what the vendor offers
1package billing
2
3// Declared here, by the consumer, sized to this package's need.
4type InvoiceStore interface {
5 Put(ctx context.Context, key string, body []byte) error
6}
7
8type Clock func() time.Time // no interface needed at all
9
10type Billing struct {
11 store InvoiceStore
12 now Clock
13}
14
15func (b *Billing) Issue(ctx context.Context, inv Invoice) error {
16 inv.IssuedAt = b.now()
17 return b.store.Put(ctx, inv.Key(), inv.Render())
18}
19
20// The S3 client satisfies InvoiceStore without importing this package
21// and without knowing it exists. A test satisfies it in four lines.

Two things to notice. InvoiceStore has one method because that is all billing uses — the S3 SDK's other forty are not this package's business, and a fourteen-method port would have dragged them in. And Clock is a function type: the smallest possible seam for the most valuable injection, with no interface, no adapter class and no file.

How to build it

Most important first.

  • Classify each dependency against the four criteria before arguing about interfaces. Most dependencies fail all four, and for those the conversation ends immediately.
  • For the ones that pass, prefer the smallest possible seam. A clock does not need a TimeService with eleven methods; it needs () => Instant (Interface Segregation, Critically).
  • Inject non-determinism rather than wrapping it: a fixed clock and a seeded random source are the two highest-value injections in most systems and cost almost nothing (Time as a Dependency, Randomness as a Dependency).
  • Re-check when volatility changes. A dependency that becomes volatile — a library that starts making breaking releases, a type that gains a second variant — has just moved category, and that is when to add the seam, with evidence (Revisit Triggers).
  • Record the ones you decided against, briefly. "No port around the money type; it is ours and it is stable; revisit if we add a second currency" prevents the same argument next quarter (Decision Records).

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
  • Next change with only volatile dependencies inverted: adding a business rule costs one file and fast, offline, deterministic tests. That is the target state and it is achievable in most modules.
  • Next change with everything inverted: the same rule change costs one file plus navigating four interfaces to find where anything actually happens, and every reader pays that tax forever.
  • Next change with nothing inverted: the rule change costs one file, plus an integration environment to test it in, plus an unreproducible result if it involves time.
  • Next change when a stable dependency turns volatile: extracting the seam then costs a mechanical refactor across its call sites — real but bounded, and cheaper than the hundred speculative ports that would have covered it.
What the recommended approach costs
  • Judgement does not scale as cleanly as a rule. Five engineers applying "invert the volatile ones" will disagree on borderline cases, and that disagreement costs review time that a blanket rule would not.
  • Getting the judgement wrong in the conservative direction — deciding something is stable when it is not — is discovered late and is expensive, and this lesson is arguing for the conservative direction.
  • Each injected volatile dependency is one more constructor parameter and one more thing the wiring has to supply, so even correct inversion is not free (Long Parameter List).

What can go wrong

Failure modes
  • A dependency is judged stable and is not. Time zone rules and tax rates are the classic examples: they look like data, they change by legislation, and code that hardcoded them is now wrong in a way tests do not catch.
  • The clock is injected in one place and called directly in three others, so the reproducibility invariant holds for most of the calculation and not all of it — the most dangerous outcome, because it tests as if it works.
  • The port is added around the volatile thing and the volatility leaks through anyway: an S3 port that returns the vendor's exception type has not isolated the vendor (Leaky Abstractions).
  • The mitigation fails when the injected fake is more reliable than reality: a fake gateway that never times out means every timeout path in the system is untested (Designing for Failure).
Dependencies, and their direction
  • Stable dependencies are called directly and appear as ordinary imports. They are not a design concern and treating them as one is where the cost comes from.
  • Volatile dependencies appear in constructor signatures, so the set of them is enumerable by reading the constructors — which makes "what is risky in this module" a mechanical question.
  • The composition root ends up owning every volatile thing in the system, which is a useful concentration: the list of what this system depends on that can change or fail is one file (Do We Need a Package for This?).
Misreads
  • "Volatile means it changes often." It means one of four things, and "has side effects" is the one people miss. A file-writing function that has not changed in a decade is volatile in the sense that matters, because it makes its caller untestable (Side Effects).
  • "External means volatile." A pure, widely-used, semantically-versioned library is often more stable than your own code. The criterion is behaviour, not provenance (Dependency Management).
  • "So the standard library is always safe to call." Not the parts that touch time, randomness, the filesystem, the network or the environment. Those are volatile by the definition here regardless of who wrote them.
  • "We only need to invert the database." The database is the famous one; the clock, the random source and the outbound HTTP client cause more untestable code in practice, and cost almost nothing to inject (A Deterministic Core).
Smells this explains
  • hidden-global-state
  • primitive-obsession

Testing it, and how it ages

What to test, and at which boundary
  • The clearest test is the negative one: can this module's tests run with no network, no database, no filesystem, and a frozen clock? Whatever prevents that is the volatile dependency (Testing as Design Feedback).
  • Test the reproducibility invariant explicitly: run the calculation twice with the same recorded time and assert identical output (Property-Based Testing).
  • Keep at least one test that uses the real volatile thing, or the fake becomes the specification and drifts from reality (Where a Test Must Be Real).
  • Do not write tests for the stable dependencies' behaviour. Testing that your date library adds days correctly is testing someone else's library (What a Unit Is).
How this design ages
  • The volatile set changes over the life of a system, usually growing: a second currency, a second region, a vendor that starts breaking APIs. Reviewing it yearly is a cheap habit with a good return.
  • Some dependencies go the other way and become stable — an internal library that stops changing — and the corresponding port can be inlined, which nobody ever does and which is a genuine source of accumulated cost (Speculative Generality).
  • The reproducibility requirement tends to spread: once one calculation must be replayable, the audit team asks for the next one, and a system that already injects its clock absorbs that request in an afternoon.

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 four criteria — changes on another schedule, slow, side-effecting, non-deterministic — describe properties of the dependency itself, so they classify the same way in any language or paradigm.
  • DOMAIN-SPECIFICWhat counts as volatile depends heavily on the business. In a regulated financial system the clock is volatile in the strongest sense, because a calculation must be reproducible years later and wall-clock time makes that impossible; in an internal admin tool the same Date.now() call is unremarkable and inverting it is waste. Tax and time-zone rules are volatile in a billing system and irrelevant in a game.
  • CONTESTEDThe strongest opposing position: volatility cannot be assessed in advance, so any classification is guesswork dressed as analysis, and the honest alternatives are to invert nothing until a real change forces it — accepting one painful refactor — or to invert uniformly and accept the indirection as a fixed cost. The "invert nothing until forced" version is a serious position with real successes behind it, and its best argument is that the refactor is usually mechanical and the predicted change usually does not arrive. It is weakest exactly where non-determinism is involved, because a hardwired clock does not merely make a future change expensive, it makes a whole class of test impossible today.

Where the depth lives

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

Domains that do not exist yet
  • Testing & Reliability Engineering — "can this test run with no network and a frozen clock" is the single question that classifies dependencies most reliably, and what to do about the ones that fail it is theirs.
  • Programming Languages & Runtime Internals — whether a seam costs a virtual dispatch, a monomorphised call or nothing at all is a compilation question, and it is a legitimate input where the seam sits in a hot path.