Conventions
The team rules a new dev needs first — branching, commits, PRs, lint hooks, cross-platform scripts, and naming.
Every team has a set of small, unwritten rules that the regulars just know — and tripping over one is the fastest way to feel like an outsider on day one. This page writes them down. None of it is hard; most of it is enforced for you by Git hooks (scripts that run automatically when you commit or push) and by the linter. The goal here is so that when a hook stops your commit, you already understand why.
Think of it in three buckets: how work flows through Git (branches, commits, PRs), how the codebase stays consistent (lint, format, naming), and how scripts stay portable (because the team is split across macOS and Windows). We'll take them in that order.
Branching
The single rule that surprises new devs most: main is far behind. Day-to-day work happens on the staging branch, and main only catches up at release time. So when you start a task, branch from staging, and point your pull request back at staging — not main, unless someone explicitly tells you otherwise.
git checkout staging
git pull
git checkout -b feat/create-userTargeting main by accident is the classic week-one mistake
If you branch from main, you start from stale code and your PR diff fills up with everyone else's already-merged work. If a PR shows hundreds of unexpected changes, check your base branch first — it's almost always this.
Naming branches
A branch name is <type>/<kebab-case-intent> — a type prefix, a slash, then a short hyphenated phrase describing the intent, not the implementation. Keep the phrase under about 30 characters.
The <type> is one of the same set you'll use everywhere else: feat, fix, chore, refactor, docs, test, perf, build, ci. So feat/create-user and fix/login-redirect-loop are good; feature/JonsBranch2 is not.
Conventional Commits
The project uses Conventional Commits — a tiny grammar for commit messages that makes history readable and changelogs automatic. The shape is type(scope): description, and a commit-msg hook rejects anything that doesn't match.
feat(users): add invite-by-email flow
fix(auth): resolve token refresh race condition
chore(deps): update NestJS to v11The allowed types are the branch types plus style and revert. The scope in parentheses is required — it's the area you touched (auth, users, build). The commit-msg hook lives in .husky/commit-msg; merge commits are waved through, everything else must match the pattern.
Your PR title is a commit too
Title the PR with the same grammar, mirroring the branch type — feat: …, fix: …, chore: …. Keep it short and outcome-focused ("add invite-by-email flow"), describing the result rather than the diff.
Staging & PR template
Before you commit, stage the whole working tree with git add -A — modified, added, and untracked files. A generated migration or a new fixture that you forget to stage is a broken PR for the next person.
git add -A
git commit -m "feat(users): add invite-by-email flow"The PR description is not freeform. It follows .github/PULL_REQUEST_TEMPLATE.md — GitHub loads these sections for you, and reviewers expect the same headings in the same order:
| Section | What goes here |
|---|---|
| Summary | A brief overview of what the PR does and why. |
| Changes | A concise, specific list of what actually changed. |
| Migration / Breaking Changes | Anything reviewers must do to existing setup — or None. |
| Environment Variables | New/changed vars (and confirm .env.example is updated). |
| Closes Issue(s) | The issue this PR closes, if any. |
Out-of-scope findings
While building one thing you'll inevitably spot another — a bug two files over, a rough edge in unrelated code. The discipline here is: don't let it grow your PR. A pull request should do one thing. Out-of-scope findings become a GitHub issue instead, so the work is tracked without bloating the review.
And rather than scatter a dozen tiny issues, the team prefers epics with checklists: one [Epic] <Area> tracking issue (there's an Epic template), with new scope added as checklist items. It keeps related work in one readable place.
Lint and format
Two tools keep the codebase looking like one person wrote it. ESLint catches likely bugs and enforces type-aware rules; Prettier owns pure formatting — quotes, commas, spacing — so nobody argues about it in review. The two are wired together so ESLint never fights Prettier over style.
Prettier's config is deliberately tiny, and worth memorizing because it shapes every file you write — single quotes, and trailing commas everywhere:
{
"singleQuote": true,
"trailingComma": "all"
}You rarely run these by hand, but when you want to: pnpm format rewrites formatting across the codebase, and pnpm lint auto-fixes what it can.
Husky hooks
Husky is the tool that installs Git hooks from the .husky/ folder. Two hooks matter day to day, and they enforce the rules above so a messy commit never reaches the remote:
- pre-commit runs lint-staged — Prettier then ESLint
--fix, but only on the staged*.tsfiles. Fast, because it ignores everything you didn't touch. - pre-push runs
pnpm test— the unit suite must pass before your push is accepted.
A failing push usually means a failing test
Because pre-push runs the whole unit suite, git push can fail for a reason that has nothing to do with your network. Read the output: if it's a red test, fix it (or run pnpm test first to catch it earlier). Don't reach for --no-verify to skip the hook — that just hands the failure to CI and your reviewer.
Cross-platform scripts
Here's a constraint that catches everyone eventually: developers on this team build on both macOS and Windows. A package.json script that works in your zsh terminal can break on a teammate's PowerShell. So every script — and every shell snippet you suggest — has to be cross-platform. Four rules cover it.
Quote with escaped double quotes, never single quotes. Windows cmd and PowerShell don't treat '...' as quoting, so single-quoted globs silently fall apart. This is why the real format script reads:
{
"scripts": {
"format": "prettier --write \"apps/**/*.ts\" \"libs/**/*.ts\""
}
}Set environment variables with cross-env, never the bare VAR=value command prefix — that POSIX shorthand simply doesn't exist on Windows. cross-env makes one line work on both:
{
"scripts": {
"start:dev": "cross-env NODE_OPTIONS=--max-old-space-size=8192 nest start --watch"
}
}Avoid Unix-only commands — no rm -rf, no cp, no chains of POSIX builtins. Reach for Node-based or cross-platform tooling instead. And use forward slashes in paths (apps/server/main); Node and the pnpm runner accept them on every OS, while backslashes are Windows-only.
Test the shape, not just the behavior
When you add a script, ask "would this run in PowerShell?" The tells are single quotes, a VAR=value prefix, and rm/cp. Swap them for escaped quotes, cross-env, and Node tooling and you're safe on both platforms.
Naming conventions
Consistent names make the codebase searchable and predictable. These are the ones a new dev hits first — copy the pattern from neighboring code and you'll rarely go wrong.
| Thing | Rule |
|---|---|
| Collection methods | Prefer list over findAll for new repository/service methods. (findAll stays on BaseRepository for compatibility.) |
| Join tables | <primary>-x-<secondary> across the table, controller, and service — e.g. student-x-class becomes StudentClassController + StudentClassService. |
@ApiOperation | operationId is required and camelCase; summary is ≤ 3 words; put detail in description. |
@ApiProperty | On every DTO field; add a description only when the field name isn't self-explanatory. |
| DB columns | snake_case, via an explicit name: plus the SnakeNamingStrategy. |
A quick word on a couple of these. The operationId is the stable name your generated API client and the Swagger docs use for an endpoint, so it has to be unique and camelCase — treat it like a public identifier, not a label. And the primary-x-secondary join-table convention is just a naming discipline for many-to-many links: the table that joins student and class is named student-x-class, and its NestJS classes drop the -x- to read StudentClass….
Migrations are user-driven — never run them yourself
Database migrations change real data, so they are always run by a developer who can see the result — migration:generate and migration:run are never invoked by an agent or a script on your behalf. If a schema change is needed, prepare it and hand it off. The full story lives in The Database and the Add an Entity & Migration recipe.
Command reference
The commands you'll reach for most, grouped by what you're doing. Run servers with pnpm dev:all to bring up the API, worker, and audit apps together.
# Run the apps
pnpm install # install dependencies
pnpm dev:all # api + worker + audit together
pnpm start:dev pnpm worker:dev pnpm audit:dev
# Quality gates (also run by the hooks)
pnpm lint pnpm format
pnpm test pnpm test:e2e pnpm test:cov
# Local infra & extras
pnpm docker:dev pnpm docker:dev:down # local Postgres / Redis / RabbitMQ
pnpm email:dev # React Email preview
pnpm compodoc:serve # generated code docs on :8009
# Migrations (developer-run only — see the callout above)
pnpm migration:run pnpm migration:revertWhere to go next
Getting Started
Clone, configure, and boot the API for the first time.
API Conventions
How DTOs, operationIds, and @ApiProperty shape the API surface.
Testing
The suite that pre-push runs — and how to write tests of your own.
Add a Resource Module
Put these naming rules to work building a new module end to end.