Architecture

Structuring Multi-Command Python CLIs

Structure multi-command Python CLIs with clear separation of parsing, business logic, and output layers for testable, maintainable command trees.

Updated

A multi-command CLI grows messy fast when the command functions do everything: parse flags, talk to the database, format a table, and print it. The fix is a layered architecture — keep the parsing layer (Click or Typer) thin and push the real work into a service layer it calls. This overview explains the layered model and routes you to the deep dives on project layout and entry points.

TL;DR

  • Split every command into three layers: parsing (the framework), business logic (a plain-Python service layer), and output/formatting (rendering).
  • Command functions stay thin — they parse arguments, call a service function, and hand the result to a renderer. No business logic in the command body.
  • This separation is what makes commands testable: you unit-test the service with plain values and the command with CliRunner, never spinning up a subprocess.
  • A predictable package layout (commands/, core/, io/) keeps a tree of dozens of commands navigable.
Separate parsing, logic, and output Separate parsing, logic, and output Parsing layer command defs (Click / Typer) Business logic services (plain Python) Output / formatting tables, JSON, text call result each layer testable in isolation

The layered model

Think of each command as a pipeline with three responsibilities, owned by three different modules:

  1. Parsing / command layer — Click or Typer turns argv into typed Python values. This is the only layer that knows about decorators, Context, exit codes, and echo.
  2. Business logic / service layer — plain functions and classes that take ordinary arguments and return ordinary data. No framework imports here. This is where the actual work lives.
  3. Output / formatting layer — turns the service's return value into text, a table, or JSON. Swapping --format json for a Rich table touches only this layer.

The discipline is simple: a command function should read like a three-line summary of itself — parse, delegate, render.

import click
from reporter.core.sales import summarize
from reporter.io.render import render_summary

@click.command(name="report")
@click.argument("amounts", nargs=-1, type=float)
def report_command(amounts: tuple[float, ...]) -> None:
    """Summarize AMOUNTS. Thin wrapper: parse -> service -> render."""
    summary = summarize(list(amounts))      # business logic
    click.echo(render_summary(summary))     # formatting

summarize knows nothing about Click; render_summary knows nothing about argument parsing. Each can change independently.

Why this separation makes commands testable

When business logic lives inside the command body, the only way to exercise it is through the framework — you build an argument list, invoke the runner, and assert on stdout. That works, but it couples every logic test to flag names and output formatting, and it's slow to reason about.

What belongs in the command layer Guidance on what a command function should contain and what should live in the core layer instead. What belongs in the command layer In commands/ Reading options and arguments Turning inputs into a call to core Formatting results for a terminal Mapping exceptions to exit codes Push down into core/ Business rules and calculations HTTP calls and database access File parsing and schema validation Anything you want to unit test without a runner If a test needs CliRunner to reach the logic, the logic is in the wrong layer.

Pull the logic into a service layer and most of your tests become plain function calls:

from reporter.core.sales import summarize

def test_service_pure() -> None:
    s = summarize([10.0, 20.0, 30.0])
    assert s.total == 60.0 and s.average == 20.0

You still write a thin smoke test per command with CliRunner to confirm the wiring — that flags map to the right service call and the exit code is right — but the bulk of your coverage targets pure functions that are trivial to test. This is the single biggest payoff of the layered model.

A src/ layout with one package per layer scales cleanly:

One module per command A package layout where cli.py registers commands, each command lives in its own module under commands, and shared logic lives in core. One module per command mytool/cli.py registers commands, owns nothing else commands/sync.py flags in, core call out commands/status.py flags in, core call out core/ plain functions, no CLI imports adding a command means adding one file and one registration line The rule that keeps this honest: core/ must never import click or typer.
src/reporter/
├── cli.py              # root group, wires subcommands together
├── commands/           # one thin module per command (parsing layer)
│   ├── report.py
│   └── deploy.py
├── core/               # business logic — no framework imports
│   └── sales.py
└── io/                 # formatting / rendering layer
    └── render.py
tests/
├── test_sales.py       # fast unit tests against core/
└── test_report.py      # CliRunner smoke tests against commands/

The root cli.py does nothing but assemble the tree:

import click
from reporter.commands.report import report_command

@click.group()
def cli() -> None:
    """reporter: example layered CLI."""

cli.add_command(report_command)

As the tool grows, you add files under commands/ and core/ — the shape of the project never changes, so contributors always know where a new command goes.

Go deeper

Wiring the three layers together

The layered model is easy to agree with and easy to drift away from. What keeps it real is a concrete shape for each layer and one rule about imports.

The command layer reads options, calls one function, and formats what comes back:

# src/mytool/commands/sync.py
import typer
from pathlib import Path
from typing import Annotated

from mytool.core import sync as core
from mytool.errors import RemoteUnavailable

app = typer.Typer()

