ADR-002: Code Layout — Layer-First with Kind Subdirectories
Status: Accepted Date: 2026-05-20 Deciders: Yuxing Wu Related: .specify/memory/constitution.md (Architectural Constraints), ADR-001
Context
ADR-001 commits Coffer to a Resource framework as part of spec 001-mcp-gateway. Two layouts can realise it while honouring the constitutional layered architecture (surfaces → application → domain with infrastructure underneath):
- Layer-first with kind subdirectories — each top-level layer holds kind-agnostic files at its root and one
<kind>/subdirectory per kind. - Vertical slice — a new top-level
kinds/directory, each kind a complete vertical slice with its own internaldomain/ application/ infrastructure/ surfaces/.
Both can satisfy the constitution if importlinter contracts enforce dependency direction. The choice is about navigability, conventions, and how the "extract-on-second-feature" rule plays out.
Decision
Layer-first with kind subdirectories.
backend/coffer/
├── domain/ # kind-agnostic core
│ ├── resource.py
│ ├── audit.py
│ └── mcp/ # MCP-specific value objects
├── application/
│ ├── resource_service.py # kind-agnostic CRUD
│ ├── audit_service.py
│ ├── retention_service.py
│ └── mcp/ # MCP-specific services
├── infrastructure/
│ ├── persistence/
│ ├── credentials/
│ ├── daemon/
│ └── mcp/ # subprocess, http upstream client
└── surfaces/
├── http/
│ ├── app.py # composition root
│ ├── resource_routes.py
│ └── mcp/ # MCP HTTP routes
├── cli/
│ ├── main.py
│ ├── resource_cmd.py
│ └── mcp.py
└── shim/ # coffer-mcp-shim (MCP-only by nature)Each kind is registered at the composition root via an explicit KindModule dataclass — no global registry, no import side effects, no kinds/ directory.
Consequences
Positive
- Mirrors the constitutional
surfaces → application → domain (← infrastructure)layering literally in the file system. The architecture document reads the same as the directory tree. - Familiar layout for Python/FastAPI conventions; new contributors recognise it immediately.
- When a cross-kind concern surfaces, the question is "which layer does it belong in" — one decision, naturally placed at the layer root.
- Small kinds pay no ceremony (
domain/profile.pyis a single file; in a vertical slice it would bekinds/profile/domain/profile.py). - Importlinter rules read naturally:
domain → infrastructure | surfacesforbidden*/mcp → */<other_kind>forbiddendomain/*(kind-agnostic) →domain/<kind>/*forbidden
Negative
- Code for one kind spans 4–5 directories. Mitigation: IDE search and grep make this a non-issue; the user has confirmed acceptance.
- Importlinter contracts must enforce two rule families (layered + cross-kind), not one.
Alternatives Considered
Vertical slice (kinds/<x>/{domain,application,infrastructure,surfaces}/). Rejected.
- Adds a fifth top-level concept (
kinds/) alongside the constitutional four layers; the architecture document would have to explain both. - Introduces dual-decision pain on every cross-kind extraction: "does this belong in the top-level layer, or in
kinds/<x>/<layer>/?" — two questions to answer instead of one. - Mostly fits the DDD-bounded-context / modular-monolith pattern, which is motivated by team isolation, independent deploy cadence, or microservice extraction — none of which apply to a single-user local-first OSS app.
- The "vertical slice is more discoverable" advantage collapses under IDE search; the cost (layout duality) does not.
Flat per-kind module (no internal layering within the kind). Rejected. Some kinds will grow large (the MCP kind is already projected at ~1500 lines). Importlinter inside a flat module cannot enforce the layered direction.
Plugin architecture (each kind a loadable plugin with manifest, isolation, discovery). Considered and deferred: see ADR-001. Not a layout decision in itself; relevant here only as the "heaviest" comparator we considered and rejected.