Software Design Reference System Designer · Managing Complexity

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.

Reading ≈ 8 minPractice ≈ 30 min

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.

The principle behind the principle

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.
Field notes · what it looks like in real code
You see thisWhat it costsThe move
_registry = {} at module scopeBehaviour depends on which module imported first.Build the registry at the composition root.
@lru_cache on a config readerThe 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 suiteState carried between tests by something shared.Inject it; give every test its own instance.
os.environ[…] read at import timeDeployment config baked in before main() runs.Read it at the composition root; pass the value down.
What people get wrong after reading this
"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 contextvars aren'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

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.

Teach it back
  1. In two sentences, explain to a junior why an order-dependent test suite costs more than the flaky tests themselves.
  2. Find one module-level mutable value in your codebase this week. Name what would change if two modules imported in the other order.
  3. 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

Run the greedy-review grep with your agent-teacher and decide each hit together.