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.
The layered model
Think of each command as a pipeline with three responsibilities, owned by three different modules:
- Parsing / command layer — Click or Typer turns
argvinto typed Python values. This is the only layer that knows about decorators,Context, exit codes, andecho. - 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.
- Output / formatting layer — turns the service's return value into text, a table, or JSON. Swapping
--format jsonfor 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.
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 recommended package layout
A src/ layout with one package per layer scales cleanly:
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
- Structuring a large Python CLI project
— the
src/layout in depth, namespace packages, lazy command loading for startup time, and scaling to dozens of commands. - Best practices for Python CLI entry points
— wiring
[project.scripts], a cleanmain(), and thepython -mfallback.
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.
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.