Skip to content
← All articles

Dependency injection without a framework

The difference between a class you can test in microseconds and one that needs Docker is a parameter. No container, no decorators, no registry — just the collaborator arriving from outside.

Two versions of the same class. Only one of them can be tested.

# Version A
class RateLimiter:
    def allow(self, key: str, limit: int, window: float) -> bool:
        now = time.time()
        hits = redis_client.lrange(key, 0, -1)
        ...

To test “does the window roll over”, version A needs a Redis and a way to make time pass. The test either sleeps — turning a 40-microsecond assertion into a 10-second one — or patches time.time globally, which is a string-keyed edit to another module’s namespace that breaks the moment somebody adds from time import time.

# Version B
class RateLimiter:
    def __init__(self, clock: Clock, store: RateLimitStore) -> None:
        self._clock = clock
        self._store = store

Version B’s test passes a FakeClock you can set to any value and a dict. It runs in microseconds, tests the boundary condition exactly, and needs no patching and no container.

That is the whole technique. The parameter is the dependency injection.

Narrow Protocols beat wide ABCs

What type should clock be? Not datetime.datetime, not a TimeService base class. A Protocol with exactly the method the limiter calls:

class Clock(Protocol):
    def now(self) -> float: ...

Narrow is the operative word. A Protocol with one method can be satisfied by a three-line fake; a Protocol with nine methods forces every test to implement nine. Interface width is a cost paid by every implementer, and the right width is “what this consumer actually calls”.

Structural typing is what makes it free: FakeClock does not inherit from anything and does not import Clock. The checker verifies the match. This is also what lets the Protocol live in the inner layer while implementations live outside it, with no import pointing back.

💡Why is @runtime_checkable plus isinstance(obj, Clock) not a way to validate that an object implements your interface? click to reveal

Because it checks presence of names and nothing else. It does not look at signatures, parameter counts, or types. An object with an attribute called now that is an integer passes isinstance(obj, Clock) and then explodes when you call it.

It is also slow — slow enough that the documentation steers you toward plain hasattr when you need a runtime check at all.

And hasattr narrowing has its own hole worth knowing: after if hasattr(x, "now"):, mypy types x.now as Any, so x.now(1, 2, 3, "garbage") type-checks cleanly. Runtime checks are not a substitute for the static one.

The correct use of @runtime_checkable is narrow: dispatching on a duck-typed capability at a boundary where you genuinely cannot know the type statically. It is not interface validation, and treating it as such gives you a false sense of a contract you do not have.

The honest comparison

ABC. Nominal: the implementer must inherit. That costs an import pointing the wrong way and forbids adapting a class you do not own. It buys two real things: shared implementation (template methods, a mixin of defaults) and __init_subclass__ hooks for registration. If you want those, an ABC is right. If you only want a contract, a Protocol is right.

A DI container. It buys genuine lifecycle management — singletons, request scopes, ordered teardown across dozens of components. It costs magic and, in Python, it usually costs your type checker: wiring by string key or by decorator means the checker cannot see what is injected, and every injected attribute becomes Any. Write the container version and watch reveal_type go blind. For most services, a main() that constructs the graph explicitly is smaller, faster and fully checked.

Default-argument injection. Tempting and usually wrong:

def process(items: list[str], clock: Clock = SystemClock()) -> None: ...

The default is evaluated once, at function definition time, so every caller shares one SystemClock created at import. For a stateless clock that is merely surprising; for anything holding a connection or a buffer it is the mutable-default bug with extra steps. Ruff flags it as B008. Use clock: Clock | None = None and construct inside, or better, require the argument and construct at the composition root.

💡A test does monkeypatch.setattr("app.limiter.time.time", fake). It passes. What is wrong with it that a passing test cannot tell you? click to reveal

It is coupled to the implementation’s import style, not to its behaviour.

If somebody refactors import time into from time import time, the patch target no longer exists in that namespace, and — depending on the tool — the test either errors on a missing attribute or silently patches something the code no longer reads. Either way the failure has nothing to do with the rate limiter.

It is also a string. No checker, no editor, and no rename refactor can follow it. The patch survives every rename of the thing it patches, right up until it does not.

The injected version has neither problem: RateLimiter(clock=FakeClock(), ...) is checked by mypy, followed by rename, and unaffected by how the production clock happens to be imported. The test tells you about the limiter, which is what you wanted to know.

The rule

If a class reaches out to get something — a clock, a connection, a random source, the environment, the filesystem — that reach is the thing that makes it untestable. Pass it in instead, typed as the narrowest Protocol the class actually uses, and construct the real one exactly once, at the top.