DebuggabilityGENERALLANGUAGE-SPECIFICCONTESTED

Randomness as a Dependency

The same argument as the clock, applied to anything that returns a different answer each call: id generation, shuffling, sampling, jitter. Injected, they are reproducible; ambient, they are a bug you cannot re-run.

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 non-deterministic values in this code are decisions I will need to reproduce, and which are noise that should stay ambient?

The requirement

A customer reports that their pause confirmation went to the wrong template — a 5% experiment bucket. Nobody can reproduce it, because the bucket is assigned by a random call at request time and nothing recorded the value.

The obvious build

Call Math.random() or uuid() where you need it. It is one line, it is available everywhere, and the values genuinely are supposed to be arbitrary — which is exactly the reasoning that makes the resulting behaviour unexplainable.

Why it breaks

The value that decided the outcome is gone the instant it is used, so "why did this customer get template B" has no answer anywhere in the system (A Deterministic Core).

How it breaks as requirements change
  • The value that decided the outcome is gone the instant it is used, so "why did this customer get template B" has no answer anywhere in the system (A Deterministic Core).
  • Tests over anything that shuffles, samples or buckets become probabilistic, so they either get a fixed input that avoids the interesting cases or they flake and get deleted.
  • A bug in bucket assignment — an off-by-one at the boundary of 5% — is unreachable by testing, because you cannot ask for the boundary value (Property-Based Testing).
  • Ids generated ambiently make it impossible to write a test asserting the id in a record, so the assertion is loosened to "some string", and then a real id-collision or wrong-id bug passes (Stable Identifiers).
  • Retrofitting is the same shape as the clock retrofit, with the extra wrinkle that some call sites genuinely must not become deterministic — so a mechanical replacement is not just expensive but wrong (Shotgun Surgery).
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
  • Id generation must stay cryptographically unguessable where ids are used as capabilities, so a seeded generator is unacceptable in those places (Capability Passing).
  • Retry jitter is deliberately random and must stay random in production, or the jitter stops doing its job (Without Jitter, Every Client That Failed Together Retries Together).
  • The experiment framework is a third-party library that reads a global random source and cannot be changed.
  • The team has already been through the clock migration and will not accept a second parameter threaded through the same five layers unless it earns it.
Invariants
  • Any random value that influences a customer-visible outcome is recorded with the outcome. Otherwise the outcome cannot be explained (Debuggability by Design).
  • Ids used as capabilities are generated from a cryptographic source, always, and never from a seeded or predictable one (Least Privilege as a Design Decision).
  • A test never depends on an unseeded random source. A test that passes 95% of the time is not a test, it is a slow flake (Flaky Tests).
  • Randomness never decides something that must be consistent across processes without that decision being recorded and shared.

Who owns what, and where the seams fall

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

Responsibilities
  • The shell owns obtaining randomness and supplying it, exactly as it owns obtaining the time (Functional Core, Imperative Shell).
  • The core owns using the value it was given, and owns recording it when it influenced a decision.
  • Security-sensitive generation owns being different: unguessable ids come from a cryptographic source and are never seeded for reproducibility, and that carve-out has to be explicit (Sensitive State).
  • The experiment framework owns bucketing, and if it cannot record its own decision, the shell owns recording it on the way past.
Boundaries
  • The line runs between values that explain an outcome and values that are genuinely arbitrary. A bucket assignment explains an outcome; the jitter added to a retry delay does not.
  • A second line runs around security: identifiers that act as capabilities are on the other side of it and must not be made reproducible at any point, including in a staging environment (Capability Passing).
  • Where a third-party library owns the source, the boundary moves to the adapter: you cannot inject into it, so you record what it returned (Anti-Corruption Layer).

Two sources, two rules

The reason this is not simply "the clock lesson again" is the security carve-out. Time has one kind; randomness has two, and they have opposite requirements — one wants to be reproducible in a test, and the other must never be reproducible anywhere.

