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.
Which of the things this module depends on actually deserve an interface, and which should I just call?
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.
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.
"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).
- "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.
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.
- 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.
- 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.
- 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.
- 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
noanswers and ayeson side effects still means invert: effects are the criterion that most often stands alone. - A
yeson 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?".
| Dependency | Changes? | Slow? | Effects? | Non-det? | Verdict |
|---|---|---|---|---|---|
| Date arithmetic library (pure functions) | No | No | No | No | Call it directly. A port here buys nothing and costs a file. |
In-house Money value type | Rarely | No | No | No | Call it directly. Revisit if a second currency arrives — that is the trigger, and it should be written down. |
Date.now() / system clock | No | No | No | Yes | Inject it. Cheapest high-value seam in most systems; makes reproducibility testable at all (Time as a Dependency). |
Math.random() / UUID generation | No | No | No | Yes | Inject it. A seeded source turns an unreproducible bug report into a failing test (Randomness as a Dependency). |
| Postgres repository | Schema moves | Yes | Yes | No | Invert it — three of four, and the slowness alone decides how often the module's tests get run. |
| S3 / third-party HTTP client | Yes | Yes | Yes | Yes | Invert 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, math | No | No | No | No | Call 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.
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.
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.
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.
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
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.
1package billing2 3// Declared here, by the consumer, sized to this package's need.4type InvoiceStore interface {5 Put(ctx context.Context, key string, body []byte) error6}7 8type Clock func() time.Time // no interface needed at all9 10type Billing struct {11 store InvoiceStore12 now Clock13}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 package21// 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
TimeServicewith 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.
- 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.
- 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
- 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).
- 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?).
- "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).
- hidden-global-state
- primitive-obsession
Testing it, and how it ages
- 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).
- 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.
- — 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.