Designing for the Long Term
Designing for the Long Term
Software lasts when the boundaries are real, the API is small, and the defaults stay boring: functions, dataclasses, the standard library, tests, and a version you can explain. This chapter is a recap you can keep on the desk.
Mental model
A package boundary is a directory other code imports. Inside, you may shuffle files. Across it, names are promises.
A stable API is a handful of functions (and maybe a dataclass) with docstrings. Hidden helpers start with _ or live in a module you do not document.
Long-term Python in this book means: Python 3.14, uv, ruff, pytest, UTC in storage, pathlib, logging, subprocess.run as a list, no secrets in git, wheels from uv build.
Worked examples
Case 1: Two modules, one public function
Save as a tiny package in a temp tree and import it. Production looks the same without TemporaryDirectory.
desk/__init__.py exports label only. desk/format_ticket.py can change.
# stable_desk.py
import sys
from pathlib import Path
from tempfile import TemporaryDirectory
INIT = '''\
from desk.format_ticket import label
__all__ = ["label"]
'''
FORMAT = '''\
def label(ticket_id: int, table: int) -> str:
"""Return a one-line ticket label for the desk display."""
return f"ticket {ticket_id} → table {table}"
'''
def main() -> None:
with TemporaryDirectory() as raw:
root = Path(raw)
pkg = root / "desk"
pkg.mkdir()
(pkg / "__init__.py").write_text(INIT, encoding="utf-8")
(pkg / "format_ticket.py").write_text(FORMAT, encoding="utf-8")
sys.path.insert(0, str(root))
import desk
print(desk.label(7, 12))
print(desk.__all__)
if __name__ == "__main__":
main()Run:
uv run python stable_desk.pyOutput:
ticket 7 → table 12
['label']
Callers import desk.label. They do not import desk.format_ticket unless you say so.
Case 2: pyproject.toml is the project boundary
The long-term file at the root of a library:
[project]
name = "desk"
version = "1.0.0"
description = "Labels and shifts for a small desk."
requires-python = ">=3.14"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"That file, desk/, tests, and README.md are the repo. Everything else is extra.
Case 3: One process that still looks like the first chapter
Save as desk_open.py. A function, a print, a main guard. Types where they help. No framework.
# desk_open.py
def open_table(n: int) -> str:
if n <= 0:
raise ValueError(f"table {n}: number must be positive")
return f"opened table {n}"
def main() -> None:
print(open_table(12))
if __name__ == "__main__":
main()Run:
uv run python desk_open.pyOutput:
opened table 12
If this is enough, stop. Add a package when a second module would otherwise collide.
The trap
A “platform” on day one: a base class, a plugin registry, and a settings object for a label.
Save as too_much.py:
# too_much.py
class BaseLabel:
def render(self, ticket_id: int, table: int) -> str:
raise NotImplementedError
class DeskLabel(BaseLabel):
def render(self, ticket_id: int, table: int) -> str:
return f"ticket {ticket_id} → table {table}"
def main() -> None:
print(DeskLabel().render(7, 12))
if __name__ == "__main__":
main()Run:
uv run python too_much.pyOutput:
ticket 7 → table 12
The boring package from Case 1 prints the same string with one function. Add a class when state lives and behavior belongs to it. Add plugins when you have a second implementation, not a slogan.
The boring rule
- Export a small
__all__. Keep helpers behind it. - Store UTC, log with
logging, run children withsubprocess.run([...]), read files withPath. - Test the public functions. Lint with
ruff. Audit withuv audit. - Version in
pyproject.toml. Build withuv build. - Reflection and FFI stay at the edges.
- When in doubt, write the function from Case 3.
Try this
- In
stable_desk.py, adddesk/shifts.pywithshift_label(name)and export it from__init__.py. - In
desk_open.py, catchValueErrorforopen_table(0)and print the error. - Delete
BaseLabelfromtoo_much.pyand keep a singlelabelfunction.