Making the two distinct types is what stops the carve-out from being a convention that decays. A call site that reads crypto.token() cannot accidentally be the one a test seeds, because there is nothing to seed.

Separate the sources, record the draw
1interface Rng { next(): number; pick<T>(xs: T[]): T } // seedable
2interface Secrets { token(bytes: number): string } // never seedable
3
4// A decision: the draw explains the outcome, so record it.
5function assignBucket(customer: CustomerId, rng: Rng) {
6 const draw = rng.next()
7 return { bucket: draw < 0.05 ? 'B' : 'A', draw } // draw travels with it
8}
9log.info('experiment.assigned', { customer_id, bucket, draw, build: BUILD })
10
11// Not a decision: jitter explains nothing, stays ambient, stays random.
12const delay = base * 2 ** attempt * (0.5 + Math.random() * 0.5)
13
14// Better than either: no randomness at all.
15const bucket = hash(customer + 'pause-template-v2') % 100 < 5 ? 'B' : 'A'

The last line is the one to reach for first. A bucket derived from a hash of a stable id is deterministic, reproducible, consistent across processes and needs no injection at all — the randomness was never a requirement, only a convenient default (Hash Partitioning and the Modulo Trap). Injection is what you do for the draws that genuinely have to be draws.

What a unit that reaches for everything looks like

Time, randomness and identity are the same design move made three times, and a unit that reaches for all of them is the recognisable end state: it cannot be tested without infrastructure, cannot be replayed, and has acquired reasons to change that have nothing to do with its job.

The verdict below is the point of the whole module. The fix is not to inject four dependencies into this class — that produces a constructor with four parameters and the same tangle. It is to notice that gathering, deciding and performing are three jobs and only the middle one is the subscription's (Designing by Responsibility).

responsibilitiesSubscriptionService.pause(), as written without any of this moduleThe unit that reaches for its own environment
Knows
  • The pause business rules
  • The current wall-clock time, by asking for it
  • How to generate a subscription event id
  • Which experiment bucket this customer is in
  • The database schema
  • The email template names
Does
  • Reads the clock, twice
  • Draws a random id
  • Draws an experiment bucket
  • Decides whether the pause is allowed
  • Writes the row
  • Sends the email
Depends on
  • Platform clock (ambient)
  • Platform RNG (ambient)
  • Database
  • Email client
  • Experiment library (ambient global)
Changes when — 6 distinct reasons
  • Pause rules change
  • The timezone policy changes
  • The id scheme changes
  • The experiment ends
  • The schema changes
  • The email copy changes

Six reasons to change and three of them are ambient, which is the finding: the three ambient dependencies do not appear in the signature, so no reviewer sees them and no test can control them. This unit cannot be re-run to the same result on any two occasions, which means no bug in it is reproducible and no property of it is testable. The fix is not four constructor parameters — that keeps every responsibility and adds ceremony. It is to split gathering from deciding from performing, at which point the deciding half takes an instant, a draw and its data as values, and is the easiest thing in the system to test (Functional Core, Imperative Shell).

Which draws to make explicit

CONTESTEDThe second column is where practitioners disagree most. One camp holds that every non-deterministic source should be injected uniformly, because a rule with exceptions decays and the security carve-out can be handled by types; the other holds that injecting jitter and sampling is ceremony that trains reviewers to ignore parameters, which is how a genuinely important one slips through. Both have watched their preferred failure happen — the table above takes the second position, and the first is not unreasonable in a codebase where the types make the carve-out mechanical.

The same triage as the clock, with one extra column that has no analogue there: whether the value must remain unpredictable. That column overrides everything else — a value that must be unguessable is never made reproducible, no matter how much it would help an investigation.

The middle column is where most of the practical value sits. Recording a drawn value is far cheaper than injecting the source, works even when the source is inside a library you do not control, and answers most of the questions injection would have answered.

