Lesson 0019 · System Designer · Module 2
Identifying and Reducing Global Coupling
A module-level mutable value is a phone line to every file in the project. Global coupling is the quietest way a codebase learns to depend on call order.
Mission tie-in: "identify and reduce global coupling" — the System Designer's first complexity leak, and the one AI code most reliably introduces with caches and singletons.
Knowledge: the hidden phone line
Global state is any state reachable from anywhere: a module-level mutable container, a singleton, a cache, a registry. The coupling it creates is invisible — no import declares it, but every consumer shares it:
# config.py
_settings: dict = {} # module-level mutable state
def set_setting(key: str, value) -> None:
_settings[key] = value
def get_setting(key: str):
return _settings.get(key)
# service_a.py
import config
config.set_setting("rate_limit", 10) # runs at import time
# service_b.py
import config
limit = config.get_setting("rate_limit") # 10 — only if service_a imported first
The bug is in the wiring, not the values: service_b's behavior depends on service_a having run first. Import order, test order, and deployment order all become part of your program's semantics. Change a test file and the behavior changes — with no diff pointing at the cause.
The fix is to make the dependency explicit — pass it, own it, inject it:
class RateLimiter:
def __init__(self, limit: int) -> None:
self._limit = limit
self._used = 0
def allow(self) -> bool:
if self._used >= self._limit:
return False
self._used += 1
return True
# wiring happens once, visibly, at the composition root:
limiter = RateLimiter(limit=10)
service_b = ServiceB(limiter=limiter)
Now the dependency appears in the constructor — reviewable, mockable, and ordered by whoever builds the system, not by import order.
Global mutable state converts a static dependency graph into a temporal one. Coupling through imports is bounded by the graph and can be read by a tool; coupling through shared mutable state is bounded only by the process, and no static analysis will find it, because the connection is made at runtime by whoever touched the value first. Two things follow mechanically. A function's signature stops describing its contract — its real inputs now include every global it reads. And tests stop being independent, which costs more than it sounds: once results depend on order, a green build is no longer evidence, and the team learns that red means "run it again".
That gives the honest boundary. Some state genuinely is process-scoped: logging configuration, a metrics registry, a connection pool, the module cache itself. The rule that survives contact with real systems is not "no globals" but a global must be immutable after the composition root, or owned with an explicit lifecycle. And in a 200-line CLI that runs once and exits, threading a dependency through four layers can cost more than the ambient value ever will. The question is never the keyword — it is who else can observe this state, and how long the process lives.
How a senior designer works the case
A twenty-person team, a fourteen-minute pipeline, and three tests that fail perhaps one run in six. Nobody can reproduce them locally. The team's coping strategy is a rerun button.
What they look at first. Not the three tests. They run the whole suite in a randomised order with a recorded seed (pytest-randomly and friends do this), because the question is not "which test is flaky" but "is my suite order-independent at all?" Twenty failures appear. The three flaky ones were the only cases where the usual alphabetical order happened to be unlucky.
The cost they name out loud. Not the twenty failures — the rerun habit. One extra pipeline run per merge on a team merging dozens of times a week is hours of wall-clock delay, and, far worse, it trains everyone to treat a red build as noise. The next real regression will also be rerun. That is the argument that gets the work funded; "globals are bad practice" is not.
The alternative they reject. An autouse fixture that resets the module's dictionaries between tests. It goes green in an afternoon, and it is a tax that grows with every new global, paid by every future test author. It also fixes the wrong system: production still depends on import order, so a worker that imports lazily still behaves differently from the web process. They keep it — as a dated entry in the debt ledger, not as the fix.
What they choose. Strangle it rather than rewrite it. The state gets an owner; the old global becomes a shim over one instance, with a countable number of remaining callers:
class PluginRegistry: # step 1: something owns the state
def __init__(self) -> None:
self._plugins: dict[str, Plugin] = {}
def register(self, name: str, plugin: Plugin) -> None:
self._plugins[name] = plugin
_default: PluginRegistry | None = None # step 2: an honest, temporary shim
def default_registry() -> PluginRegistry:
global _default
if _default is None: # 23 call sites remain; see the debt ledger
_default = PluginRegistry()
return _default
The consequence over time. New code takes a registry as a parameter; old code goes through the shim; the number of shim callers is a figure in the debt ledger that only ever goes down, and a fitness test (lesson 0022) fails the build if a new module-level mutable container appears in the package. Six weeks later the count reaches zero and the shim is deleted. The team never had to schedule a big-bang refactor, and — the part that actually matters — the suite became evidence again.
† The greedy-review test: grep for module-level mutable containers (= {}, = [], = 0 at module scope) and singletons. Every hit is a candidate phone line. Exceptions: constants (immutable) and caches whose invalidation you own consciously.
| You see this | What it costs | The move |
|---|---|---|
| _registry = {} at module scope | Behaviour depends on which module imported first. | Build the registry at the composition root. |
| @lru_cache on a config reader | The first call freezes the value for the whole process. | Cache on an object you can create and discard. |
| a test that passes alone, fails in the suite | State carried between tests by something shared. | Inject it; give every test its own instance. |
| os.environ[…] read at import time | Deployment config baked in before main() runs. | Read it at the composition root; pass the value down. |
- "Singleton is a design pattern, so it's fine."
- Singleton is a rule about lifetime, and it is usually implemented as a global with a lock attached. Naming it after a pattern changes nothing about who can observe the state or when it was first created.
- "Module-level constants are global state too."
- Immutable values shared by everyone are fine — that is what a module is for. The defect is mutation plus ordering. Watch the fakes, though: a module-level list or dict named in capitals is still mutable, and someone will append to it.
- "Fixing this requires a dependency-injection framework."
- It requires a
main()that constructs the objects and passes them down. That function is the composition root. Containers are optional, and in Python they usually trade readable wiring for a registry with its own lifecycle bugs. - "Thread-locals and
contextvarsaren't global." - They narrow the audience, not the ownership. The value is still ambient, still absent from every signature, and still set by whoever ran first in that context. They are the right tool for request-scoped context — with a documented owner and a place where it is set, exactly once.
When this is the wrong move
- Process-wide infrastructure. Logging configuration, tracing, metrics registries and connection pools are legitimately one per process. Construct them at the composition root, freeze them after startup, and do not let application code reconfigure them at runtime.
- Scripts and short-lived CLIs. One entry point, one run, no test suite to protect: the injection plumbing can cost more than the coupling.
- When the alternative is threading a parameter through layers that don't care. A request context passed through fifteen frames that ignore it is its own kind of noise. Use an explicit context object at the edge, or
contextvarswith one owner — and write down which you chose and why. - Caches of genuinely pure functions.
functools.lru_cacheover a function of its arguments alone is fine. It stops being fine the moment that function reads a config value, a clock or an environment variable.
Skill: find the phone lines
A module-level mutable dict read by two services creates:
The right home for a rate limit shared by two services is:
A module-level cache is acceptable when:
Practice on your own code
Grep your project (and AI output) for module-level mutable containers and singletons. Pick one that two or more modules read or write. Convert it to an owned, injected object and move construction to a composition root.
In the lab: the phase-2 checkpoint asks you to find the module-level registry the returns service mutates at import time, give the state one owner, and leave a shim behind so the migration can be done in slices — plus the order-randomised test run that proves it worked.
Reveal: a worked de-globalization
# before: settings._registry mutated at import time by several plugins
import settings
settings.register_plugin(MyPlugin()) # import side effects everywhere
# after: a PluginRegistry object built once, explicitly
registry = PluginRegistry()
registry.register(MyPlugin())
app = build_app(registry)
Plugin registration is now visible in the build code, testable in isolation, and immune to import order.
- In two sentences, explain to a junior why an order-dependent test suite costs more than the flaky tests themselves.
- Find one module-level mutable value in your codebase this week. Name what would change if two modules imported in the other order.
- Assume nothing is done and the service grows a second entry point — a worker, a CLI, a migration. Predict the first behaviour difference between them.
Your win
You can grep for global coupling, name the hidden dependency it creates (call order), and replace it with an injected, owned object — with the composition root as the one place wiring happens.
Read and watch deeper
- The Pragmatic Programmer, Hunt & Thomas — the sections on global data and "decoupling": the classic treatment.
- Code Complete, McConnell — ch. 5: global data as the worst coupling level (common coupling).
- A Philosophy of Software Design, Ousterhout — ch. 5 "Information Hiding": what the module exposes is a choice; globals expose everything.
- Fowler, "Inversion of Control Containers and the Dependency Injection Pattern" — the primary source for the distinction this lesson leans on: injection is passing the dependency; the container is an optional extra.
- Watch: ArjanCodes YouTube — search "global state" or "dependency injection".
- Next: lesson 0020 — shared state and ownership.
- Reference: Design Principles — separate creation from use; glossary — global coupling, composition root.
Run the greedy-review grep with your agent-teacher and decide each hit together.