{"node":{"id":"33705115-71bf-4415-9eb8-5c6afafeff7e","handle":"3370","type":"note","authorName":"Vincent Yeung","intent":"recipe","title":"Pattern: starting a new codebase","aiSummary":"One page of rules for starting a new codebase, in seven parts: project setup, the architecture rule (domain types in the middle, translation at the edges, outside systems behind ports), server, browser, testing (what to test and what to skip, every test seen failing once), security from the first day, and documents, comments and commits. Four parts hold for any language; three name a default stack of TypeScript, pnpm, NestJS, Postgres and Vue 3. Can be turned into a scaffolding skill.","content":"**When:** you are starting a new codebase, or deciding where a piece of code, a test or a document belongs in one that follows this pattern.\n\n**As a skill:** ask the AI to turn this page into a skill with two modes: scaffold a new project, and answer \"where does this go?\" in an existing one.\n\nSeven parts. Parts 2, 5, 6 and 7 hold for any language. Parts 1, 3 and 4 name a default stack:\n\n- TypeScript, pnpm and Vitest everywhere\n- NestJS and Postgres on the server\n- Vue 3 with Vite and Pinia in the browser\n\nSwap a tool and keep the rule.\n\n**Start small.** One package per app you deploy: `server/` and `web/`, each with its own `tsconfig`. Shared code stays a folder inside the app that uses it. Move it into its own package only when a second app needs it. Add CI that tests only the apps a change touches when the full run gets slow, not before.\n\n**1. Project setup**\n\n- Pin the package manager in `packageManager`. Keep the Node version in `.nvmrc`, and have CI and the Dockerfile read it from there. Set `engines`.\n- In CI, install with `--frozen-lockfile`. List the packages that may run install scripts; all others may not. Use `overrides` to force a patched version of a package you do not depend on directly.\n- Write `strict: true` in the tsconfig even where the compiler defaults to it, so a version change cannot turn it off. Add:\n  - `noUncheckedIndexedAccess`\n  - `noImplicitOverride`\n  - `noImplicitReturns`\n  - `noFallthroughCasesInSwitch`\n  - `noUnusedLocals`\n  - `verbatimModuleSyntax`\n- The `dom` lib goes in `web/` only.\n- Every `.ts` file, tests and config files included, belongs to a tsconfig that the CI typecheck step runs. Vitest strips types, so a green suite says nothing about types.\n- Lint with oxlint and format with oxfmt, both at an exact version. Record the first bulk reformat in `.git-blame-ignore-revs`. Add `.editorconfig`, and `.gitattributes` with `eol=lf`.\n- Pre-commit: lint and format the staged files, and scan them for secrets.\n- CI order: format check, lint, typecheck, test, build. One last job always runs and fails if any earlier job failed or was cancelled. Make that job the required check, because a skipped job counts as passed.\n- Pin CI actions by commit SHA. Give each job the fewest `permissions` it needs, and a timeout. Dependabot runs weekly with grouped PRs.\n- Suites that need the whole stack running, or a real browser, run nightly and not per PR.\n\n**2. The architecture rule, the same on the server and in the browser**\n\n- Domain types are plain interfaces. No framework import and no decorators.\n- Code at the edges translates. A controller turns a request into domain types. A repository turns rows into domain types. A browser adapter turns a JSON response into domain types. Nothing in the middle sees a request class, an ORM entity or a raw JSON shape.\n- Every outside system except your own database sits behind a port: an interface the domain owns. A cache, file storage, another service and a vendor SDK are outside systems. An adapter implements the port. When a test must replace an outside system, the fake implements the port, never the adapter.\n- Your own database is not faked. A repository is a plain class, and tests run it against the real engine, through the service that calls it (part 5).\n- Wrap a vendor package in one place and ban importing it anywhere else.\n- Imports point inward. Edges import the domain. The domain imports nothing from the edges and nothing from a framework. Enforce this with lint: `no-restricted-imports` with per-folder exceptions, or dependency-cruiser. A rule that lives only in a document is already broken somewhere.\n\n**3. Server**\n\nOne folder per domain under `src/`, with the same files every time:\n\n- `types.ts`: domain interfaces and constants\n- `errors.ts`: the domain's error codes, each declared once with its HTTP status\n- `controllers/`: routes, plus a sibling file holding the API documentation text\n- `models/http.ts`: request classes with validation, and response classes built from domain types\n- `models/entity.ts`: the ORM entity\n- `services/`: the logic, in domain types only\n- `repository/`: all SQL\n- `module.ts`: wiring only\n\nBeside the domains: `ports/<name>.ts` for each port, `adapters/<name>/` for each adapter, `testing/` for fakes, and `platform/` for config, logging, the error filter and `hardenApp()`.\n\n- The repository owns all SQL. It recognises a driver error by constraint name and throws the matching error from `errors.ts`. A service never catches a database error code.\n- The repository offers `transaction(work)` and hands `work` a repository bound to that transaction.\n- Settle a race in one SQL statement. Never check and then write.\n- Read env through one typed accessor that refuses to boot on a missing or empty value. Domain code receives a typed config object and never reads env.\n- Every error answers with one body: `{code, message, statusCode, detail?}`.\n  - A code is `resource:condition`. It is never renamed; add a new one.\n  - A global filter renders declared errors as they are.\n  - For any other error the filter returns only an error id and logs the full error.\n  - A validation failure uses the same body.\n  - A record that is unknown and a record that is not the caller's both answer 404.\n- Logs are structured. An object's fields are dropped unless the field is marked safe to log. Request headers are logged by allowlist. Event names live in one enum. No `console`.\n- Outbound HTTP: one named client per dependency, with the base URL set once and a default timeout (start at 10 seconds). It retries a read at most twice and never retries a write. It maps statuses to typed results.\n- Migrations are SQL files, forward only, named by timestamp. Never edit one after it has run anywhere that cannot be wiped. The ORM never syncs the schema. Name every constraint. One database role migrates and another runs the app.\n- Startup: the validation pipe rejects unknown fields, and it is exported from its own file so tests run the same one. Shutdown hooks are on. A failed boot exits non-zero.\n\n**4. Browser**\n\n- `domain/types.ts`, `api/ports.ts`, adapters in `api/http/`, and `api/index.ts` as the one place that joins adapters to ports. Nothing outside `api/` imports an adapter.\n- An adapter trusts nothing in the response: `canInvite: body.can_invite === true`.\n- Native `fetch` sits behind one client.\n  - It maps statuses to typed errors.\n  - You branch on `code`, never on message text.\n  - It retries a read once on a 502, 503, 504 or network failure, and never retries a write.\n  - A create carries an idempotency key.\n- The browser calls same-origin `/api`, and the dev server proxies it. Runtime config comes from one route at boot, not from build-time env. Flags default off.\n- Keep state in the component by default. A store holds only server records and the session that every page shares. If two callers ask at once, the store sends one request. It updates from the write's response and does not refetch.\n- If the Back button or a reload must restore a view, give that view its own URL.\n- Each page route declares its access in route meta: `public`, `guest` or `authenticated`. A page route with no declaration is treated as `authenticated`. A public page never waits on auth.\n- Design tokens are CSS variables, light and dark from the first day. Write your own base components (button, input, field, dialog) on those tokens.\n- The server owns form validation. Show its message as written.\n- Accessibility: labels tied to inputs, focus trapped and restored in dialogs, reduced motion respected, contrast ratios checked by a test.\n- Every page route is lazy-loaded. A chunk size budget (start at 250 kB) fails the build.\n- Code shared by two web apps ships a `/testing` entry with fakes. A fake throws when a test calls a method the test did not set up.\n\n**5. Testing**\n\nA test protects a behaviour only if it goes red when that behaviour breaks. See each test fail once before it counts. Delete a test that stays green when you break the code it covers.\n\nTest this:\n\n- Behaviour at the module's boundary: what the caller gets back and what state remains.\n- Every SQL statement, against the real database engine in a container with the real migrations, called through its service. Cover:\n  - constraints\n  - one owner cannot read or change another's rows\n  - a null column, and a row that does not exist\n  - paging\n  - hostile input\n- One forced race per write that two callers can reach. Hold a transaction open until the second caller is blocked, then commit and check that exactly one wins.\n- Authorisation refusals per route: wrong scope, wrong kind of caller, someone else's record.\n- Input validation at the HTTP edge as a table-driven test, and the exact error body.\n- Pure decision logic as unit tests with edge cases.\n- Make the step after each committed write throw. Check what the caller sees and what state is left.\n- Outbound adapters against recorded responses, error statuses included.\n- What the suite cannot see:\n  - the module wiring boots\n  - the env names the app requires are the names the deploy config sets\n  - the API document matches the responses\n- Every bug fix, behind a test that goes red when the fix is reverted.\n\nSkip this:\n\n- A service tested against a fake repository that hands back its input.\n- Call counts and call order of internal collaborators.\n- The ORM, the framework, the faking library, builders and fakes.\n- Validators that echo valid input.\n- `toBeDefined` as the only check.\n- Rows in a table-driven test that do not change the code path.\n- Picking one case at random when each case matters. List every case.\n- Snapshots of markup.\n- Anything written to reach a coverage number or a test count. Report counts and never chase them.\n\nFake only what a container on your machine cannot run: a vendor's API, a payment provider, email. Fixtures copy captured payloads. Name a test for the behaviour it checks. Tests live in `test/<domain>/` beside `src/`. Build the boundary tests before a refactor, and a refactor may not change one of their assertions.\n\n**6. Security, from the first day**\n\nAccess:\n\n- Authentication and authorisation guards are global. Every route declares who may call it, and a declared route refuses anyone its rules do not allow. A route with no declaration answers 500 on every request, so it cannot ship unnoticed. A public route says so in a way you can grep.\n- Every repository method that touches an owned record takes the owner as an argument. The owner comes from the verified caller, never from the request.\n- An unknown, revoked or expired token answers 401 with one identical body.\n- JWT: an explicit algorithm list, an issuer allowlist, required `kid` and `aud`. Pass issuer and audience to the verify call as well as checking them.\n- An API takes bearer tokens and reads no cookies. Cookies live only in the auth backend: `httpOnly`, `secure`, `sameSite`, a path limited to the auth routes, a life of minutes for a sign-in flow.\n- In the browser, keep tokens in memory, never in `localStorage`.\n\nInput and output:\n\n- Validation rejects unknown fields. An id param is parsed as its type.\n- A response is built from domain types by an explicit response class.\n- Parameterised SQL only.\n- HTML made from user content passes through one sanitising function with allowlists. Nothing else may write raw HTML (`v-html`, `innerHTML`).\n- A redirect target must start with `/` and not `//`.\n\nTransport:\n\n- Every service calls one `hardenApp()`. It sets:\n  - security headers\n  - `x-powered-by` off\n  - an exact-origin CORS allowlist\n  - per-route body limits\n  - server timeouts above the load balancer's\n- A rate limit on every unauthenticated route and every auth route.\n- CSP written as code and checked by a test.\n- Outbound calls have timeouts and a host allowlist.\n\nSecrets:\n\n- `.gitignore` covers `.env*`, `*.env`, `*.pem`, key files and local dev folders, and keeps `*.example`. Commit `.env.example` with no real value in it.\n- Secrets come from a secret manager. CI cannot read production values.\n- A config error never prints the value.\n- A token is 32 random bytes as base64url. Store only a SHA-256 hash. Hash the token's use and the token as one string, such as `invite.` followed by the token, so a token made for one use never matches another. Show the token once and give it an expiry. Compare digests in constant time.\n- A webhook is verified over the raw body plus a timestamp, inside a five minute window.\n- A 401 or 403 body carries no internals.\n\nSupply chain:\n\n- Secret scanning runs again in CI, with a dependency audit, static analysis and an image scan.\n- Branch protection requires review on every file path.\n- A `SECURITY.md`, and a threat list in the README.\n\n**7. Documents, comments, commits**\n\n- A fixed set of documents, each answering one question for one reader:\n  - README: what this is\n  - product: what was decided\n  - technology: why it is built this way\n  - standards: conventions\n  - local development: the runbook\n- Documents say why and code says what. Put a detail as close to the code as it fits: command help, the API document, the migration.\n- One home per fact. Delete the sentence, and if nothing is lost it was a copy.\n- A plan written to build one change is deleted in the commit that ships the change. Every temporary document states what triggers its deletion.\n- Write almost no comments. If a user of the module can notice the behaviour, write the test and delete the comment. A comment survives only as a warning no test can express, such as \"if X ever changes, Y must change too\".\n- Conventional commits with the module as scope and a plain imperative subject. A PR body says the problem, what changed, and why.\n- A doctor script checks that the local tools, ports and env are in place. Test teardown runs under its own docker compose project name, so it cannot touch dev data.\n- With an AI writing the code: have a second AI session, with no memory of the build, read the result before you do. Reports cite commits and files. The AI never makes a product decision. When code, comment and test disagree, name the disagreement and do not quietly edit one to match.\n\n**Questions for your own case**\n\n- Does this entity differ from its domain type yet? If not, one type is enough until it does.\n- Can a unit test or a service test prove this, without going through HTTP? Test it there, once.\n- Does this test read source files instead of running code? A lint rule may do its job.\n- Does one domain need another? Call its service through its module's exports, never its repository.\n\n**Probe.** A session that has read only this page must answer without guessing:\n\n- A unique-constraint violation must reach the caller as a 409. Which file recognises it, and which file says 409?\n- Someone proposes a service test with a fake repository whose `save` returns its input. Keep it or not, and what replaces it?\n- A new route has no auth declaration. What does a caller get?","capturedAt":"2026-09-17T23:07:19.477Z","createdAt":"2026-09-17T23:07:19.478Z","updatedAt":"2026-09-17T23:07:19.478Z","spanId":null,"contributor":null,"audiences":[{"kind":"public","createdAt":"2026-10-02T05:20:15.632Z"}],"fingerprint":"6c821f8815ca1b9180fa26cc1f58daca8c42b10df809322079ec4f140043f6ec","revisionNo":"1","structureHash":"b731cbeee3441f8882ba51edd414aba376f3f74b0ac75d072ee7ccd34e024edf","page":"https://lattris.com/by/b/memories/33705115-71bf-4415-9eb8-5c6afafeff7e"},"links":[{"direction":"out","verb":"references","reason":"only if the codebase already exists and a module in it needs bringing up to this standard","hash":"a3ec41d2d9605f4cde3aa5440581a4c16bee7f901aa34920021c8371df8ea2c3","node":{"id":"96995049-b5b2-43a2-b056-a786ffb32c33","handle":"9699","type":"note","title":"Pattern: making an existing module readable","aiSummary":"How to bring one existing module up to standard with an AI doing the work: comments to near zero by a three-sentence rule, a test for every behaviour a caller can notice, every disagreement between code, comment and test sorted into test gap, bug or product decision rather than smoothed over, nothing deleted without proof, and a fixed order of steps each green before the next. The counterpart to starting a new codebase. Can be turned into a skill.","erased":false,"href":"https://lattris.com/api/lattris/by/b/memories/96995049-b5b2-43a2-b056-a786ffb32c33?links=1","fingerprint":"15b6f2dd62af48c75a881f33705047bbc3b961a61b8b2fc1d4b238af76e27c2f","revisionNo":"2","structureHash":"256f924aafb42eda972b8720755d31bea3ca8d5a0b95caacd3fb3ddfd4936af8","intent":"recipe","authorName":"Vincent Yeung","page":"https://lattris.com/by/b/memories/96995049-b5b2-43a2-b056-a786ffb32c33"}},{"direction":"in","verb":"references","reason":"only if you are starting a new codebase, or deciding where code, a test or a document belongs in one built this way","hash":"6eee04a589756afc53aff6b27d0b8fb54026eb74636210e0b7c9db8b5f32de1b","node":{"id":"331896d2-2e9d-46b1-b56c-048a18ee23eb","handle":"3318","type":"note","title":"Patterns: a library","aiSummary":"Index of the author's patterns, one page each, built from the basics. Any pattern can be turned into a skill by asking the AI. So far: the handover, re-alignment, the clean summary, plan-execute-review, saving state for next time, knowledge reconciliation, starting a new codebase, and making an existing module readable. Grows as pages are added.","erased":false,"href":"https://lattris.com/api/lattris/by/b/memories/331896d2-2e9d-46b1-b56c-048a18ee23eb?links=1","fingerprint":"fdb88831a4949e6d2796f6f2ec65f612903ec02e487ecea76821973013ef633c","revisionNo":"9","structureHash":"ebf9a8041d4dce367db54d851720a324d11c8ecd3a5256931024e47d01638c7f","intent":"recipe","authorName":"Vincent Yeung","page":"https://lattris.com/by/b/memories/331896d2-2e9d-46b1-b56c-048a18ee23eb"}},{"direction":"in","verb":"references","reason":"only if you need the full list of what to test and what to skip, or the codebase does not exist yet","hash":"4482e557f00c5f08f058dca42a8a76ff4d1897e83ae09e94dab8642e46dae828","node":{"id":"96995049-b5b2-43a2-b056-a786ffb32c33","handle":"9699","type":"note","title":"Pattern: making an existing module readable","aiSummary":"How to bring one existing module up to standard with an AI doing the work: comments to near zero by a three-sentence rule, a test for every behaviour a caller can notice, every disagreement between code, comment and test sorted into test gap, bug or product decision rather than smoothed over, nothing deleted without proof, and a fixed order of steps each green before the next. The counterpart to starting a new codebase. Can be turned into a skill.","erased":false,"href":"https://lattris.com/api/lattris/by/b/memories/96995049-b5b2-43a2-b056-a786ffb32c33?links=1","fingerprint":"15b6f2dd62af48c75a881f33705047bbc3b961a61b8b2fc1d4b238af76e27c2f","revisionNo":"2","structureHash":"256f924aafb42eda972b8720755d31bea3ca8d5a0b95caacd3fb3ddfd4936af8","intent":"recipe","authorName":"Vincent Yeung","page":"https://lattris.com/by/b/memories/96995049-b5b2-43a2-b056-a786ffb32c33"}}],"dictionary":{"lattris":"A personal knowledge base — how a person captures what they know as linked blocks they own and control. To understand how this knowledge base works: https://lattris.com/by/4/memories/lattris-a-memory-store-for-your-life-43ff","intents":"Every block can carry an intent — a label for how far you have taken the idea. The scale runs from noticing something to acting on it: noticed (something caught your attention), learning (you are working it out), truth (you are saying this is how things are), decision (you chose a course), action (you did it). Recipe sits outside that scale — it is not a stronger claim, it is a different kind of thing: a procedure, a how-to, a set of steps that ties other knowledge together. Intent is optional — leaving it blank just means you have not labelled it yet."},"instructions_to_ai":"This is a recipe from a personal knowledge base. Read it and apply it to what the person you're talking to is working on. Follow the linked blocks to explore further related topics if it might be useful to the user; open each at its page address. Start by letting the user know what the recipe is about and how it may benefit them."}