Side Effects
Computation returns a value; an effect changes something. Database writes and network calls are the obvious ones — clocks and randomness are the two that make a function look pure and behave otherwise.
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.
What counts as an effect in my code, and which of them are hiding inside functions that look like calculations?
A pricing function has been "the reliable part of the system" for two years. In December it starts producing different totals in tests than in production, and the December release is blocked while three people argue about caching.
Effects are I/O. Keep database calls and HTTP requests out of the domain and the domain is pure. Date.now() and Math.random() are not I/O, they are language builtins, so they are fine.
They are exactly as much an effect as reading a file: both return a different answer on the second call for reasons outside the function. The rule "no I/O" passes them through, which is why the December bug got written by someone following the rule.
- They are exactly as much an effect as reading a file: both return a different answer on the second call for reasons outside the function. The rule "no I/O" passes them through, which is why the December bug got written by someone following the rule.
- As the rules grow, the hidden clock read spreads. A promotional window, a subscription proration and a tax rate change each add another
Date.now()inside the domain, and now the function has three independent notions of "now" that can disagree across a midnight boundary. - The test suite compensates by mocking the global clock, which works and quietly couples every test to a global — so tests cannot run in parallel and one forgotten restore breaks an unrelated file (Hidden Global State).
- The same happens with randomness the first time an experiment bucket or a generated id appears inside the domain, and by then there are two categories of untestable and one shared workaround.
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 function is called from six places including a nightly job, and none of them can change signature this sprint (Incremental Migration).
- The business genuinely needs time-dependent pricing — promotional windows are a product feature, not an accident.
- The team's CI runs at 02:00 UTC, which is why the bug appeared in tests before anyone saw it in production.
- A price computed for a given order, at a given instant, with given rules, is always the same number.
- Anything that reads the world — clock, random, environment, network, disk — is visible in the signature of whatever depends on it.
- Re-running a calculation never changes the system's state.
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- The domain owns deciding *what* should happen given inputs, and owns none of the reading or writing.
- The caller owns supplying the world: the instant, the seed, the fetched rows (Functional Core, Imperative Shell).
- One adapter owns each source of nondeterminism, so "where does time come from" has exactly one answer (Time as a Dependency).
- Nobody owns "convenience access" to a global clock. That is the responsibility being deliberately deleted.
- The boundary is the signature. If a value influences the result and is not a parameter, the function is not a calculation whatever it is named.
- A second boundary separates reads from writes: a function that reads the world is untestable, a function that writes to it is dangerous to re-run, and the two need different treatment (Idempotency by Design).
- Logging and metrics sit on the line. They are effects, they are usually acceptable inside otherwise-pure code, and pretending they are not is how a "pure" module ends up with a logger, a config read and a feature-flag lookup (Logging at Boundaries).
The full list, including the two nobody counts
Most teams have an implicit rule that amounts to "no database in the domain", which is a good rule that catches about half of what matters. The table below is the full list, ordered roughly by how easy each is to miss.
The last column is the one to read. It is not "is this bad" — several of these are perfectly fine in domain code — it is what each effect actually costs you, which is different for each.
- Rows four to seven are the ones that make a function *look* like a calculation and behave like a device (Local Reasoning).
- The test for any of them: call it twice with identical arguments. Do you get the same answer, and is the world unchanged?
| Effect | Looks like | How obvious | What it costs you |
|---|---|---|---|
| Persistent write | A repository or ORM call | Obvious | Re-running is unsafe; the test needs a database or a double |
| Network call | An HTTP or RPC client | Obvious | Latency, failure modes, and a test that needs a server (What Changes at the Network Boundary) |
| Visible mutation | Modifying an argument or a shared object | Sometimes hidden | The caller's value changes under it; aliasing bugs (Immutability) |
| Clock read | Date.now(), LocalDate.now(), time.time() | Nearly invisible | Two calls disagree; tests break seasonally and at midnight |
| Randomness | Math.random(), uuid(), shuffles, sampling | Nearly invisible | Non-reproducible results; failures that cannot be replayed |
| Environment / config read | process.env, a static settings object | Invisible | Behaviour depends on deployment; the test passes on one machine |
| Feature-flag evaluation | A flag client call mid-domain | Invisible | The state space doubles per flag, silently (Feature Flags and What They Cost) |
| Logging / metrics | A logger or counter call | Visible, usually benign | Nothing observable, until the log line is doing business work |
The December bug, in eight lines
This is the actual shape of the failure in the requirement above. Nothing about it is exotic and nothing about it looks wrong in review — which is the point worth internalising.
The second version is not more sophisticated. It is the same arithmetic with the world moved into the parameter list, and it happens to be reproducible, parallel-testable and correct across midnight.
1// looks pure. is not.2function priceFor(order: Order): Money {3 const promo = activePromotion(Date.now()) // read 14 const proration = daysRemaining(order.term, Date.now()) // read 25 return base(order).minus(promo).times(proration)6}7// Across a midnight boundary the two reads land on different8// days, and the total is a combination that no rule describes.9// The CI job runs at 02:00 UTC. Production runs all day.10 11// the world becomes an argument12function priceFor(order: Order, now: Instant): Money {13 const promo = activePromotion(now)14 const proration = daysRemaining(order.term, now)15 return base(order).minus(promo).times(proration)16}17// One instant per operation, supplied by the caller.18// Testing December is now a value, not a mock.The bug is not "it reads the clock" — it is that it reads the clock *twice*, which no amount of mocking a global reliably fixes and which a parameter makes structurally impossible.
What each hidden effect does when it fails
The reason to enumerate effects rather than to ban them is that they fail in different ways and want different responses. A hidden clock produces a seasonal bug; a hidden config read produces a bug that exists only in one environment; a hidden flag read produces a state space nobody enumerated.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| Clock read inside the domain | Tests fail in December, or at 00:00, or in one timezone | The result depends on when it ran, which is not in the signature | Pass a single instant per operation from the boundary (Time as a Dependency). |
| Randomness inside the domain | A failure that cannot be reproduced from the same input | The input was not the whole input | Inject a seeded generator; log the seed with the operation (Randomness as a Dependency). |
| Config or env read inside the domain | Works locally, wrong in staging, nobody can say why | Deployment state is an invisible parameter | Resolve configuration once at startup and pass values down (Validate at Startup, Fail Loudly is the backend mechanism). |
| Flag evaluation inside the domain | A combination of flags nobody tested reaches production | Each flag doubles the state space at a point no test enumerates | Evaluate at the boundary and pass a resolved decision inward (Feature Flags and What They Cost). |
| Mutation of an argument | The caller's object has changed after a "calculation" | The effect is on a reference the caller still holds | Return a new value, or name the function so the mutation is expected (Immutability). |
| A write inside a read path | A GET that is not safe to retry; a cache warm that double-charges | Deciding and doing were never separated | Split the query from the command (Functional Core, Imperative Shell). |
How to build it
Most important first.
- Enumerate the effects honestly: writes to a database or file, network calls, mutation of anything the caller can see, throwing, reading the clock, reading randomness, reading environment or configuration, logging.
- Take the two forgettable ones as parameters.
now: Instantandrng: () => numberare unglamorous and they turn a class of untestable functions into ordinary ones (Randomness as a Dependency). - Read the clock once per operation, at the boundary, and pass that single instant down. Two reads inside one operation is a bug waiting for a midnight (A Deterministic Core).
- Name effectful functions so the call site tells the truth:
getPriceshould not write an audit row, and if it must, it isrecordPriceQuote(Naming). - Separate deciding from doing. A domain function that returns "charge this card, send this email" is testable; one that does both is not (Functional Core, Imperative Shell).
- Do not chase purity into places it does not pay. A script, a migration or a glue layer is effect from top to bottom and wrapping it in ceremony buys nothing (When Design Does Not Pay).
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.
- Before: "prices must be computed in the customer's timezone" requires finding every hidden clock read, and there is no compiler question that finds them — only grep and hope. The cost is proportional to the number of reads and unbounded in the number you miss.
- After: the same change is a parameter type change from
InstanttoZonedInstantand a compile error at each boundary that supplies it. Six callers, six decisions, no search. - Testing a new pricing rule goes from "write a test, mock the global clock, remember to restore it" to "call the function with an instant", which is the change that makes the next twenty rules cheap.
- What stays expensive: an existing caller that genuinely does not know which instant it means — a batch job re-pricing historical orders has to decide between the order's time and now, and the design surfaces that question rather than answering it.
- Explicit effects mean longer signatures and more plumbing. In a small service that plumbing can exceed the logic it protects, and the honest answer is that it was not worth it there.
- Injecting a clock creates the possibility of two different clocks in one process — a real one and a fake one that leaked out of a test helper — which is a failure mode the global never had.
- Purity in the domain moves complexity to the shell rather than removing it, and the shell is the part with the fewest tests and the least structure (Functional Core, Imperative Shell).
What can go wrong
- Every function grows a
nowparameter including the forty that do not use it, and the discipline is abandoned because the noise is worse than the bug it prevented. - The clock is injected but read twice at the boundary, so the invariant "one instant per operation" is broken in the one place that was supposed to guarantee it.
- Purity is achieved and the caller still calls the function inside a loop with a fresh
Date.now()each iteration, which is the original bug relocated (Temporal Coupling). - The mitigation fails on its own: a
Clockinterface with one production implementation and one fake becomes a place people hang other things — a timezone, a business calendar, a holiday list — and it turns into a small god object (God Object).
- The domain gains explicit dependencies on an instant and a seed, and loses its implicit dependency on the process's global state — the total number of dependencies goes up and the invisible ones go to zero.
- Callers now depend on being able to produce a
now, which pushes clock access up to the entry point where it belongs (Dependency Direction). - Tests depend on nothing global, which is what lets them run in parallel and in any order (Purity and Testing).
- "No I/O means pure." Time and randomness are neither I/O nor pure, and they cause most of the flaky tests attributed to concurrency. They are the two to check first (Determinism: Same Input, Same Output? covers the concurrency-side view).
- "Logging breaks purity so remove it." Technically true and practically harmful. A log line is an effect that no caller observes and no test asserts on; treat it as acceptable and spend the discipline on the effects that change answers (Essential and Accidental Complexity).
- "Effects are bad." Effects are the point of the program. The design question is where they live and whether they are visible, not how few there are (Mutability, Used Deliberately).
- "Just mock the clock globally, it works." It works and it makes every test that touches it non-parallelisable and order-dependent, which is a cost paid every day by everyone rather than once by the author (Hidden Global State).
- hidden-global-state
- temporal-coupling
Testing it, and how it ages
- Test the pricing function across a midnight and a daylight-saving boundary by passing instants. No mocking, no global state, and the test is readable as a specification (Testing as Design Feedback).
- Test that two calls with identical arguments return identical results — a property test is the natural shape here (Property-Based Testing).
- One integration test that the boundary reads the clock exactly once per operation, which is the invariant that is easiest to regress.
- Grep-level check in CI that the domain package contains no
Date.now,Math.randomorprocess.env. Crude, effective, and it is the test that survives staff turnover.
- The set of injected effects grows slowly and predictably: clock, then randomness, then configuration, then a feature-flag reader. Each addition is a small ceremony and a large gain in testability.
- The pressure that eventually forces a change is a parameter list that has become a bag. That is the moment to introduce a single context value rather than to abandon the discipline (Introduce Parameter Object).
- It stops being right if the domain becomes genuinely effectful — a simulation whose whole point is streaming randomness — at which point the effects are the subject and pushing them out is fighting the problem (Effect Boundaries).
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.
- GENERALAny function whose result depends on something not in its arguments is untestable in the same way, in every language and paradigm — the ambient value differs (a static clock, a thread local, a module global) and the consequence does not.
- LANGUAGE-SPECIFICHaskell tracks effects in the type system so this lesson is enforced by the compiler; Rust makes mutation-through-a-shared-reference impossible but says nothing about clocks; TypeScript, Java and Python enforce none of it, so the discipline lives in review and in a CI grep. What changes is not the advice but who catches the violation and when.
- CONTESTEDThe strongest opposing view is that threading a clock, a seed, a config reader and a flag evaluator through every layer is worse than the disease: ambient context — a request-scoped container, a thread local, a framework-supplied clock — keeps signatures readable and is testable through the same mechanism, and large Java and .NET codebases run this way successfully for decades. The counter is that ambient context makes the dependency invisible to a reader and to a reviewer, and invisibility is what let the December bug ship. Both sides are describing real costs; the disagreement is about which cost compounds faster.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — Testing & Reliability Engineering — flaky tests are usually a hidden-effect diagnosis rather than a concurrency one, and that domain owns the triage.
- — Programming Languages & Runtime Internals — effect systems and monadic IO are what it looks like when a language tracks this in types instead of leaving it to review.