Mirrored from the repository
This page is CONVENTIONS.md, rendered here. It is generated
on every build, so edit the source rather than this copy — the pencil above
already points there.
Conventions¶
Every template in stacks/ obeys this document. It is the reason twenty
templates across nine languages read like one product rather than twenty
personal styles.
If a template has to break a rule here, it breaks it loudly — with a comment saying why — rather than quietly.
1. Names are spelled out. Always.¶
No abbreviations, no initialisms, no clipped words. Not in a directory name, a file name, a variable, a function, a package, a script, or a service name in a compose file.
| Write this | Not this |
|---|---|
database |
db, Db, DB |
configuration / config (only as a directory name, never mixed) |
cfg, conf |
repository |
repo |
request |
req |
response |
res, resp |
button |
btn |
navigation |
nav (except the established TopNavigation component name) |
error |
err |
number |
num, no |
identifier / id (id is universal and stays) |
ident, idx |
dto (the second and last permitted initialism — see rule 4, and only where the ecosystem uses it natively) |
inventing dataTransfer |
message |
msg |
properties |
props (React's props parameter stays — it is the framework's word) |
The one word for storage is database. Everywhere, in every language:
src/database/ the storage seam (code)
database/ demo data and its loader (data)
database.users the repository object
config.database the configuration key
--database postgres the CLI flag
services: database: the compose service
npm run database:seed the script
There is no db anywhere in this repository, and adding one is a review
comment. The exception carved out on purpose: DATABASE_URL and other
environment variables keep their conventional SCREAMING_SNAKE names, and a
third-party library's own API (req, res in an Express signature) is that
library's vocabulary, not ours.
2. One error envelope, in every language¶
Every backend returns exactly this on failure, and nothing else:
{
"error": {
"status": 400,
"message": "Validation failed",
"details": [{ "field": "email", "message": "must be a valid email" }]
}
}
details is present only for validation failures. A client needs one branch
to handle any failure from any backend in this repository.
Services raise/throw a domain error (ApiError, ApiException, HTTPException,
ApiError enum…). Exactly one handler maps it to a response. No controller
contains a try/catch. An unexpected exception becomes a logged 500 whose
internals never reach the caller.
3. One resource, one shape¶
Every backend exposes the same users resource, so the templates are
interchangeable and every frontend can talk to every backend:
GET /api/users?page=1&limit=20&q= list, paginated
GET /api/users/:id one
POST /api/users create 201
PATCH /api/users/:id update authenticated
DELETE /api/users/:id delete 204 authenticated
GET /api/health/liveness liveness
GET /api/health/readiness readiness
The user, in every language:
{ "id": "…", "email": "…", "name": "…", "role": "user|admin",
"createdAt": "ISO-8601", "updatedAt": "ISO-8601" }
Every list endpoint returns this envelope:
passwordHash never appears in a response. Not because someone remembered to
delete it, but because it has no path out: the conversion to the public shape
happens in exactly one place per backend. Every backend has a test asserting it.
4. Layers, and the rule that keeps them honest¶
No HTTP type crosses into the service. No request, no response, no
HttpServletRequest, no HttpContext. That single constraint is what makes a
service testable without booting a server, and it is the first thing to break in
a codebase that later becomes untestable.
The tell that it has been violated: a controller growing ifs that are not about
status codes.
Entities never leave the service layer. The database model is not the API contract. Every backend converts to a data-transfer object before responding.
Where each of those lives¶
src/models/<domain>.<ext> the record, as the selected storage stores it
src/database/ the seam: adapters, connection, serialization
src/dto/<domain>/ the shapes crossing the boundary — where the language uses them
models/ is storage-specific and pruned at scaffold time, exactly like the
adapters are. A --database mongo project gets a Mongoose schema; a --database
postgres project gets the table definition. It is the one place that knows how a
record is physically stored, which is why database/ — the seam — must not also
know: two files describing one table is how they drift.
For memory and file there is no schema to write, so the model carries what
those adapters genuinely need instead: the field list and the natural key that
makes seeding idempotent. models/ exists in every project regardless of
storage, because a convention with exceptions is one nobody can rely on.
dto/ is conditional, and absent by default. In Java it is the ecosystem's
own word and a Spring template ships it. In JavaScript a plain object literal is
the data-transfer object, and a dto/ directory full of shapes the language does
not check is ceremony — those templates express the same boundary through
validation schemas and toPublic<Domain> instead. A directory that exists only
to satisfy a diagram is worse than no directory.
Where it does exist, it is split by domain and named for the use case:
File names stay kebab-case per rule 1; the exported type is PascalCase. The file
is not user-create inside user/ — the directory already said user.
dto is the one initialism rule 1 permits besides id, and only in the
ecosystems that use it natively. It is not a shortening this repository invented;
it is what the Spring and NestJS documentation calls the thing, and spelling it
data-transfer-object/ would read as written by someone who had never opened
either.
5. Demo data lives in database/, split by domain¶
Every backend ships:
database/
├── fill-demo-data.<ext> the loader
├── README.md how to add a domain
└── data/
└── users.json one file per domain — never one big seed file
- One file per domain (
users.json,products.json, …), so two people adding two domains do not collide in one file. - Adding a domain's demo data is adding a file and registering its loader. Adding the domain is not — it is a repository in every adapter the template supports, plus the service, controller, validation and routes. Adding products to the express template touched fifteen files. This rule governs the seed data only, and says nothing about what the rest of a domain costs.
- The loader goes through the repository, never through a driver — so one loader works against every storage the template supports.
- It is idempotent: rows are matched on their natural key, so running it twice creates no duplicates and does not fail. Seeding is something you do repeatedly while developing, not once.
--resetdeletes existing rows first.- Passwords in the JSON are plaintext on purpose — the loader hashes them the same way the API does, so a demo account can actually log in. The README says so, in those words.
- A data file with no registered loader is reported and skipped, never silently ignored.
- The script is called
database:seedin every language's task runner.
6. Frontend structure¶
Every frontend template — React, Vue, Svelte, Angular, Nuxt, Next, vanilla — uses this structure. The names are the same across frameworks; only the file extension changes.
| Directory | Contains |
|---|---|
components/ |
Simple, reusable components |
components/core/ |
Components with no domain meaning — Button, Card, Spinner |
components/toasts/ |
Components that display toasts |
components/<domain>/ |
Components for one specific domain — e.g. components/users/ |
layouts/ |
Components that define the app's frame |
layouts/RegularLayout |
The layout for normal use |
layouts/AdminLayout |
The layout for the administration area |
layouts/TopNavigation |
The horizontal navigation bar at the top |
layouts/ErrorPage |
Shown when something goes wrong |
pages/ |
The views the app renders. One subdirectory per page, holding every component that belongs to that page |
pages/welcome/ |
The landing page |
pages/users/ |
Everything that makes up the users page |
lib/ |
Libraries that provide functionality but are not components — the API client lives here |
config/ |
Configuration and constants — the one central place to configure the app |
hooks/ |
App-specific hooks (or composables / stores, per framework) |
router.<ext> |
Which URL renders which layout and which page |
The dependency rules — these are the point¶
components/core/ → depends on nothing else in the app
components/<domain>/ → may depend on components/core/ only
layouts/ → may use components/, never pages/
pages/ → may use components/, never layouts/
A violation of these four lines is a bug, not a style preference. They are what stop a component graph from becoming a cycle, and they are stated in every frontend template's README.
Navigation¶
router.<ext> maps URL → layout → page, in one file. Navigation happens either
through a <NavigationButton> component (built on the router's link primitive)
or, in code, through the framework's navigate hook. The ErrorPage demonstrates
the code path.
Look¶
Bootstrap 5 (React Bootstrap / the framework's idiomatic Bootstrap binding) and Bootstrap Icons, so every frontend in the library is visually consistent and nobody has to design a button.
7. Configuration comes from the environment¶
One module reads the environment, validates at startup, and everything else imports the validated object. A missing secret fails at boot, not on the first login attempt at 3am. No other module touches the environment directly.
8. Migrations own the schema¶
Where the language has a migration tool (Flyway, Alembic, EF Core, sqlx), it owns the schema, and the ORM runs in validate mode. A drifted entity fails at boot instead of quietly altering a production table.
9. Draining is a behaviour, not a library¶
Every backend that serves HTTP shuts down the same way. The mechanism differs per language; the behaviour is the contract, and it is the behaviour that is the rule.
On SIGTERM, in this order:
- readiness answers 503 — while the server is still accepting and serving. This is the whole point. A load balancer's endpoint list is eventually consistent, so for a beat after the process decides to die it is still being sent new requests. They must arrive at a server that is still listening.
- liveness keeps answering 200. A failing liveness probe makes the kubelet SIGKILL the pod — mid-drain, killing the very drain this exists to perform. Liveness must never consult the database, for the same reason.
- Only then does the server stop accepting, finish what is in flight, and exit.
| How | |
|---|---|
| Express, Fastify | @godaddy/terminus — readiness is registered on the http.Server, below the framework, which is the only place it can answer 503 after the framework has been told to stop |
| Spring Boot | ReadinessDrainLifecycle — a SmartLifecycle at DEFAULT_PHASE, so it stops before the web server does |
| FastAPI | app/core/lifecycle.py — uvicorn's signal handler is intercepted, and handed back after the grace period |
server.shutdown: graceful is not sufficient on its own, and the name is why
this rule is written down. It stops accepting new connections immediately, so
readiness does not turn 503 — it becomes unreachable, and the connections still
being routed to this instance are refused rather than drained. Step 1 is the part
that has to be added, in every language.
A template that claims to drain has been drained: send it a real SIGTERM, and watch readiness go 503 while liveness stays 200.
10. Every template ships¶
README.md— what it is, how to run it, the layout, and why it is shaped that way. Written for someone who has never seen the project.- A test suite that runs with one command and passes on a clean clone.
.gitignore(as_gitignore— npm rewrites a real one).- A
database/directory, if it has a database. - Health probes, if it is a server.
- No abbreviations. See rule 1.
11. Comments earn their place¶
A comment says why, never what. It states a constraint the code cannot: a trap in a library, an ordering that is load-bearing, a decision that looks wrong until you know the reason. It never narrates the next line, and it never says where the code came from.
12. Every exported function is documented in the ecosystem's own format¶
Rule 11 is about why — an inline note on a constraint the code cannot state itself. This rule is about a different surface entirely: the doc comment on an exported function, class or module, which feeds IDE tooltips, generated docs and type-checkers. The two are not interchangeable, and neither substitutes for the other.
The format is never invented — it is whatever the ecosystem's own tools already read:
| Language | Convention |
|---|---|
| JavaScript / TypeScript | JSDoc (/** ... */, @param, @returns) |
| Python | docstrings (Google-style: Args: / Returns: / Raises:) |
| Java / Kotlin | Javadoc / KDoc |
| C# (.NET) | XML doc comments (///) |
| Go | godoc — a comment directly above the identifier, starting with its name |
| Rust | rustdoc (/// for items, //! for module-level) |
| Swift | /// (DocC markup) |
Same reasoning as rule 1: use the ecosystem's own vocabulary rather than an invented one that reads as written by someone who had never opened that language's documentation.
Document parameters, return shape and thrown/rejected error cases — not a restatement of the function's name. A private, unexported helper does not need one unless its contract is genuinely non-obvious; a doc comment on every one-line private function is noise a reviewer learns to skip past, which is worse than no comment at all.
This rule is documentation-only for now, the same as rule 11 — tests/registry.test.js
enforces rule 1 mechanically, but a doc-comment presence check across nine
languages is a project of its own, not a small addition to this one.