Great—plain and hands-on.

Lightweight Dependency Injection

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

  1. Define small interfaces (Protocols) for things you depend on.
  2. Inject them via __init__ (constructor).
  3. 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.partial to bind dependencies to functions.
  • FastAPI’s Depends or 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 into AuditLogger (use it instead of the hardcoded key).
  • Test that swapping KeyStrategy changes the key format without touching AuditLogger.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.