Python programs stay small and single-purpose not because the language is a “scripting language” but because nothing in it enforces structure. Large, well-structured Python is Python plus a constitution plus checkers — the structure is imposed by convention and tooling, and the industry has converged on what both look like.
Links: Practicality vs. Precision — Principled LLM Code — the general thesis (accretion → big ball of mud; persistence is the fix); this page is its Python instance, Repairing LLM Code — The Two Oracles, Conservation of Complexity, Camelot From YouTube — first specimen, dj-score — the specimen’s target library, CyborgDJ, emu-harness — a plug-in shaped tool (backends × machines)
The limit is not the language. Instagram’s server is “a several-million-line Python monolith” (Meyer, Strict Modules, 2019); Kraken’s Django monolith is 27,637 modules worked on by ~400 developers (Seddon, EuroPython blog, 2023); Dropbox typed “over 4 million lines” (Lehtosalo, 2019).
The cause is the absent enforcement. No compiler checks types; every module is importable from everywhere; nothing is private; a file can grow without limit. A 50-line script and a 2M-line system are written identically — so the default trajectory is Foote & Yoder’s Big Ball of Mud (PLoP 1997; PLoPD4, 2000 — laputan.org/mud). Cosmic Python’s framing: “A big ball of mud is the natural state of software.” The symptom is “sameness of function” — UI handlers doing domain logic, logic classes doing I/O.
The remedy is to supply what the language omits — each row below replaces a missing language feature with a convention + a checker:
| Missing from the language | Supplied by | Checker |
|---|---|---|
| a package boundary | one installable package, src layout, pyproject.toml (PEP 621) |
the install itself |
| a compiler / type system | type hints + @dataclass instead of dicts |
mypy / pyright in CI |
| interfaces | typing.Protocol ports (PEP 544) — structural, no inheritance |
the type checker |
| visibility / layering | dependency direction (domain ← services ← adapters/entrypoints) | import-linter, tach |
| an extension mechanism | registry + plugins; entry points (PyPA spec) | the loader |
| a spec | the test suite (fakes, not mocks) | pytest |
myapp/
pyproject.toml # metadata, deps, tool config — one place
src/myapp/ # src layout: tests can't import the working copy by accident
__init__.py # the PUBLIC API — re-export what callers may use
domain/ # pure logic: dataclasses, rules, no I/O
services/ # use cases: one function per user action, orchestrates domain via ports
adapters/ # the outside world: files, DB, HTTP, audio, emulators
entrypoints/ # CLI / GUI / web — thin: parse input, call a service
bootstrap.py # composition root — the ONE place concrete adapters get wired in
config.py # env-derived values, read via functions; only bootstrap imports it
tests/unit/ tests/integration/ tests/e2e/
This matches Cosmic Python’s template (domain / service_layer / adapters / entrypoints) with bootstrap.py from ch. 13. Why src/: the current directory is first on sys.path, so a flat layout silently imports the dev copy and hides packaging mistakes (Packaging Guide).
The one rule that matters most: dependencies point inward. domain imports nothing from the outer layers. Circular imports are usually a symptom of violating this — a boundary bug, not something to patch with a function-local import.
Architecture Patterns with Python (Percival & Gregory, 2020; free online). Thesis: dependency inversion — business logic and infrastructure depend on abstractions, so each can change independently; Flask/SQLAlchemy become “minor implementation details.” The book is unusually honest about cost — every chapter ends in a pros/cons table, and the repeated warning is simple CRUD doesn’t need this.
| Pattern | Solves | Skip it when |
|---|---|---|
| Domain model | rules scattered and unreadable | logic is trivial |
| Functional core, imperative shell (ch. 3) | I/O tangled with decisions; mock.patch tests | — (always cheap) |
| Service layer (ch. 4) | no single home for use cases; HTTP/UI holding logic | controllers can hold it (“fat models, thin controllers”) |
| Repository / Unit of Work | storage coupled to logic; transaction boundaries implicit | the ORM already abstracts it; no persistence to speak of |
| Aggregate | invariants across many objects; concurrency | single-user, no shared state |
| Events + message bus (ch. 8–9) | side-effect “goop” (email, logging) wedged into features | flow becomes implicit and hard to trace |
| CQRS (ch. 12) | reads and writes with different needs | reads and writes share concepts |
DI via bootstrap.py |
hidden dependencies, import-time globals | — frameworks only pay off with deep dependency chains |
Heuristics worth keeping: a function whose description needs “and” or “then” has two responsibilities; “when X, then Y” phrasing is an event. “Make the change easy; then make the easy change.” Fakes over mocks — if an abstraction is hard to fake, it is too complicated. Every line of test code is coupling.
From the epilogue — the part that matters for a pile of one-off tools:
clients → territories → core, 40+ contracts, 15 ignored imports left (blog). Contract types: forbidden, protected, layers, independence, acyclic-siblings, custom (docs). This is a machine-checked architecture constitution — the mechanism of Principled LLM Code in shipping form.components/<domain>/ with required manifest.json + __init__.py; platforms (sensor.py, light.py) loaded by name (file structure). ~1,548 component folders on dev (our count via the GitHub API, not an official figure).Any/cast/bare ignores discouraged (docs).utils.py / helpers.py as a dumping ground — the one-use-tool pile hidden in a single file.detect_events / event_detect) — re-derivation drift (Principled LLM Code §3)..py files (~14.3k lines), flat layout, no pyproject, no tests/; two files hold ~42% of the code (audio_ui.py 3,449, camelot_from_youtube.py 2,585); name-twins detect_events.py / event_detect.py / detect_stem_events.py and three visualize_* modules. Target shape (Chris, 2026-10-21): CLI tools are fine — but they must share one library; many of the tools are variations on one theme, and some are likely obsolete, superseded by better methods found later. So the audit sorts tools into keep as thin entrypoint / fold into library / retire. Note it is an analysis pipeline, not a transactional app — repository/UoW/aggregates/CQRS are likely overkill; layering, functional core, service layer, bootstrap.py, and fakes are the applicable subset.
Audit done (2026-10-21): the variations-on-a-theme are measurable — detect_bpm_and_beats byte-identical in three files, six separate beat_track call sites, the downbeat scorers copied into two files, three readers of one undocumented cache schema, three generations of the event detector; matplotlib imported at module level by the analysis code. Outcome: the library is a new repo, dj-score, and camelot stays the UI (an app that depends on it, as CyborgDJ will). Verdicts per tool + the copy → repoint → archive order (amended 2026-10-21: retire means archive, not purge, so a superseded tool’s artifacts stay reachable; and the library repo holds code only, with generated data and fixtures kept out) live in the repo’s MIGRATION.md (single source). Constitution from day one: one import-linter layers contract + one forbidden contract (core never imports matplotlib/tk/pygame/torch/pyrekordbox/IO), each checked to actually fire on a planted violation. This partly answers the first two Open Questions; whether it stops accretion across sessions is now testable.
Outcome (2026-10-22): Two Libraries Out of a Grown Tool. The recipe held across two sessions and ~30 commits: dj-score (analysis) and dj-render (rendering, split from CyborgDJ along the dependency line), with camelot as the UI, CyborgDJ as the controller, and one shared data store. No output changed: oracles ran the original from git history, and the renders stayed byte-identical. The contracts fired on real violations (a service importing an adapter; a planted librosa import in the renderer). The main lesson for §4: an oracle must be shown able to fail. Green suites passed vacuously five different ways.na1, ro3k, gk, na2, rot3k2, bk, GemFire, LEmp), the SNES line, game-annotation. A shared layer already exists in part — koei-nes (engine/VM toolchain) and nes-render (PPU) graduated out per the projects SDK — so the audit question differs from camelot’s: not “extract a library” but “what is still re-derived per game repo that should live in the shared layer, and which per-game tools are variations on a theme”. Same method: imports + use cases per tool, measured duplication across repos, keep / fold / retire, one constitution. Shape: a toolset, not a tool (Chris: the tools were made self-contained but share a hierarchy) — nes-render (PPU) → koei-nes (engine/VM) → per-game repos, with koei-nes/tools/games.toml already acting as a per-game profile registry. That is §1’s plugin row (registry + plugins) more than camelot’s single library: the audit should check that the hierarchy is a real dependency direction (enforceable per layer) and that each game is a profile plus game-specific tools, not a fork.pyproject + mypy + one import-linter layers contract? Is that enough to stop accretion across sessions? (Evidence 2026-10-22: dj-score held across two sessions with pyproject + import-linter + oracle tests and no mypy. One case, so still open.)plots, stems, rekordbox). Infinite Lagrange is still untested.