EffectsGENERALFRAMEWORK-SPECIFICSCALE-SPECIFIC

Hidden Global State

A value reachable from everywhere is a dependency nobody declared. It shows up as tests that pass alone and fail together, functions whose behaviour depends on what ran first, and a concurrency bug you cannot reproduce.

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

What does a global actually cost me, and how do I remove one without rewriting every caller?

The requirement

The test suite has become unreliable. Twenty tests pass individually and three of them fail when the suite runs in a different order. Someone suggests running the suite single-threaded and sorted alphabetically, which works.

The obvious build

Use a singleton for things that are genuinely one thing. There is one configuration, one logger, one database connection pool and one feature-flag client, so representing each as a module-level value is accurate and saves threading four parameters through every layer.

Why it breaks

The premise is right and the conclusion does not follow. There is one configuration *in production*; in a test process there are as many as there are tests, and the singleton makes that unrepresentable.

How it breaks as requirements change
  • The premise is right and the conclusion does not follow. There is one configuration *in production*; in a test process there are as many as there are tests, and the singleton makes that unrepresentable.
  • As the codebase grows, the global acquires writers. Configuration was read-only until someone added a runtime override for a feature flag, and now execution order determines behaviour — with nothing in any signature saying so (Temporal Coupling).
  • Test isolation degrades one mutation at a time. Each new test that sets the global adds a cleanup nobody can verify, and the failure appears in an unrelated file weeks later.
  • The concurrency version arrives last and is worst: two requests in the same process read a value the other set, intermittently, under load, on Tuesdays (Shared Mutable State in Concurrency & Parallelism).
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
  • The global in question is a configuration singleton read from about ninety places, so a signature change everywhere is not a single pull request (Incremental Migration).
  • The framework itself supplies two globals — a request context and a logger — that cannot be removed at all (What a Framework Charges).
  • Fixing the suite by sorting it removes the symptom this week and removes the diagnostic forever, so the decision is somewhat urgent.
Invariants
  • A function's behaviour is determined by its arguments and the state its arguments reach — not by what ran earlier in the process.
  • Tests are independent: any subset, in any order, in parallel, produces the same results (Purity and Testing).
  • Every dependency that can change an answer is visible to a reader of the signature.

Who owns what, and where the seams fall

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

Responsibilities
  • One module owns each piece of process-wide state, exposes it as a value, and is the only thing that constructs it (State Ownership).
  • The entry point owns resolving that state once, at startup, and passing it inward (Wiring and the Composition Root).
  • Every function owns declaring what it reads — in its parameters or in its constructor, never by reaching outward (Dependency Injection).
  • Nobody owns "convenient access from anywhere". That convenience is the whole cost.
Boundaries
  • The boundary is the process entry point. Above it, read the environment and construct the world; below it, everything arrives as an argument (Functional Core, Imperative Shell).
  • A second boundary is between read-only and mutable globals, and they are different problems: a frozen configuration resolved at startup is a mild dependency-visibility issue, while a mutable one is order dependence and a race.
  • Framework-supplied ambient context is a boundary you do not control. The response is to read it once at the edge and pass values inward, so the ambient reach stops at the adapter (Boundary Adapters).

The dependency that is not in the signature

The two versions below have identical dependencies. One of them says so. That is the whole difference, and it decides whether a reader, a reviewer, a test and a second tenant can each do their job.

Notice the third block: it is the migration seam, and it is what makes this fixable in a codebase with ninety call sites rather than requiring one enormous pull request.

The same dependency, declared and undeclared
1// config.ts
2export let config = loadFromEnv() // mutable, reachable, global
3
4// pricing.ts — signature says it needs an Order. It needs more.
5export function price(order: Order): Money {
6 return order.total.times(1 + config.vatRate)
7}
8// test A sets config.vatRate = 0.2 and forgets to restore it
9// test B, running later, silently asserts against 0.2
10
11// declared: the dependency is in the type
12export function price(order: Order, rates: TaxRates): Money {
13 return order.total.times(1 + rates.vat)
14}
15
16// the seam that makes ninety call sites survivable:
17export function price(order: Order, rates: TaxRates = config.taxRates): Money { ... }
18// step 1: add the parameter with a default (nothing breaks)
19// step 2: pass it explicitly from the module you are working in
20// step 3: when the last caller passes it, delete the default
21// step 4: when the last default is gone, delete the global

Step one is the important one: the default keeps every existing caller working, so the migration can proceed one module at a time and stop at any point without leaving the codebase in a worse state than it started (Expand and Contract).

Four costs, and they are not the same problem

