Time as a Dependency
A now() buried in a rule makes the rule untestable and unreproducible. Injecting a clock fixes that and costs a parameter threaded through code that did not want one — which is a real price, not a rounding error.
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 parts of this system need time to be an input rather than something the code reaches for, and where is threading a clock through not worth it?
A renewal bug only reproduces for customers in Auckland during the first week of April, and only sometimes. The test suite passes. The bug is real and has been open for six weeks.
Call Date.now() where you need the time. It is available everywhere, it costs nothing, and passing a clock around for something as universal as the current time feels like architecture astronautics — which is a fair reaction and is why this is so common.
The first test for "renews after 30 days" has to either wait thirty days or manipulate the machine clock, so it does not get written, and the rule goes untested (Purity and Testing).
- The first test for "renews after 30 days" has to either wait thirty days or manipulate the machine clock, so it does not get written, and the rule goes untested (Purity and Testing).
- Month-boundary and daylight-saving bugs become unreproducible: they require the machine to be in a particular zone at a particular instant, which is not a test, it is a coincidence.
- The decision cannot be replayed from a log, because the instant it used was never recorded — only the instant it was logged, which is a different one (A Deterministic Core).
- Two clock reads inside one operation drift apart. A rule that checks
isDue(now())and then stampschargedAt = now()can straddle midnight, and the row then says it was charged on a day it was not due. - Retrofitting is genuinely expensive: two hundred call sites, most of them innocent, and no way to tell from the call site which ones are decisions (Shotgun Surgery).
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.
- The codebase has roughly two hundred calls to the platform clock and they cannot all be changed at once (Incremental Migration).
- Some of them are in logging and metrics, where the wall clock is exactly right and injecting anything is pure ceremony.
- The team ships weekly, so any migration has to be partial and safe at every point.
- Tests must not depend on the machine's timezone, because CI runs in UTC and three engineers do not (Timezone and DST Failures).
- A decision that depends on time takes that time as an argument. If the instant is not in the signature, the decision cannot be replayed.
- One logical operation sees one instant. Two calls to the clock inside one decision can straddle a midnight or a month boundary, and that is the bug in half of these cases.
- The clock is never used to order events across processes. Wall clocks disagree, and code that assumes otherwise is wrong in a way that is invisible until it is not (Clock Skew: The Gap You Cannot Measure From Inside).
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- The shell owns reading the wall clock, once, at the top of an operation, and passing the instant down (Functional Core, Imperative Shell).
- The domain owns interpreting the instant against business rules — due dates, grace periods, pause windows — and owns none of the reading.
- Infrastructure concerns that need real time — log timestamps, metric buckets, cache expiry, HTTP timeouts — own their own clock reads, and injecting one there buys nothing.
- The scheduler owns triggering. A rule should never poll for "is it time yet"; it should be told, with the instant as an argument (Cron Jobs in Production).
- The line runs between decisions and mechanics. A rule that decides whether to charge is a decision; a metric bucket timestamp is mechanics, and drawing the line anywhere else produces either untestable rules or pointless ceremony (Over-Decomposition).
- The instant enters at the operation boundary — request handler, job start, message consumption — and is carried, not re-read (Stable Identifiers).
- The type boundary matters too: an
Instantis not aLocalDate, and business rules about "the first of the month" need a zone, which means the zone is an input as well (Units in Names and Types).
A value, not an interface
Most discussion of this jumps straight to a Clock interface with a SystemClock and a FakeClock, which is an abstraction with one production implementation — the shape this domain otherwise treats with suspicion (Premature Abstraction). For the common case, an instant passed as a value is simpler, has no double to configure and no lifecycle.
The interface earns its place in one situation: code that reads time more than once and cares about the gap. A retry loop, a rate limiter, a timeout budget. There, time is genuinely a dependency with behaviour, not a value.
1// 1. A decision that needs the time: take it as a value.2function isDue(renewalAt: Instant, now: Instant): boolean {3 return !now.isBefore(renewalAt)4}5 6// 2. A calendar rule: the zone is an input too, or the rule is wrong.7function graceEndsAt(pausedAt: Instant, zone: ZoneId): Instant {8 return pausedAt.atZone(zone).plusDays(14).withTime(23, 59, 59).toInstant()9}10 11// 3. Code that reads time repeatedly: an interface is justified here.12interface Clock { now(): Instant; elapsedSince(t: Instant): Duration }13async function withRetries<T>(f: () => Promise<T>, budget: Duration, c: Clock) {14 const start = c.now()15 while (c.elapsedSince(start).lessThan(budget)) { /* ... */ }16}17 18// The shell, once per operation:19const now = systemClock.now()20await handlePause({ ...cmd, now }) // one instant, whole operationThe last two lines carry more weight than the interface discussion. Reading the clock once per operation and passing it down is what prevents the straddling bug — a rule that checks isDue at 23:59:59.9 and stamps chargedAt at 00:00:00.1 has produced a row that is internally inconsistent, and no amount of injection helps if the injected clock is read twice.
Which reads actually matter
The reason this advice gets a bad reputation is that it is applied uniformly. Two hundred clock reads in a codebase are not two hundred problems; most are logging, metrics and cache expiry, where the wall clock is correct and a parameter is noise.
The question to ask at each site is whether the value affects a decision someone might later ask about. If yes, it is an input and belongs in the signature. If it is a record of when something was observed, it is mechanics and should stay where it is.
Does the value change what the code decides, or only what it records?
when Due dates, grace periods, pause windows, trial expiry, effective-dated pricing.
cost A parameter in every signature between the boundary and the rule. This is the case that repays it, and the cost is real noise in the layers between.
when Retry budgets, rate limiters, timeout deadlines.
cost A Clock interface rather than a value, plus the discipline to use a monotonic source rather than the wall clock (Never Measure a Duration With the Wall Clock).
when Log timestamps, metric buckets, created_at audit columns.
cost Leave it. Injecting here buys no testability, because nothing asserts on it, and it adds a parameter to code with no decision in it.
when Deciding which of two updates happened first; resolving a conflict.
cost A clock will not fix this at any level of injection. Use a sequence, a version vector or a logical clock (Lamport Clocks: Consistent With Causality, Blind to Concurrency).
when A local cache deciding whether an entry is stale.
cost Leave it, unless the TTL behaviour itself is the thing under test — in which case it is a decision and belongs in the first row.
The Auckland bug, priced
The bug in the requirement is a real class: a renewal check that computes a local date from an ambient clock, in a zone thirteen hours ahead, during the week a daylight-saving transition moves the boundary. It is one line of code and six weeks of investigation.
What the injected version buys is not a fix — the logic is equally wrong in both — but reachability. The failing case becomes three literals in a test file, and the fix is verified in seconds rather than by waiting for April.
Find and fix a date-boundary bug that only manifests in far-forward timezones during a DST transition week.
Reproduction requires putting a machine into a specific zone at a specific instant. In practice the team adds logging, deploys, and waits for the next occurrence — a feedback loop of one month per hypothesis, and six weeks in they have three hypotheses.
The reported case is three literals. A loop over every zone and both DST transitions runs in milliseconds and finds the other two instances of the same bug that nobody had reported yet.
How to build it
Most important first.
- Pass the instant as a value where you can. A plain
now: Instantparameter is simpler than aClockinterface and covers most cases without introducing an abstraction (Premature Abstraction). - Use a
Clockinterface only where the code genuinely needs to read time more than once — a retry loop measuring elapsed time, a rate limiter. Then it is a real dependency rather than a value (Volatile Dependencies). - Read once per operation, at the boundary, and thread it. This kills the straddling-midnight class of bug outright, which is a larger share of real time bugs than the untestability argument suggests.
- Make the zone explicit in the type where a rule is calendar-based. "The 1st of the month" is not a fact about an instant; it is a fact about an instant in a zone (Timezone and DST Failures).
- Migrate incrementally. Convert the rules that have had bugs, leave the logging alone, and let the boundary move over months rather than in one commit (Incremental Migration).
- Record the instant used in the decision line, so the log answers "what did it think the time was", which is a different question from "when was this logged" (Logging at Boundaries).
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.
- With time injected, the next calendar rule — "grace period ends at 23:59 in the customer's zone" — is a test with three literals and no infrastructure. The rule can be exercised for every zone in a loop.
- Without it, the same rule can only be validated by deploying and waiting, so its cost is one feedback cycle of whatever period it operates on. For a monthly rule that is a month.
- Retrofitting later costs two hundred call sites, of which perhaps thirty matter, and no mechanical way to tell them apart — so it is done by hand, over months, and abandoned halfway more often than not (Shotgun Surgery).
- What stays expensive regardless: cross-process ordering. Injecting a clock does not make two machines agree, and code that needs them to agree needs a different mechanism entirely (There Is No Global Clock).
- The parameter is genuinely noisy. Threading an instant through five layers to reach a rule adds it to five signatures that have no interest in time, and this is the argument people who dislike the technique are actually making — it is a fair one.
- A
Clockinterface is an abstraction with one production implementation, which is exactly the shape this domain otherwise warns about. It earns its place only where time is read repeatedly (Premature Abstraction). - Frozen-clock tests can be less realistic than real-clock ones and can hide ordering bugs that only appear when time actually moves. Determinism is bought with a small loss of fidelity (Test Doubles, Precisely).
What can go wrong
- A
Clockinterface is introduced and half the code still calls the platform clock, so tests pass with a frozen clock while production uses the real one, in the same operation. - Tests freeze time at a value that hides the bug — noon on a Wednesday in UTC — and the suite becomes evidence that the class of bug does not exist (Test Doubles, Precisely).
- The clock is injected and then read three times inside one rule, restoring the straddling bug with more ceremony.
- Monotonic and wall-clock time get confused: elapsed time measured with a wall clock goes backwards when NTP corrects, and a timeout becomes negative (Never Measure a Duration With the Wall Clock).
- The mitigation fails too: a team injects clocks everywhere, including logging and metrics, and the parameter noise makes reviewers stop reading signatures — after which a real decision taking an ambient clock passes review unnoticed.
- Every layer between the boundary and the rule now depends on carrying an instant, which is the honest cost: it is a cross-cutting parameter with the same shape as a correlation id (What Belongs in the Pipeline).
- Calendar rules depend on a timezone database, which is data that changes several times a year when governments change daylight-saving rules. That dependency is often invisible until a country moves its clocks (Dependency Management).
- Ordering must not depend on the clock across processes; it depends on a sequence, a version or a logical clock instead (Never Measure a Duration With the Wall Clock).
- "Inject a clock everywhere." No. Inject it where a *decision* depends on time. Log timestamps, metric buckets and cache TTLs should read the real clock and injecting there is pure ceremony.
- "Use a Clock interface." Usually you want a value, not an interface.
now: Instantis simpler, has no test double, and covers the case where the operation reads time once — which is most cases (Dependency Injection). - "Freezing time in tests makes them deterministic." It makes them repeatable. If the frozen value avoids every boundary, the tests are repeatably uninformative (Testing as Design Feedback).
- "Timestamps order events." Not across machines, and not reliably within one when NTP steps the clock. Ordering needs a sequence or a version (Clock Skew: The Gap You Cannot Measure From Inside).
- shotgun-surgery
Testing it, and how it ages
- Test the calendar rules at their boundaries: 23:59:59, midnight, the 28th of February, the last day of a month with 31 days, a DST transition, and a leap year (Property-Based Testing).
- Run the suite with the machine timezone set to something hostile —
TZ=Pacific/Auckland— in CI. It costs one environment variable and finds a class of bug nothing else does (Timezone and DST Failures). - Assert that one operation sees one instant, by supplying a clock that fails the test if read twice. That is a design assertion and it is cheap.
- Do not test that logging uses the real clock. That is mechanics and asserting on it pins the implementation for nothing.
- The boundary tends to move outward: first the rules take an instant, then the whole operation carries a context that includes it, and eventually the instant lives next to the correlation id because they have the same lifetime (Stable Identifiers).
- Once the system spans processes, wall-clock reasoning has to be replaced rather than injected — sequence numbers or logical clocks — and the injected clock is what makes that replacement a local change (Lamport Clocks: Consistent With Causality, Blind to Concurrency).
- It stops being enough when time itself becomes a business concept, with effective-dated rules and a distinction between when something happened and when it was recorded. That is bitemporal modelling, and it is a much bigger commitment than an injected clock (Two Clocks: When It Happened and When You Saw It).
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 a function reading an ambient clock cannot be re-run to the same result holds in every language. What differs is the escape hatch: some runtimes let a test framework replace the global clock wholesale, which removes the parameter cost and reintroduces a global — a different trade, not a free one.
- DOMAIN-SPECIFICWeighted for systems with calendar semantics — billing, scheduling, entitlement, compliance windows. In a system where time only ever means "how long did this take", monotonic elapsed time is the only concern and almost none of this applies.
- CONTESTEDThe strongest opposing view is that global clock replacement in tests — freezing the platform clock for the duration of a test — gets the same determinism at a fraction of the cost, with no parameter in any signature, and that the injected-clock discipline is ideology dressed as engineering. That is a strong argument in ecosystems with reliable clock-mocking libraries, and its weakness is real: a globally frozen clock is still a global, it does not survive concurrency, and it leaves the production code unable to say which of its two hundred clock reads was a decision.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — Testing & Reliability Engineering — running the suite under a hostile timezone is a CI practice with a design payoff, and the confidence argument for it lives there.