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 hub 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.