Globals get discussed as one thing and they are four, with different symptoms, different urgency and different fixes. Two of them are already true of a read-only global; two require mutation. Knowing which you have decides how much to spend.

The second row is the one that gets teams to act, because it is the one that hurts every day. The fourth row is the one that costs money, because it reaches production.

What a global actually does to you
TriggerSymptomCauseResponse
Hidden dependencyA signature says a function needs an order; it also needs a tax rate, a clock and a flagThe dependency is reachable rather than declared, so nothing documents itPass it. Even read-only globals cost this, and this cost alone rarely justifies a migration (Local Reasoning).
Test couplingTests pass alone, fail together; a setup in one file affects anotherOne process, one instance of the state, many tests that need different valuesMake the state a value the test constructs. This is the cost that pays for the migration (Purity and Testing).
Order dependenceCorrect code produces different answers depending on what ran firstSomething writes to the global, and no signature expresses the ordering requirementFreeze it if it should not change; give it an owner and a lifecycle if it should (Temporal Coupling).
ConcurrencyIntermittent wrong answers under load, not reproducible locallyTwo units of work in one process sharing mutable state with no synchronisationRemove the sharing — per-request values, not process values. Locking makes it correct and slow; not sharing makes it correct (Shared Mutable State in Concurrency & Parallelism).
Ambient framework contextA domain function cannot run outside a requestThe framework's current-user or current-tenant reaches to the bottom of the stackRead it at the controller, pass values down. The ambient reach then ends where you decided (Boundary Adapters).

Removing one without stopping the world

The order below exists because a global with ninety readers cannot be removed in one change and should not be. Each step is independently shippable and independently revertible, and the sequence is chosen so the codebase is never worse than when you started.

Retiring a global, one shippable step at a time
  1. 1
    Randomise test order in CI

    Turn the intermittent failure into a reproducible one before changing any production code.

    fails by Fixing the global first, and having no way to tell whether the suite got better.

  2. 2
    Separate read from write

    Find every writer. If there are none outside tests, freeze it and most of the problem is gone for one line.

    fails by Assuming it is read-only because it was when it was written.

  3. 3
    Add the parameter with a default

    Every function that reads the global takes it as an optional argument defaulting to the global. Nothing changes behaviour.

    fails by Making it required immediately, which produces a nine-hundred-line diff nobody can review.

  4. 4
    Pass explicitly, module by module

    In the module you are already working in, thread the value from the entry point. Ship each module separately.

    fails by Doing it across the whole codebase at once, which is how the effort stalls and half-migrates.

  5. 5
    Prove it in a test

    A test that runs the module with two different configurations in one process. It cannot pass until the module is genuinely free of the global.

    fails by Trusting the diff instead, and leaving one reader behind.

  6. 6
    Remove the default

    When no caller relies on it, make the parameter required. The compiler confirms the migration is complete.

    fails by Leaving the default forever, which is the state most of these migrations actually end in.

  7. 7
    Delete the global

    And add the CI grep that stops it being reintroduced next quarter.

    fails by Deleting it without the grep, and finding a new one in six months.

Steps one and two are worth doing even if the rest never happens: a reproducible suite and a frozen configuration eliminate the two costs that hurt daily, for a fraction of the work. If a migration has to be abandoned, abandon it after step two rather than in the middle of step four.

How to build it

Most important first.

  • Separate the two problems first. Freeze what can be frozen: a configuration that is read-only after startup is a much smaller problem than one anyone can write to, and freezing it is a one-line change (Immutability).
  • Resolve at the edge, pass inward. The value becomes a parameter or a constructor argument, and the invisible dependency becomes a visible one.
  • Migrate with a seam rather than a rewrite: give the global an accessor, then let the accessor take an optional override, then make the override required in the module you are working on, then delete the global's last reader (Seams).
  • Do not build a dependency-injection container to solve this. A container makes the wiring uniform and keeps the dependency invisible at the call site, which is half the problem unaddressed (Service Locator).
  • For framework ambient context you cannot remove, wrap it: one adapter reads it, everything below takes values. The ambient reach then has a boundary you can point at.
  • Make the test suite prove it: run in random order, in parallel, in CI. A suite that cannot do that has a global whether or not anybody has found it (Testing as Design Feedback).

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
  • Before: adding per-tenant configuration means finding ninety read sites and deciding at each whether it is in a tenant context. There is no compiler question that finds them, and the failure mode of missing one is serving tenant A's settings to tenant B (Multi-Tenancy is the backend framing).
  • After: the same change is a field on a value that is already being passed, and the compiler names every construction site. The read sites do not change at all.
  • Writing a test for a new rule goes from "set the global, remember to restore it, hope nothing else in the file cares" to "construct a value", which is the change that pays daily.
  • What stays expensive: the migration itself. Ninety call sites is ninety small edits and a lot of review, and the honest reason to do it is the tenant requirement rather than the tidiness (When Design Does Not Pay).