@app.command()
def sync(
    source: Annotated[Path, typer.Argument(exists=True, file_okay=False)],
    retries: Annotated[int, typer.Option(min=1, max=10)] = 3,
    dry_run: Annotated[bool, typer.Option(help="Report without uploading.")] = False,
) -> None:
    """Sync SOURCE to the configured bucket."""
    result = core.sync_directory(source, retries=retries, dry_run=dry_run)
    typer.echo(f"{result.uploaded} uploaded, {result.skipped} skipped")

The logic layer is plain Python. It takes values, returns values, and raises domain exceptions. It has no idea a terminal exists:

# src/mytool/core/sync.py
from dataclasses import dataclass
from pathlib import Path

from mytool.errors import RemoteUnavailable
from mytool.io.bucket import BucketClient

@dataclass(frozen=True, slots=True)
class SyncResult:
    uploaded: int
    skipped: int

def sync_directory(source: Path, *, retries: int = 3, dry_run: bool = False) -> SyncResult:
    client = BucketClient()
    uploaded = skipped = 0
    for path in sorted(source.rglob("*")):
        if not path.is_file():
            continue
        if client.is_current(path):
            skipped += 1
            continue
        if not dry_run:
            client.upload(path, retries=retries)
        uploaded += 1
    return SyncResult(uploaded=uploaded, skipped=skipped)

The I/O layer owns everything that talks to the outside world — HTTP sessions, database handles, file formats — behind an interface the logic layer can fake in a test.

And the rule: imports only ever point downwards. commands/ may import core/; core/ may import io/; nothing imports upwards, and nothing below commands/ ever imports typer or click. One upward import is all it takes to make the logic untestable without a runner, and it is the kind of change that sails through review because it is one line.

A cheap way to enforce it, once the layout is worth protecting:

def test_core_does_not_import_a_cli_framework():
    for path in Path("src/mytool/core").rglob("*.py"):
        source = path.read_text()
        assert "import typer" not in source
        assert "import click" not in source

What the split buys you at test time

The payoff shows up as speed and as the kind of failure you get.

# a unit test — no runner, no parsing, milliseconds
def test_skips_files_already_current(tmp_path, fake_client):
    (tmp_path / "a.txt").write_text("x")
    fake_client.mark_current(tmp_path / "a.txt")

    result = sync_directory(tmp_path)

    assert result == SyncResult(uploaded=0, skipped=1)
# a command test — proves the wiring, not the behaviour
def test_dry_run_flag_reaches_the_core(monkeypatch, tmp_path):
    seen = {}
    monkeypatch.setattr(core, "sync_directory", lambda src, **kw: seen.update(kw) or SyncResult(0, 0))

    result = CliRunner().invoke(app, ["sync", str(tmp_path), "--dry-run"])

    assert result.exit_code == 0
    assert seen["dry_run"] is True

Two tests, two jobs. The first can be parametrised over a dozen edge cases without paying for argument parsing each time. The second exists so that a renamed flag or a mis-plumbed default fails loudly, and there is exactly one of it per command rather than one per behaviour.

The failure modes differ too. When a unit test breaks, the logic changed. When a command test breaks, the interface changed — which is exactly the signal you want, because the interface is the part users depend on.

Growing the tree without growing the pain

A layout that works at three commands and collapses at fifteen usually collapses in the same two places.

The registration file. Keep cli.py to imports and registration. The moment it grows a conditional — "if the user passed --legacy, register these other commands" — that logic wants to be a function in core/ returning a list, with cli.py iterating over it.

Shared helpers. The second command that needs "resolve the config path" is the moment to put it somewhere both can see. Resist a general utils.py; name modules for what they are about (core/paths.py, core/naming.py) so that the import graph tells you something. A utils module is where unrelated functions accumulate until nobody can tell what depends on what.

When the tree reaches the point where --help no longer fits on a screen, add a level rather than more top-level commands. mytool db migrate and mytool db seed under a db group leave the root help listing five subject areas instead of thirty verbs, and each group can carry its own callback for setup that only its commands need.

The last thing worth doing early is deciding how commands are discovered. Explicit registration — importing each module and adding it — is obvious and fine up to a few dozen commands. Beyond that, or as soon as start-up time starts to matter, a registry of dotted paths resolved on demand keeps --help instant no matter how many commands exist; that pattern is covered in lazy loading subcommands.

Errors, exits and the boundary that owns them

The layering has one more consequence worth spelling out: because core/ never touches the CLI, it cannot decide what a failure looks like. It raises; something above it decides.

# src/mytool/errors.py — one small module both layers can import
class MytoolError(Exception):
    """Base class for every expected failure."""

class ConfigError(MytoolError):
    """The configuration is missing or invalid."""

class RemoteUnavailable(MytoolError):
    """A service the tool depends on is unreachable."""
# src/mytool/main.py — the only place that knows about exit codes
import sys
import typer

