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.
What does a global actually cost me, and how do I remove one without rewriting every caller?
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.
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.
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.
- 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).
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 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.
- 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.
- 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.
- 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.
1// config.ts2export let config = loadFromEnv() // mutable, reachable, global3 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 it9// test B, running later, silently asserts against 0.210 11// declared: the dependency is in the type12export 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 in20// step 3: when the last caller passes it, delete the default21// step 4: when the last default is gone, delete the globalStep 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.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| Hidden dependency | A signature says a function needs an order; it also needs a tax rate, a clock and a flag | The dependency is reachable rather than declared, so nothing documents it | Pass it. Even read-only globals cost this, and this cost alone rarely justifies a migration (Local Reasoning). |
| Test coupling | Tests pass alone, fail together; a setup in one file affects another | One process, one instance of the state, many tests that need different values | Make the state a value the test constructs. This is the cost that pays for the migration (Purity and Testing). |
| Order dependence | Correct code produces different answers depending on what ran first | Something writes to the global, and no signature expresses the ordering requirement | Freeze it if it should not change; give it an owner and a lifecycle if it should (Temporal Coupling). |
| Concurrency | Intermittent wrong answers under load, not reproducible locally | Two units of work in one process sharing mutable state with no synchronisation | Remove 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 context | A domain function cannot run outside a request | The framework's current-user or current-tenant reaches to the bottom of the stack | Read 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.
- 1Randomise 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.
- 2Separate 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.
- 3Add 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.
- 4Pass 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.
- 5Prove 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.
- 6Remove 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.
- 7Delete 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.
- 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).
- 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
- 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).
- 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.
- "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.
- hidden-global-state
- temporal-coupling
- shared-state-coupling
Testing it, and how it ages
- 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.
- 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.
- — 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.