Architecture

Dependency Injection Patterns for CLI Commands

Wire collaborators into Python CLI commands without globals - default parameters, context objects, protocols, and tests that need no monkeypatching.

Updated

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 Protocol so 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

Where collaborators are constructed Dependencies are built once at the boundary, placed on the context, and passed into core functions as parameters rather than imported globally. Where collaborators are constructed Callback reads settings Builds collaborators client, store, clock Command receives them from ctx Core function takes them as arguments once per run ctx.obj explicit Constructing at the boundary is what lets a test pass a fake without patching anything.

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

Four ways to supply a dependency A comparison of module-level globals, default parameters, context objects and a container for supplying collaborators to command line code. Four ways to supply a dependency Approach Testable? Use when Module-level global no — needs resetting never, in new code Default parameter yes — pass an override one or two collaborators Context object yes — build it in the test shared across commands A container/registry yes, with ceremony many interdependent parts A default parameter covers most CLI cases; the context covers the rest.

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 makes the seam explicit A protocol describes what a collaborator must provide, allowing the real implementation and a test fake to be used interchangeably. A protocol makes the seam explicit class BucketClient(Protocol) the contract is_current(path) implemented by both upload(path, retries) implemented by both S3Bucket the real one FakeBucket the test one Type checking catches a drifting fake Neither class inherits from the protocol The core function accepts the protocol A protocol costs a few lines and turns "the fake is out of date" into a type error.

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.