Random valueInject it?Record it?Why
Experiment bucket assignmentYes — or better, replace with a hash of a stable idAlwaysIt explains a customer-visible outcome, and the boundary case cannot be tested any other way.
Entity id generationYes, from an injected sourceIt is the recordThe id must exist before the write so a failed write can name it (Stable Identifiers).
Security token, invite link, signed URLNo — a dedicated cryptographic source, never seedableNever log the valueReproducibility is the vulnerability. This row is the reason the sources are separate types (Capability Passing).
Retry jitterNoNoIt explains nothing about an individual outcome, and making it deterministic actively breaks it (Without Jitter, Every Client That Failed Together Retries Together).
Sampling decisions for tracingNoThe decision, not the drawWhether a trace was sampled matters; which number produced that does not (Sampling Without Throwing Away the Evidence).
Shuffling a result listYes, if the order is user-visibleThe seedA user reporting "the order looked wrong" needs the seed to be answerable at all.

How to build it

Most important first.

  • Pass a generator, not a value, where several draws are needed — rng: Rng is the analogue of the Clock interface, and a single value is the analogue of now: Instant (Time as a Dependency).
  • Record the drawn value alongside the outcome it decided. The bucket, the chosen shard, the generated id — a decision line that names them is what makes the outcome explainable (Logging at Boundaries).
  • Seed deterministically in tests and never in production. A seeded generator in production is a security bug in the identifier case and a correlated-behaviour bug in the jitter case (Retry Storms: The Load You Generated Yourself).
  • Split the sources by purpose: one cryptographic source for identifiers and tokens, one ordinary source for jitter and sampling. Conflating them means one of the two is wrong (Credentials and Password Handling).
  • Generate entity ids in the domain from an injected source, so the id exists before the write and can be asserted in a test (Stable Identifiers).
  • Leave jitter, sampling and load-balancing choices ambient unless they are being tested. They explain nothing about an individual outcome and threading a generator to them is cost with no return.

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
  • With the source injected, reproducing the experiment-bucket bug costs a seed and an assertion. Testing the 5% boundary specifically costs supplying the boundary value, which is impossible otherwise.
  • Without it, the same investigation costs adding a log line, deploying, and waiting for a 5% event to recur — and the fix cannot be verified except statistically.
  • The next feature that needs a random decision inherits the plumbing, so it costs nothing extra. That is the same compounding shape as ids and time, which is why the three belong in one module.
  • What stays expensive: anything already generated. Ids written with an ambient source cannot be re-derived, so a change to the id scheme is a data migration regardless of how the source is wired (Data Migration).
What the recommended approach costs
  • A second cross-cutting parameter next to the clock, in signatures that care about neither. Two of these is where a team starts arguing for an ambient context object instead, and that argument has merit (Request Context Propagation).
  • Recording drawn values costs storage and adds fields to records for the benefit of a rare investigation, which is a cost review will question and you will have trouble defending in advance (Designing for Cost).
  • Deterministic tests over a randomised algorithm are less faithful than probabilistic ones, and the fixed-seed failure mode — exploring one path forever — is a real regression in coverage if nobody varies the seed.

What can go wrong

Failure modes
  • A seeded generator leaks into production through a config default, and identifiers become predictable. This is the failure that makes the whole technique dangerous if applied without the security carve-out (When Secrets Fail).
  • One generator is shared across concurrent workers, so the sequence interleaves and the "deterministic" test is deterministic only when run single-threaded (Shared Mutable State).
  • The drawn value is recorded but the code path that used it changed, so the recorded bucket no longer explains the outcome and is trusted anyway.
  • Jitter is made deterministic during the migration, so every client retries at the same instant and the dependency that was recovering is knocked over again (Retry Storms: The Load You Generated Yourself).
  • The mitigation fails too: tests are seeded, always with the same seed, so the suite explores one path through the generator forever and the property test degenerates into a single fixed case (Property-Based Testing).
