Lightweight Dependency Injection (pass what you use)
When to use
- You want easy tests: swap real I/O (S3, time) for fakes without patching.
- Keep code decoupled from globals/singletons; wire things in one place.
- Make policies (clock, storage, retry) explicit and swappable.
Avoid when a simple function parameter is enough and won’t spread.
Diagram (text)
compose() ── builds ──> AuditLogger(storage, clock)
▲ ▲
Storage impl Clock impl
(S3/Local) (system/fixed)
Step-by-step idea
- Define small interfaces (Protocols) for things you depend on.
- Inject them via
__init__(constructor). - Have a tiny compose/build function for defaults in prod; pass fakes in tests.
Python example (≤40 lines, type-hinted)
Concrete: an AuditLogger that needs Storage and Clock.
from __future__ import annotations
from typing import Protocol, Mapping
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
import json
class Storage(Protocol):
def put(self, bucket: str, key: str, data: bytes) -> None: ...
class Clock(Protocol):
def now(self) -> datetime: ...
@dataclass
class LocalFS:
root: Path
def put(self, bucket: str, key: str, data: bytes) -> None:
p = self.root / bucket / key; p.parent.mkdir(parents=True, exist_ok=True); p.write_bytes(data)
class SystemClock:
def now(self) -> datetime: return datetime.utcnow()
@dataclass
class AuditLogger:
storage: Storage; clock: Clock; bucket: str = "audit"
def log(self, name: str, payload: Mapping[str, object]) -> str:
ts = self.clock.now().isoformat()
key = f"{ts}_{name}.json"
self.storage.put(self.bucket, key, json.dumps(payload).encode()); return key
def build_logger(*, storage: Storage | None = None, clock: Clock | None = None) -> AuditLogger:
from pathlib import Path
return AuditLogger(storage or LocalFS(Path("/tmp/app")), clock or SystemClock())
Tiny pytest (cements it)
def test_di_allows_fakes(tmp_path):
from datetime import datetime; import json
class FakeClock: # deterministic time
def now(self) -> datetime: return datetime(2025, 11, 6, 0, 0, 0)
class FakeStorage: # capture writes
def __init__(self): self.saved=[]
def put(self,b,k,d): self.saved.append((b,k,d))
fs, clk = FakeStorage(), FakeClock()
log = AuditLogger(storage=fs, clock=clk, bucket="metrics")
k = log.log("login", {"user":"u1"})
assert k.startswith("2025-11-06T00:00:00") and fs.saved[0][0] == "metrics"
assert fs.saved[0][1].endswith("login.json")
assert json.loads(fs.saved[0][2])["user"] == "u1"
Trade-offs & pitfalls
- Pros: Tests are trivial (inject fakes); no globals; clear seams; easy to swap backends/policies.
- Cons: A bit more wiring; constructors gain params; need a “compose” function.
- Pitfalls:
- Slipping back to hidden singletons or module globals.
- Factories doing heavy I/O at import time—do it in
compose(). - Unclear lifetimes—decide who owns closing clients; add a
shutdown()when needed.
Pythonic alternatives
- Plain params (what we did) + a small
compose()= 95% of use cases. - Dataclasses to hold related deps (
App(deps=Deps(storage, clock))). functools.partialto bind dependencies to functions.- FastAPI’s
Dependsor tiny containers (e.g.,punq) if a web app needs request-scoped deps—avoid heavy DI frameworks.
Mini exercise
Add a KeyStrategy dependency:
class KeyStrategy(Protocol): # decides object key naming
def make_key(self, now: datetime, name: str) -> str: ...
- Implement
DailyPrefix("YYYY-MM-DD")and inject it intoAuditLogger(use it instead of the hardcoded key). - Test that swapping
KeyStrategychanges the key format without touchingAuditLogger.log.
Checks (quick checklist)
- Dependencies are constructor-injected, not looked up globally.
- Tiny Protocols define contracts (Storage/Clock/etc.).
- One compose/build function provides sane defaults.
- Tests pass fakes with no patching.
- Ownership/lifecycle (e.g., closing clients) is explicit.




