Commands need things: an HTTP client, a database session, a clock, a settings object. How those arrive decides whether your logic can be tested without patching, and whether two commands can disagree about the state of the world. The answer is not a framework — it is two small habits.
TL;DR
- Build collaborators once at the boundary, put them on the context, pass them down explicitly.
- A default parameter (
client: Client | None = None) covers most cases with no ceremony. - Describe the seam with a
Protocolso a fake cannot silently drift from the real thing. - Never reach for a module-level global — it is the thing tests have to remember to reset.
- If you need a container, keep it dumb: a frozen dataclass of already-built objects.
Prerequisites
A CLI with the layering from structuring multi-command Python CLIs: command functions that call plain functions in a core package.
Where things get constructed
The rule: construct at the boundary, once per invocation, from settings. Commands receive what they need; core functions take it as arguments.
# src/mytool/context.py
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class Services:
settings: Settings
bucket: BucketClient
clock: Callable[[], datetime]
def build(settings: Settings) -> Services:
return Services(
settings=settings,
bucket=S3Bucket(region=settings.region, retries=settings.retries),
clock=lambda: datetime.now(UTC),
)
# src/mytool/cli.py
@app.callback()
def main(ctx: typer.Context, config: Path | None = None) -> None:
settings = load_settings(config)
configure_logging(settings.verbosity)
ctx.obj = build(settings)
# src/mytool/commands/sync.py
@app.command()
def sync(ctx: typer.Context, source: Path, dry_run: bool = False) -> None:
services = ctx.obj
result = core.sync_directory(source, bucket=services.bucket, dry_run=dry_run)
typer.echo(f"{result.uploaded} uploaded")
Three properties fall out. Every command sees the same client, built once — no connection pool
per command, no duplicated configuration parsing. The construction is in one reviewable function.
And a test can build a Services with fakes and hand it straight to the command.
Four ways to supply a dependency
A module-level global is what people reach for first and the only one that is simply wrong. It survives between tests, so a suite passes or fails depending on order, and every test that touches it needs to remember a reset.
A default parameter is the lightest thing that works, and it covers most core functions:
def sync_directory(
source: Path,
*,
bucket: BucketClient | None = None,
dry_run: bool = False,
) -> SyncResult:
bucket = bucket or S3Bucket()
...
Production code calls it with no extra argument; a test passes a fake. No patching, no container, and the signature documents the dependency.
A context object is the right answer when several commands share the same collaborators, which is the usual case for a CLI. It is the pattern shown above.
A container — a registry that constructs objects on demand — earns its place only when there are many interdependent parts with real lifecycles. For a CLI, a frozen dataclass of already-built objects gives you the same benefit without the indirection.
Making the seam explicit
A Protocol documents what a collaborator must provide, and lets a type checker verify that both
the real implementation and the test fake satisfy it:
from typing import Protocol
class BucketClient(Protocol):
def is_current(self, path: Path) -> bool: ...
def upload(self, path: Path, *, retries: int = 3) -> None: ...
class FakeBucket: # no inheritance needed
def __init__(self) -> None:
self.uploaded: list[str] = []
self.current: set[str] = set()
def is_current(self, path: Path) -> bool:
return str(path) in self.current
def upload(self, path: Path, *, retries: int = 3) -> None:
self.uploaded.append(str(path))
The value arrives when the real interface changes. Adding a method to S3Bucket without adding it
to the protocol is fine; adding it to the protocol without updating the fake is a mypy error in
the pull request that made the change, rather than a runtime surprise months later.
What not to inject
Injection is a tool for things that are slow, stateful or non-deterministic. Injecting everything produces signatures nobody can read.
Worth injecting: HTTP clients, database sessions, filesystem roots, the clock, random sources, anything that talks to a service.
Not worth injecting: pure functions, formatting helpers, constants, standard library modules that
do not touch the world. json.dumps does not need to arrive as a parameter.
The test is whether a test would want to substitute it. If no test ever would, a plain import is clearer.
UX considerations
This is an internal pattern, but it shows up in two user-visible ways.
Start-up cost. Building collaborators in the callback means constructing them on every
invocation, including --help. If a client opens a connection or reads credentials eagerly, that
cost lands on the cheapest commands. Construct lazily inside the object, or build only what the
chosen command needs.
Error timing. Failing to construct a client in the callback means mytool --help fails when
the configuration is wrong. Validate configuration there, but defer anything that can fail for
environmental reasons — a missing credential should surface when a command needs it, with a message
about that command.
Testing the behaviour
The payoff is that tests need no patching at all:
def test_sync_skips_current_files(tmp_path):
(tmp_path / "a.txt").write_text("x")
bucket = FakeBucket()
bucket.current.add(str(tmp_path / "a.txt"))
result = core.sync_directory(tmp_path, bucket=bucket)
assert result == SyncResult(uploaded=0, skipped=1)
def test_command_uses_the_injected_services(cli):
services = Services(settings=Settings(), bucket=FakeBucket(), clock=lambda: FIXED_TIME)
result = cli.invoke(app, ["sync", "data"], obj=services) # runner passes ctx.obj
assert result.exit_code == 0
assert services.bucket.uploaded == ["data/a.txt"]
CliRunner.invoke accepts an obj= argument that becomes ctx.obj, which means a command test can
supply the whole service graph without touching the callback. That single capability is what makes
the context-object pattern comfortable to test.
Retrofitting this into existing code
Most codebases arrive at this pattern from the opposite direction: a module-level client that grew because it was the obvious place to put it. The refactor is safe if you do it in three steps.
Step one: add the parameter, keep the global. Give the function an optional parameter that falls back to the existing global. Nothing calling it needs to change, and tests can start passing a fake immediately.
_client = S3Bucket() # the existing global, untouched for now
def sync_directory(source: Path, *, bucket: BucketClient | None = None) -> SyncResult:
bucket = bucket or _client
...
Step two: push construction upwards. Build the client in the callback, put it on the context, and pass it from each command. The global is now unused by anything except the fallback.
Step three: delete the global. Make the parameter required, or keep the None default and
construct inside — but with nothing at module level. The import-time side effect disappears, which
also removes it from your start-up cost.
Each step is a separate commit, the tests pass throughout, and at no point does the codebase have two competing sources for the same object.
The one thing to resist is doing all three at once across a dozen call sites. Half-finished injection — some callers passing, some relying on a global — is harder to reason about than either end state.
Conclusion
Build collaborators once at the boundary, hand them down explicitly, and describe the seam with a protocol. Two habits, no framework, and the result is a codebase where tests construct what they need instead of patching what they cannot reach.
Frequently asked questions
Is a dependency injection library worth it?
Rarely for a CLI. Libraries like dependency-injector solve wiring problems that appear in
long-lived applications with many interdependent services and lifecycles. A command-line tool
constructs a handful of objects once and exits; a frozen dataclass does the same job with no
indirection to learn.
How do I inject into a command that Click constructs?
Through the context, which is exactly what it exists for. The callback assigns ctx.obj, commands
read it, and the test runner can supply it directly with obj=. There is no need for the framework
to know about your objects.
What about expensive collaborators only one command needs?
Build them lazily. Either give the Services object a cached property, or have the command
construct that one collaborator itself from settings. Constructing a data-warehouse client in the
callback so that --help pays for it is a common and avoidable mistake.
Does this conflict with lazy command loading?
No — they compose. The registry imports a command module on demand, and that module's function still receives services from the context. What you should avoid is constructing collaborators at module import time, which would defeat both patterns at once.
How do I handle a dependency needed by only some commands in a group?
Put it on the group's own callback rather than the root. A db sub-app can build a connection in
its callback, so mytool sync never opens one, and mytool db migrate still gets it through the
context chain.