Dependencies, and their direction
  • The same cross-cutting dependency as the clock, and usually carried in the same context object — which is a good reason to do the two migrations together rather than twice (What Belongs in the Pipeline).
  • Cryptographic generation depends on the platform's entropy source, which in constrained environments — a container starting cold, an embedded target — can block or be weak at startup (Validate at Startup, Fail Loudly).
  • Deterministic tests depend on the seeded generator producing the same sequence across versions of the runtime, which is not guaranteed by every standard library and is worth pinning (Dependency Pinning).
Misreads
  • "Inject randomness everywhere." No. Inject it where the drawn value explains an outcome. Jitter, sampling and load-balancing picks explain nothing about an individual case and should stay ambient.
  • "Seed it in production for reproducibility." Never for identifiers, tokens or anything used as a capability, and never for jitter — a seeded jitter is a synchronised one, which is the opposite of what jitter is for (Without Jitter, Every Client That Failed Together Retries Together).
  • "A UUID is random enough to be a secret." A v4 UUID from a cryptographic source may be; one from a fast non-cryptographic generator is not, and the two look identical at the call site (Capability Passing).
  • "Deterministic tests mean a fixed seed." A fixed seed forever is one path through the generator. Vary the seed and report it on failure — that is reproducible *and* exploratory (Property-Based Testing).
Smells this explains
  • shotgun-surgery

Testing it, and how it ages

What to test, and at which boundary
  • Seed the generator per test and vary the seed across the suite, printing it on failure. A fixed seed forever explores one path; a random seed with the value reported gives reproducibility and coverage at once (Property-Based Testing).
  • Assert the boundary of any bucketing rule by supplying the exact boundary value, which is the case that cannot be reached with an ambient source.
  • Assert that production configuration cannot select a seeded generator — a startup check, not a code review convention (Validate at Startup, Fail Loudly).
  • Do not assert on jitter values. Assert that jitter is within a range and that two clients do not receive the same delay; the specific number is noise (Without Jitter, Every Client That Failed Together Retries Together).
How this design ages
  • Once the generator is a dependency, seeded replay of a whole operation becomes possible — the same inputs, the same instant, the same draws — which is the strongest form of reproducibility available short of recording everything (Deterministic Replay: Making the Schedule Reproducible).
  • Experiment bucketing usually migrates from random assignment to a hash of a stable id, which is deterministic by construction and removes the problem rather than injecting around it. That is the better end state and it is worth reaching for directly (Hash Partitioning and the Modulo Trap).
  • The carve-out for cryptographic sources gets sharper over time as more identifiers turn out to be capabilities — a signed URL, an invite token — and each promotion moves a call site across the security line (Capability Passing).

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 unrecorded random draw makes an outcome unexplainable is a property of information, not of a platform. What varies is whether the standard library separates cryptographic from ordinary generation clearly enough that a call site reveals which one it is — several do not, which is where the security failures come from.
  • LANGUAGE-SPECIFICIn Go and Rust the cryptographic and ordinary sources are distinct types from distinct packages, so the security carve-out is visible at every call site. In JavaScript and Python the two are a similar-looking function call apart, so the same design needs a lint rule or a review habit to hold — the argument is identical and its enforcement is much weaker.
  • CONTESTEDThe strongest opposing view is that randomness should be removed rather than injected: bucket by hashing a stable id, generate ids from a monotonic source, and derive jitter from a request id, at which point nothing is random, nothing needs injecting, and there is no parameter to thread. That is genuinely better where it applies and it is the recommendation to reach for first — it fails for anything that must be unguessable, where unpredictability is the requirement rather than an accident.

Where the depth lives

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

Performancetrace-sampling
Domains that do not exist yet
  • Testing & Reliability Engineering — varying the seed and reporting it on failure is the practice that makes randomised tests both reproducible and exploratory.
  • Programming Languages & Runtime Internals — whether a standard library distinguishes cryptographic from ordinary generation at the type level decides whether the security carve-out is enforced or merely intended.