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

Three stacked CLI layers — parsing (command defs), business logic (services), and output/formatting — with a downward call arrow and an upward result arrow; 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.

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:

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