Clean Architecture
Overview
Use this skill to decide three things during architecture work:
- Where each responsibility belongs.
- Which dependencies are allowed.
- How much structure is justified at the current size.
Prefer a clean core: domain rules and use-case orchestration stay separable from real-world effects; effects enter through explicit boundaries; important domain values use semantic types when invalid primitives would cause misuse; and abstractions exist only when they protect testing, substitution, or boundary clarity.
Do not add abstractions for speculative future shape. Minimal abstractions are preferred unless an interface, type, boundary, or package protects testing, substitution, dependency direction, or reader clarity today.
The four-zone vocabulary is a practical carving, not a natural law: it separates domain meaning, concrete effects, inbound surfaces, and composition. If a repo uses different names, preserve those roles and dependency direction rather than forcing these labels.
Treat names as flexible when a repo has established vocabulary, but keep these boundary semantics stable:
entrypointsare inbound ways the outside world calls the program: CLI, HTTP, jobs, hooks, commands, routes, handlers, presenters, argument parsing, and wire-format parsing when needed. Inbound surfaces and transport-specific request/response code live here.startupis the composition root: build concrete dependencies, select implementations, and wire adapters into core workflows.coreis what the system means and does: entities, use cases, interfaces, policies, deterministic transformations, and shared domain errors.adaptersis what the program talks to or controls, grouped by external system or mechanism: DBs, filesystems, clients, host apps, subprocesses, telemetry stores, reporting output, embeddings, and local state. Outbound implementations live here.
If architecture-test.md is available, use it for expanded smell lists, refactor sequences, and prompt templates; this skill must still contain the minimum placement and boundary tests needed to proceed without it.
Invariant vs. Expression
This skill has two layers. Confusing them is the most common failure mode.
The invariant must hold at every size:
- Dependencies point inward toward domain logic.
- Pure rules are separable from effects.
- Concrete effect mechanisms do not enter core; when core must orchestrate an effect, it depends on parameters, callbacks, or domain-owned interfaces chosen at the lightest useful expression.
- Invariants live with the type that has them.
The expression scales with service size:
- Whether zones are files, modules, directories, or separate crates/packages.
- Whether folder names are literal (
core/,adapters/) or implicit. - How deeply nested the structure is.
The invariant is non-negotiable. The expression is chosen to make the invariant visible at the current scale. Pick the lightest expression that makes the invariant legible. Promote up when legibility breaks.
Escalation Ladder
The four zones describe roles, not mandatory folders. How they are expressed depends on the service.
Stage 1 - Single-module service
- Fits: scripts, small CLIs, single-responsibility binaries, pure algorithm libraries; typically under ~500 LOC.
- Expression: one or two files. Pure logic and effects separated within the file or across two files. Traits/interfaces defined alongside the domain types that own them.
- Do not create: dedicated zone folders, separate startup module, interfaces directory.
Stage 2 - Flat module layout
- Fits: small services with a single responsibility; roughly 500-2,000 LOC; few effects.
- Expression: flat
src/with named modules, such asdomain,adapters, andmain. Domain types and interfaces in one module, adapter implementations in another, wiring inmainor equivalent. - Do not create: subdirectories per zone.
Stage 3 - Zone subdirectories
- Fits: services with multiple responsibilities, several adapters, or growing past the point where a flat module list is legible. Often this appears beyond ~2,000 LOC, but promote earlier only when responsibilities or adapters make the flat shape ambiguous.
- Expression: create explicit zone subdirectories only for roles with real substance. A common shape is
src/core/,src/adapters/, andsrc/entrypoints/, withmainor equivalent as startup, but keep a role as a file or flat module when a directory would be empty, near-empty, or less informative than the file name.
Stage 4 - Workspace crates / packages
- Fits: multiple binaries sharing a domain, or any service where compiler-enforced dependency direction is worth the overhead.
- Expression:
coreis its own crate/package and literally cannot import adapters. Direction is enforced at the build-graph level.
Promotion triggers
Promote up one stage when the current stage stops making the invariant legible, not on a calendar or LOC count alone.
Concrete triggers:
- A reader cannot tell where to look for a piece of logic in under ~10 seconds.
- A module's internal boundaries have become unclear.
- More than one binary needs to share domain code.
- Boundary violations have started appearing because the structure does not make the right placement obvious.
LOC ranges are sanity checks, not promotion rules. Role count, effect count, number of binaries, and boundary legibility outrank size.
Operational legibility test: given one new responsibility, an agent should be able to name its role and destination without opening more than three files and without choosing between two equally plausible homes. Legibility has broken when peer modules mix unrelated reasons to change, the same kind of rule appears in multiple zones, a responsibility requires hunting through unrelated files, or a corrected boundary violation reappears because the current shape does not make the allowed placement obvious.
Never promote speculatively. The lightest expression that works is the right one.
Form Matches Content
Do not create empty or near-empty zones. A folder with one file or no files signals that the form is lying about the content; a reader sees adapters/ and expects substance.
Specifically:
- No empty
core/interfaces/directory if there are no interfaces. - No empty
adapters/directory if there is no adapter code. - No speculative
entrypoints/http/if the service only exposes a CLI. - No
core/errors.*file holding a single error that could live with its use case.
If a stage of the ladder forces a near-empty zone, you are at the wrong stage. Drop down.
This is not aesthetic preference. Empty zones increase navigation cost, mislead pattern-matching agents, and erode trust in the structure. Elegance here is correctness.
Core Categories
Use these categories only when a repo is at Stage 3+ or otherwise has a real core package/directory. They are buckets for substantial core content, not mandatory folders.
core/entities: domain state types and semantic value objects. Enforce invariants at construction and state-transition boundaries when invalid values should not travel through the system.core/use_cases: workflows plus their localrequest.*,result.*, and use-case-specificerrors.*. Requests and results are not persistence models, wire payloads, or schemas. Use cases orchestrate work without owning every rule.core/interfaces: outbound behavior interfaces the core needs from outside, such as repositories, clocks, ID generators, query adapters, external clients, and units of work. Create these only when they protect testing, substitution, or dependency direction today.core/policies: pure rules, decisions, calculations, and cross-aggregate invariants over already-loaded values.core/errors.*: shared expected errors only. Keep errors used by one workflow beside that use case.
Do not create these buck