Module layout
The folder tree of an API and of one module, what goes in each folder, and how files are named. Read it when you add a module or wonder where a file belongs.
How it works
The API
src/
server.ts # create Fastify, global hooks, register each module
config/
env.ts # secrets from the environment, Zod-validated
settings.ts # non-secret settings with defaults
shared/
db/ # pool, withTransaction, migration runner
errors/ # AppError, codes, HTTP mapping
result.ts # Result<T, E>
auth/ # verify token, guards: requireUser, requireStaff
logger.ts
infrastructure/ # clients more than one module uses
modules/
<feature>/
One module
modules/orders/
index.ts # public functions other modules may call, and register()
api/
orders.routes.ts # REST, or orders.resolvers.ts for GraphQL
orders.schemas.ts # Zod schemas for input and output
use-cases/
create-order.ts
cancel-order.ts
repositories/
orders.repo.ts
ports/ # only if the module calls an outside service
payments.port.ts
adapters/
stripe-payments.ts
db/
schema.ts # this module's tables
README.md # what the module owns, its tables, its events
Naming
| Thing | Name | Not |
|---|---|---|
| Module folder | The domain, singular or plural, kebab-case: orders, prayer-requests | Project phases: slice-8, phase2 |
| Use-case file | The action: cancel-order.ts | order-service.ts with 40 functions |
| Route file | <module>.routes.ts or <module>.resolvers.ts. Split by sub-area when it passes about 300 lines: orders-admin.routes.ts | slice-3-resolvers.ts |
| Repository | <table group>.repo.ts | db.ts |
| Test | Next to the file: cancel-order.test.ts | A distant tests/ mirror tree, except for end-to-end flows |
Rules
Rule
index.ts stays small: it exports public functions and a register() that mounts routes.
If wiring passes about 200 lines, split it by sub-area. A 1,500-line composition file is a
sign the module is two modules.
Rule
Name files after the domain, never after the sprint or the delivery slice. Six months later nobody remembers what "slice 8e" was.
Rule
Every module has a README listing what it owns, its tables and the events it publishes. Keep it true; a README that lists folders that no longer exist is worse than none.
Mistakes we made
- Resolver files named
slice-3toslice-11, includingslice-8atoslice-8f. Nobody could find a resolver without searching. - A settings file grew to over 4,000 lines. Split settings by module, next to the module, and compose them.
- Docs listed modules that did not exist and missed ones that did, after renames. Update the README in the same pull request as the rename.
- The package name kept the old project name after the repo was renamed.
Checklist
- Module folder named after the domain.
- One use-case per file, named after the action.
-
index.tssmall, exports only public functions. - Module README current.