Decisions
Generated application layout
Status: Accepted (2026-09-14) · Amended by: ADR-0032
Context#
The generated app is what developers see every day. It must be easy to navigate, keep business rules free of HTTP and database details, scale to teams, and make its rules enforceable by tooling. Two candidates were considered: layers as files inside one package per feature, or layered sub-packages per module (the style the maintainer used successfully in a previous production API).
Options#
- Flat feature packages:
handler.go,service.go,store.goin one package. - Layered modules:
domain/,usecase/,repository/,delivery/sub-packages per module. - Global layers:
handlers/,services/,repositories/across all features.
Decision#
Option 2, with rules that avoid its common pitfalls.
text
internal/
├── app/ composition root: build, wire, run, shut down (only env reader)
│ └── architecture_test.go
├── modules/<module>/
│ ├── module.go wires the module's layers; exposes Routes(), Jobs()
│ ├── domain/ entities, value objects, rules, domain errors, events (stdlib only)
│ ├── usecase/ application logic; ports.go defines the interfaces it needs
│ ├── repository/ adapters implementing ports: hand-written SQL with pgx, one file per operation (ADR-0032)
│ └── delivery/ HTTP adapter: Huma operations and request/response types ↔ use cases (ADR-0027)
└── jobs/ emails/ wellknown/Dependency rules (enforced by architecture_test.go in every generated app)#
text
delivery ──► usecase ──► domain
repository ─► usecase (implements ports) + domain
module.go ──► its own layers
internal/app ──► each module.go
modules never import other modules| # | Rule | Prevents |
|---|---|---|
| 1 | Domain structs have no json or db tags; delivery maps to its request/response types, repository scans into domain fields or maps from unexported row structs | API contract changing when domain or schema changes |
| 2 | No package-level setters or global state; everything through constructors | Untestable, unsafe shared state |
| 3 | Ports live in usecase/ports.go, not in repository/ | Use cases depending on adapters |
| 4 | No imports between modules; cross-module needs go through consumer ports wired in internal/app; IDs passed as values | Tangled modules |
| 5 | Transactions via a TxManager port; use cases never import pgx | Database leaking into business logic |
| 6 | Generated code uses clear import aliases (projectdomain, projectusecase) | Ambiguous domain/usecase imports |
| 7 | Business rules in domain constructors and methods; use cases coordinate permissions, transactions and audit | Anemic pass-through layers |
| 8 | No platform/ folder; cross-cutting concerns come from the apistock library | Copied infrastructure drifting per app |
| 9 | Only internal/app reads environment variables | Hidden configuration |
| 10 | Tests per layer: domain (unit), usecase (fake ports), repository (real PostgreSQL), delivery (httptest against the OpenAPI contract) | Slow, fragile test suites |
Commands and other directories#
cmd/api,cmd/worker(optional separate job process),cmd/migrate,cmd/seed.api/openapi.jsonexported from code (ADR-0027), plus generatedpostman_collection.jsonandllms.txt.db/migrations(single ordered history); SQL lives next to the repository method that runs it (ADR-0032).test/e2e,test/testutil;docs/with an ADR folder for the app's own decisions.compose.yaml,Dockerfile,.env.example,apistock.yaml,apistock.lock,ARCHITECTURE.md,AGENTS.md.
Why#
- Package boundaries make the dependency rule enforceable, not just a convention.
- The structure is familiar to teams using DDD and layered architecture and is easy for AI tools to navigate.
- Folders name business capabilities.
Trade-offs#
- More packages and files per module; mapping code between layers (generated).
- Thin modules (
auth,ops) keep all four layers for consistency even when some files are short.
Consequences#
ARCHITECTURE.mdin every app explains the layers, rules and how to add a module.- A feature that outgrows one module is split into two modules, not into deeper nesting.
Was this page useful?Open an issue