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.

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 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.

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.

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.

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.