What the recommended approach costs
  • Explicit dependencies mean longer signatures and constructor plumbing at every layer, and in a codebase where nothing will ever need two configurations that plumbing is pure cost.
  • A parameter object fixes the noise and hides which functions read which fields, which is a smaller version of the same visibility problem.
  • Removing a global is a large, boring, risky diff touching code nobody has read in years, with no user-visible benefit — which is why it usually rides along with a requirement rather than standing alone (What Technical Debt Actually Is).

What can go wrong

Failure modes
  • The global is made injectable and one default remains — config = injected ?? globalConfig — so ninety call sites keep the old behaviour and the suite is still order-dependent. This is the most common half-finished version.
  • Freezing the configuration breaks the one legitimate runtime override nobody documented, in production, at a time that has nothing to do with the change.
  • The parameter is threaded through forty functions that do not use it, the noise is unbearable, and the whole effort is reverted with the conclusion that globals were fine (Introduce Parameter Object is the answer that was skipped).
  • The mitigation fails on its own terms: a container is introduced, wiring becomes uniform and invisible, and three years later nobody can tell what a class depends on without reading a registration file in another module (Wiring and the Composition Root).
Dependencies, and their direction
  • The dependency already exists; the design only decides whether it is visible. A function that reads a global depends on it exactly as much as one that takes it as a parameter — with none of the documentation (Local Reasoning).
  • Making it explicit moves the dependency up to the entry point, which is where composition belongs and where it can be varied per test.
  • A container-based fix moves the dependency into a registration file, which is better than nothing and worse than a signature: the call site still says nothing about what it reads.
Misreads
  • "Constants are globals." A frozen constant is not the problem; it cannot make two runs differ. The problem is reachable state that can *change*, plus the invisibility that makes any global dependency undeclared (Immutability).
  • "A DI container fixes this." It fixes construction, not visibility. If a class can still ask the container for anything at any time, the dependency is as invisible as it was and now has indirection on top (Service Locator).
  • "The tests are flaky because of concurrency." Order dependence and shared mutable state are two different diagnoses with the same symptom, and randomising order distinguishes them in one run. Do that before touching any locking (Determinism: Same Input, Same Output? in Concurrency & Parallelism).
  • "Sort the tests and move on." That converts a diagnostic into a permanent constraint. The order dependence is still there; you have only agreed never to look at it, and the production version of the same bug is unaffected.
Smells this explains
  • hidden-global-state
  • temporal-coupling
  • shared-state-coupling

Testing it, and how it ages

What to test, and at which boundary
  • Run the suite in randomised order in CI, permanently. This is the test for this lesson; everything else is a detail (Flaky Tests is the DevOps framing).
  • Run it in parallel. Order randomisation finds order dependence; parallelism finds the shared mutable state that order randomisation misses.
  • Assert that a module's behaviour is identical with two different configurations constructed in the same process — impossible with a singleton, trivial once it is a value.
  • A CI grep that the domain package imports no configuration module. Crude, and it is what stops the global growing back.
How this design ages
  • Globals accumulate rather than appear: the first one is read-only and harmless, the fifth is mutable, and no single commit was wrong.
  • The forcing function is almost always multi-tenancy, per-request overrides, or a move to a runtime where one process serves many contexts — all three make "one of these per process" false, and all three arrive with a deadline.
  • It stops being a problem, honestly, in a single-tenant CLI or a serverless function with one invocation per process. There the singleton is accurate and removing it buys nothing (When Design Does Not Pay).

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.

  • GENERALReachable mutable state with no declared dependency behaves the same in every language: it makes results depend on execution order, and it is invisible to a reader of the call site. What varies is only how it is spelled — a module variable, a class static, a thread local, a framework context.
  • FRAMEWORK-SPECIFICRails, Spring and Django all supply ambient context by design — current request, current user, current tenant — and fighting the framework wholesale costs more than it returns. The workable answer there is to read the ambient value once in the controller or middleware and pass values inward, so the ambient reach ends at a boundary you chose rather than at the bottom of the call stack.
  • SCALE-SPECIFICIn a single-tenant CLI with one invocation per process, a configuration singleton is accurate and costs nothing but test-setup awkwardness. In a multi-tenant server where one process handles thousands of contexts concurrently, the same code is a correctness bug waiting for load. The design did not change; the number of simultaneous worlds did.

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 — randomised, parallel test execution is the diagnostic this lesson depends on, and that domain owns how to make a suite that can survive it.