from mytool.cli import app
from mytool.errors import ConfigError, MytoolError, RemoteUnavailable

EXIT = {ConfigError: 78, RemoteUnavailable: 69, MytoolError: 1}

def main() -> None:
    try:
        app()
    except MytoolError as exc:
        typer.secho(str(exc), fg=typer.colors.RED, err=True)
        sys.exit(next(code for cls, code in EXIT.items() if isinstance(exc, cls)))

Three things fall out of this. Commands never call sys.exit, so no command has an opinion about what code a failure deserves. The mapping is a dictionary you can read in ten seconds and test directly. And a domain exception raised deep in core/ produces the same user-facing behaviour whether it was reached through a command, a script importing the package, or a scheduled job.

The one case that needs care is an unexpected exception — a bug rather than an expected condition. Let it produce a short message plus a hint, and put the traceback behind a flag:

    except Exception:
        if "--debug" in sys.argv:
            raise
        typer.secho("internal error — re-run with --debug for the traceback", err=True)
        sys.exit(70)

Making the layout discoverable

A structure only helps if the next person can see it. Three cheap habits do most of that work.

Name modules after the subject, not the pattern. core/sync.py and io/bucket.py tell you what lives there; core/services.py and io/helpers.py do not, and they become the modules everything imports.

Mirror the tree in tests/. When tests/core/test_sync.py sits opposite src/mytool/core/sync.py, finding a file's tests is mechanical and a moved module drags its tests along in the same commit. It also makes a coverage gap visible as a missing file rather than as a number.

Write the layer rule down. Two sentences in the README or a short ARCHITECTURE.md — what each directory is for, and that imports point downwards — is enough to make the rule reviewable. Without it, the first upward import is a reasonable-looking line in a pull request; with it, it is a discussion.

Once those are in place the structure maintains itself, because every new command has an obvious place to go and every new helper has an obvious home. That is the real return on the layering: not elegance, but that the twentieth command costs the same as the third.

Retrofitting the layers into an existing tool

Most people arrive here with a working CLI whose logic already lives inside the command functions. The refactor is safe if you do it in the order that keeps the tool running.

Step one: add the directories, move nothing. Create core/ with an empty __init__.py. This sounds trivial and it matters, because it gives the next steps somewhere to land without a decision each time.

Step two: extract one command's body. Pick the command with the least branching. Cut the body into a function in core/, leave the command calling it, and run the tests. The signature falls out naturally: whatever the body used from the parameters becomes an argument, and whatever it printed becomes a return value.

Step three: replace prints with returns. This is where most of the value is. A body that prints as it goes has to be reorganised so the caller decides what to display — usually into a small result dataclass. Once that is done the function is testable, reusable and easy to reason about.

Step four: repeat, cheapest first. Each command is an independent commit. There is no big-bang step and no point at which the tool is half-migrated in a release.

Two things make the process go faster. Add the import-direction test early, so nothing new points the wrong way while you work. And resist tidying the extracted functions on the way out — move first, improve second, in separate commits, so that a behaviour change is never hidden inside a move.

Frequently asked questions

Is three layers overkill for a small tool?

For a single-command script, yes — one file is the right answer. The split earns its keep at the second command, because that is when logic starts being shared and when "I want to test this without invoking the CLI" first comes up. Adding the layers later is a mechanical refactor; untangling logic that grew inside three command bodies is not.

Where do shared options like --config live?

On the root callback, declared once, with the result placed on the context. Repeating the option on every command means the help text drifts, the default drifts, and every command re-implements the loading. If only some commands need an option, a shared annotated type or a small decorator keeps the declaration in one place without putting it on commands that do not want it.

How do I keep core/ from needing the CLI's settings object?

Pass values, not the object. A core function that takes retries: int can be called from anywhere; one that takes a Settings instance drags the whole configuration model into every caller and every test. Let the command unpack what it needs from settings and hand the pieces down.

Should each command module define its own Typer app?

It is the tidiest arrangement for groups: a subject-area module owns an app and its commands, and cli.py mounts it with add_typer. For single leaf commands, exporting the function and registering it centrally is simpler. Either way, the module should be importable without side effects beyond defining things — no config loading, no network calls at import time.

What about a main() function — is it still needed?

Yes, as the place where the error boundary and the exit code live. The entry point in pyproject.toml points at main(), which wraps the app in a try/except, maps domain exceptions to documented exit codes, and calls sys.exit exactly once. Commands then raise instead of exiting, which keeps the mapping reviewable in one file.

Does this layout hurt start-up time?

Only if cli.py imports every command module eagerly, and that is a separate, fixable problem. The directories themselves cost nothing — Python does not pay for a package it never imports. What costs is what those modules import at the top level, which is why the layering and the lazy-loading pattern complement each other: small, focused modules are exactly what makes resolving a single command on demand practical, because each module pulls in only what its own command genuinely needs rather than the union of everything the tool can do.