When to use
Reach for this when writing or reviewing any code and you want the cross-cutting quality
baseline. This file holds the general code-style conventions and indexes the rest of the set —
the "Where the rest lives" table at the bottom maps every convention to its home, so this is the
one place to see the whole picture.
To stay DRY, conventions detailed elsewhere are summarized + linked, not re-explained: where
files go → structure-a-backend-service; service-internal
patterns (queries, events, logging, tests) → write-service-code; unit
tests → write-unit-tests; workflow/release/config/DB →
CLAUDE.md.
Each rule is a portable principle with a ▸ TS example and ▸ Other stacks note. The
TypeScript-only gotchas (§8) are skippable for non-TS repos.
Steps
1. Style guide & linter
2. Naming — case by role
| Role |
Case |
Example |
| File, folder, route |
kebab-case |
your-file.service.ts, /listing-photos |
| Class, module, enum, decorator |
PascalCase |
ListingService, ListingStatus |
| Variable, method, function |
camelCase |
firstName, getListingDetail |
| Constant |
SCREAMING_SNAKE_CASE |
const DAYS_IN_WEEK = 7; |
(File & class casing for the layout is also in structure-a-backend-service §3.) ▸ Other stacks:
keep the same role→case mapping; switch only where the language's community standard differs (Python
files & functions snake_case; Go exports PascalCase, locals camelCase).
3. Size limits
- File ≤ ~500–600 lines; method/function ≤ ~20–30 lines. Past that, split by responsibility.
This puts concrete numbers on the global Code Style — Function Size & Density rule ("reads
top-to-bottom in one screenful; split a method covering 3+ concerns"). ▸ Other stacks: same
ceilings — a long file/function is a missing module/function.
4. Early return — keep control flow flat
Handle the invalid/empty case first and return early, so the happy path stays un-indented
instead of buried in nested ifs.
// Bad — arrow of nested ifs
function handleClick(event) {
if (event.target.matches('.save-data')) {
const id = event.target.getAttribute('data-id');
if (id) {
const token = localStorage.getItem('token');
if (token) localStorage.setItem(`${token}_${id}`, true);
}
}
}
// Good — guard clauses, flat body
function handleClick(event) {
if (!event.target.matches('.save-data')) return;
const id = event.target.getAttribute('data-id');
if (!id) return;
const token = localStorage.getItem('token');
if (!token) return;
localStorage.setItem(`${token}_${id}`, true);
}
Keep nesting ≤ 2 levels. ▸ Other stacks: universal — guard clauses + early return everywhere.
(Applies in request handlers too — write-service-code §1.)
5. Pick the array function that states intent
Reaching for a manual loop to transform a collection is the smell (pipeline-over-loops is the global
Iteration & Collections rule + write-service-code §1). Choose by intent:
| Intent |
Function |
What it does |
| keep a subset |
filter |
new array of the elements that pass the test |
| transform each element |
map |
new array, each element run through the callback |
| collapse to one value |
reduce |
folds the array into a single value via an accumulator |
| first element matching |
find |
the first element that passes the test, else undefined |
| does any match? |
some |
true if at least one element passes |
| do all match? |
every |
true if every element passes |
| map then flatten one level |
flatMap |
map + one level of flattening |
| pure side effect, nothing else fits |
forEach |
last resort — only when none of the above apply |
Keep callbacks pure (don't mutate the source array). ▸ Other stacks: the equivalents
(comprehensions, LINQ, Go slices helpers, Kotlin/Java streams).
6. Principles — SOLID, KISS, SRP
- SOLID — single, clear responsibility. Before adding code ask: what is this responsible for,
where does it belong, what does it do? One reason to change per function/class/module.
- KISS — simplest thing that works. Prefer simple, reusable, readable, maintainable code; review
your own diff before asking others to. Add a comment only where the code is genuinely non-obvious.
- One responsibility per PR/MR — but a cohesive change is ONE PR, don't over-split. "One
responsibility" means one logical change, not one file or one mechanical step. A feature that
spans several steps (e.g. a layout migration + its barrel + the import alias, or a fix + its test)
is one PR — use multiple commits to tell the story, not multiple PRs. Split into separate PRs
only when the parts are genuinely independent (each reviews and reverts on its own and neither
needs the other to make sense). Never build a deep stack of dependent PRs (#A→#B→#C→…): it's
slower to review and a nightmare to merge/rebase — far worse than one well-described PR. When in
doubt, default to one PR.
7. Traps to avoid
- Magic numbers → name them.
x = price * TAX_RATE, not x = price * 1.07.
- Negative conditionals → positive predicates. Define
isOnline(...), not isNotOnline(...);
read it as if (!isOnline(...)). Double negatives are hard to reason about.
- Side effects → pure functions. A function should take its inputs and return its output, not
mutate shared/global state. ▸ Bad:
toBase64() reassigns a module-level name. ▸ Good:
toBase64(text): string returns the encoded value and touches nothing else. (Same reason pipeline
callbacks must stay pure.)
- Deep-copy by value, not by alias. When you must not mutate the source, take a real deep copy.
▸ TS/JS:
structuredClone(obj) (not a shallow {...obj}/Object.assign, which still shares
nested refs). ▸ Other stacks: the language's deep-copy (copy.deepcopy, value semantics, etc.).
8. TypeScript-specific gotchas (skip for non-TS repos)
- No redundant casts or non-null assertions. If the type is already narrowed (e.g. inside
typeof x === 'string'), x as string / x! is noise that can hide real bugs. Let inference work.// Bad // Good
console.log('name: ' + name!); console.log('name: ' + name);
return (name as UserName).fullName; return name.fullName; // already narrowed
- Don't append
! to a value you already guarded, and only use optional ?./? where the
value can truly be absent — not everywhere "just in case". If you checked the array isn't empty,
drop the ? after it.
- Stop using
{} as a type. {} means "any non-null value" — strings, numbers, arrays, dates
all satisfy it, so it catches nothing. Use Record<string, unknown> (or { [k: string]: unknown })
for an object bag.type Params = Record<string, unknown>; // not: function f(p: {})
Where the rest of the conventions live
The full set spans these docs — this file is the style baseline; the rest are detailed in their
natural home (kept here as a map so nothing is lost):
| Convention |
Home |
Pipelines over for/while loops |
global Iteration & Collections + write-service-code §1 (§5 here = which function) |
null over undefined + API response defaults ([] for arrays, null otherwise) |
write-service-code §3 |
Promise.all for independent async |
write-service-code §2 |
| Private helpers below public methods |
write-service-code §4 |
Query performance — avoid N+1, upsert, select needed fields, single round-trip, joins, indexes/orderBy |
write-service-code §5 |
| Decimal lib for money, date lib for time (DecimalJs / Dayjs) |
write-service-code §5 |
Events / SQS — domain events; don't throw in a consumer (extend AbstractEventHandler, logger.error + return) |
write-service-code §6 |
| Structured logging (message + context object, mask PII, levels) |
write-service-code §7 |
Testing — integration (AAA, factories, faker, it.each, matchers, coverage, real-DB through the boundary) |
write-service-code §8 |
Testing — unit (mocked deps, createHandlerTestingModule, DTO validation, ≤300-line specs, clean per test) |
write-unit-tests |
Folder/module layout, CQRS split, DTO index.ts barrels, domain entities vs models |
structure-a-backend-service |
libs/ shared libraries (vendored, path-alias) |
structure-a-backend-service §1 |
| Migrations (DDL) vs seeds (DML) |
structure-a-backend-service §5 |
| Branching & release (develop→staging→master, tags, semver, hotfix) |
git-flow |
| Workflow, release safety, config/env, DB rules, root-cause, PR review |
CLAUDE.md |
Verification
- Lint/format clean under the community config (airbnb-base + prettier for TS); any inline
disable carries a comment justifying it; no project-wide disables added casually.
- Names match the case table; no file > ~600 lines or method > ~30 lines without a reason.
- Flat control flow — guard clauses up top, ≤2 nesting levels; collection work uses the
intent-matching array function, not a manual loop.
- No raw magic numbers, no negative-named predicates, no helper mutating shared/global state.
- (TS) no
as/! a guard already made redundant; no {} type; ? only where a value can be
absent.
Related
1---2name: code-conventions3description: Use when writing or reviewing code for general style conventions — style guide & linter, naming, file/function size, array functions, early return, SOLID/KISS, magic numbers, casts, side effects, deep copy, TS gotchas. Also indexes the full convention set. Language-agnostic, TS examples.4---56## When to use78Reach for this when writing or reviewing **any** code and you want the cross-cutting quality9baseline. This file holds the general code-style conventions **and** indexes the rest of the set —10the **"Where the rest lives"** table at the bottom maps every convention to its home, so this is the11one place to see the whole picture.1213To stay DRY, conventions detailed elsewhere are **summarized + linked**, not re-explained: *where14files go* → [structure-a-backend-service](./structure-a-backend-service.md); *service-internal15patterns* (queries, events, logging, tests) → [write-service-code](./write-service-code.md); *unit16tests* → [write-unit-tests](./write-unit-tests.md); workflow/release/config/DB →17`CLAUDE.md`.1819Each rule is a portable principle with a **▸ TS** example and **▸ Other stacks** note. The20TypeScript-only gotchas (§8) are skippable for non-TS repos.2122## Steps2324### 1. Style guide & linter2526- **Follow the largest community style guide for the language** rather than inventing one. For27 JS/TS that's Airbnb (<https://github.com/airbnb/javascript>). Required read:28 clean-code-javascript (<https://github.com/ryanmcdermott/clean-code-javascript>); optional:29 clean-code-typescript (<https://github.com/labs42io/clean-code-typescript>).30- **Lint + format are enforced, not optional.** ▸ *TS:* ESLint `extends: ['airbnb-base',31 'prettier']`. Think **twice** before disabling a rule on an ad-hoc block; think **thrice** before32 disabling it project-wide — and leave a comment saying why. ▸ *Other stacks:* adopt the de-facto33 linter+formatter (ruff/black, gofmt + golangci-lint, ktlint, RuboCop) and treat disables the same.3435### 2. Naming — case by role3637| Role | Case | Example |38|---|---|---|39| File, folder, route | `kebab-case` | `your-file.service.ts`, `/listing-photos` |40| Class, module, enum, decorator | `PascalCase` | `ListingService`, `ListingStatus` |41| Variable, method, function | `camelCase` | `firstName`, `getListingDetail` |42| Constant | `SCREAMING_SNAKE_CASE` | `const DAYS_IN_WEEK = 7;` |4344(File & class casing for the *layout* is also in structure-a-backend-service §3.) ▸ *Other stacks:*45keep the same role→case mapping; switch only where the language's community standard differs (Python46files & functions `snake_case`; Go exports `PascalCase`, locals `camelCase`).4748### 3. Size limits4950- **File ≤ ~500–600 lines; method/function ≤ ~20–30 lines.** Past that, split by responsibility.51 This puts concrete numbers on the global *Code Style — Function Size & Density* rule ("reads52 top-to-bottom in one screenful; split a method covering 3+ concerns"). ▸ *Other stacks:* same53 ceilings — a long file/function is a missing module/function.5455### 4. Early return — keep control flow flat5657Handle the invalid/empty case first and **return early**, so the happy path stays un-indented58instead of buried in nested `if`s.5960```ts61// Bad — arrow of nested ifs62function handleClick(event) {63 if (event.target.matches('.save-data')) {64 const id = event.target.getAttribute('data-id');65 if (id) {66 const token = localStorage.getItem('token');67 if (token) localStorage.setItem(`${token}_${id}`, true);68 }69 }70}7172// Good — guard clauses, flat body73function handleClick(event) {74 if (!event.target.matches('.save-data')) return;75 const id = event.target.getAttribute('data-id');76 if (!id) return;77 const token = localStorage.getItem('token');78 if (!token) return;79 localStorage.setItem(`${token}_${id}`, true);80}81```82Keep nesting ≤ 2 levels. ▸ *Other stacks:* universal — guard clauses + early return everywhere.83(Applies in request handlers too — [write-service-code](./write-service-code.md) §1.)8485### 5. Pick the array function that states intent8687Reaching for a manual loop to transform a collection is the smell (pipeline-over-loops is the global88*Iteration & Collections* rule + [write-service-code](./write-service-code.md) §1). Choose by intent:8990| Intent | Function | What it does |91|---|---|---|92| keep a subset | `filter` | new array of the elements that pass the test |93| transform each element | `map` | new array, each element run through the callback |94| collapse to one value | `reduce` | folds the array into a single value via an accumulator |95| first element matching | `find` | the first element that passes the test, else `undefined` |96| does **any** match? | `some` | `true` if at least one element passes |97| do **all** match? | `every` | `true` if every element passes |98| map then flatten one level | `flatMap` | `map` + one level of flattening |99| pure side effect, nothing else fits | `forEach` | **last resort** — only when none of the above apply |100101Keep callbacks **pure** (don't mutate the source array). ▸ *Other stacks:* the equivalents102(comprehensions, LINQ, Go slices helpers, Kotlin/Java streams).103104### 6. Principles — SOLID, KISS, SRP105106- **SOLID — single, clear responsibility.** Before adding code ask: *what is this responsible for,107 where does it belong, what does it do?* One reason to change per function/class/module.108- **KISS — simplest thing that works.** Prefer simple, reusable, readable, maintainable code; review109 your own diff before asking others to. Add a comment only where the code is genuinely non-obvious.110- **One responsibility per PR/MR — but a cohesive change is ONE PR, don't over-split.** "One111 responsibility" means one *logical* change, not one file or one mechanical step. A feature that112 spans several steps (e.g. a layout migration + its barrel + the import alias, or a fix + its test)113 is **one PR** — use multiple *commits* to tell the story, not multiple PRs. Split into separate PRs114 only when the parts are **genuinely independent** (each reviews and reverts on its own and neither115 needs the other to make sense). **Never build a deep stack of dependent PRs** (#A→#B→#C→…): it's116 slower to review and a nightmare to merge/rebase — far worse than one well-described PR. When in117 doubt, default to **one PR**.118119### 7. Traps to avoid120121- **Magic numbers → name them.** `x = price * TAX_RATE`, not `x = price * 1.07`.122- **Negative conditionals → positive predicates.** Define `isOnline(...)`, not `isNotOnline(...)`;123 read it as `if (!isOnline(...))`. Double negatives are hard to reason about.124- **Side effects → pure functions.** A function should take its inputs and return its output, not125 mutate shared/global state. ▸ *Bad:* `toBase64()` reassigns a module-level `name`. ▸ *Good:*126 `toBase64(text): string` returns the encoded value and touches nothing else. (Same reason pipeline127 callbacks must stay pure.)128- **Deep-copy by value, not by alias.** When you must not mutate the source, take a real deep copy.129 ▸ *TS/JS:* `structuredClone(obj)` (not a shallow `{...obj}`/`Object.assign`, which still shares130 nested refs). ▸ *Other stacks:* the language's deep-copy (`copy.deepcopy`, value semantics, etc.).131132### 8. TypeScript-specific gotchas (skip for non-TS repos)133134- **No redundant casts or non-null assertions.** If the type is already narrowed (e.g. inside135 `typeof x === 'string'`), `x as string` / `x!` is noise that can hide real bugs. Let inference work.136 ```ts137 // Bad // Good138 console.log('name: ' + name!); console.log('name: ' + name);139 return (name as UserName).fullName; return name.fullName; // already narrowed140 ```141- **Don't append `!` to a value you already guarded**, and only use optional `?.`/`?` where the142 value can *truly* be absent — not everywhere "just in case". If you checked the array isn't empty,143 drop the `?` after it.144- **Stop using `{}` as a type.** `{}` means "any non-null value" — strings, numbers, arrays, dates145 all satisfy it, so it catches nothing. Use `Record<string, unknown>` (or `{ [k: string]: unknown }`)146 for an object bag.147 ```ts148 type Params = Record<string, unknown>; // not: function f(p: {})149 ```150151## Where the rest of the conventions live152153The full set spans these docs — this file is the style baseline; the rest are detailed in their154natural home (kept here as a map so nothing is lost):155156| Convention | Home |157|---|---|158| Pipelines over `for`/`while` loops | global *Iteration & Collections* + [write-service-code](./write-service-code.md) §1 (§5 here = which function) |159| `null` over `undefined` + **API response defaults** (`[]` for arrays, `null` otherwise) | [write-service-code](./write-service-code.md) §3 |160| `Promise.all` for independent async | [write-service-code](./write-service-code.md) §2 |161| Private helpers below public methods | [write-service-code](./write-service-code.md) §4 |162| **Query performance** — avoid N+1, `upsert`, select needed fields, single round-trip, joins, indexes/orderBy | [write-service-code](./write-service-code.md) §5 |163| **Decimal lib for money, date lib for time** (DecimalJs / Dayjs) | [write-service-code](./write-service-code.md) §5 |164| **Events / SQS** — domain events; **don't throw in a consumer** (extend `AbstractEventHandler`, `logger.error` + `return`) | [write-service-code](./write-service-code.md) §6 |165| **Structured logging** (message + context object, mask PII, levels) | [write-service-code](./write-service-code.md) §7 |166| **Testing — integration** (AAA, factories, faker, `it.each`, matchers, coverage, real-DB through the boundary) | [write-service-code](./write-service-code.md) §8 |167| **Testing — unit** (mocked deps, `createHandlerTestingModule`, DTO validation, ≤300-line specs, clean per test) | [write-unit-tests](./write-unit-tests.md) |168| Folder/module layout, CQRS split, **DTO `index.ts` barrels**, domain entities vs models | [structure-a-backend-service](./structure-a-backend-service.md) |169| `libs/` shared libraries (vendored, path-alias) | [structure-a-backend-service](./structure-a-backend-service.md) §1 |170| **Migrations (DDL) vs seeds (DML)** | [structure-a-backend-service](./structure-a-backend-service.md) §5 |171| Branching & release (develop→staging→master, tags, semver, hotfix) | [git-flow](./git-flow.md) |172| Workflow, release safety, config/env, DB rules, root-cause, PR review | `CLAUDE.md` |173174## Verification175176- **Lint/format clean** under the community config (airbnb-base + prettier for TS); any inline177 disable carries a comment justifying it; no project-wide disables added casually.178- **Names match the case table**; no file > ~600 lines or method > ~30 lines without a reason.179- **Flat control flow** — guard clauses up top, ≤2 nesting levels; collection work uses the180 intent-matching array function, not a manual loop.181- **No raw magic numbers**, no negative-named predicates, no helper mutating shared/global state.182- **(TS)** no `as`/`!` a guard already made redundant; no `{}` type; `?` only where a value can be183 absent.184185## Related186187- [structure-a-backend-service](./structure-a-backend-service.md) — folder/module/naming layout.188- [write-service-code](./write-service-code.md) — control flow, async, queries, events, logging, integration tests.189- [write-unit-tests](./write-unit-tests.md) — isolated unit tests.190- [git-flow](./git-flow.md) — branching & release workflow.191- `CLAUDE.md` — the global engineering rules. This doc adds to them; it does not repeat them.