Skip to content
Chapter 101Lesson 2

The thin README and source-as-doc

Keep the README to its one first-contact job and let your source files document the rest, the source-as-doc discipline that stops docs drifting.

You have read a README like this. The first fifty lines are clean: what the project is, how to run it. Then it rots. The env-var table is missing three variables, “Data model” lists columns renamed two quarters ago, “Architecture” hasn’t changed since the first commit. Nobody reads past line 50, and everything below it has gone stale.

The fix is structure, not discipline. Last lesson left you one reflex: before paraphrasing canonical truth, ask could this be a link? Apply it to the README. Make it do one job and let your source files document the rest.

A README has two readers. The first is a new contributor in their first hour, who cloned the repo and wants a dev server running. The second is a recruiter skimming on GitHub, deciding in thirty seconds whether this looks like the work of someone who knows what they’re doing. Both want the same small handful of things, and nothing else.

That handful is the README’s job: first contact. Anything a reader needs after the first hour belongs in the file that owns that truth.

So a thin README is a feature, not a compromise. A short one earns trust because there’s nowhere for an error to hide; a long one is presumed stale on sight, and usually is. “Getting started” is the new hire’s only tutorial: it works if they reach a green checkmark in under thirty minutes without asking anyone.

The README has one job and two readers, so its shape is fixed: five sections, each short enough to fit on one screen. Here it is for our running example, a multi-tenant invoice SaaS.

# Acme Invoices
A multi-tenant invoice management SaaS built on Next.js 16, Postgres, and Stripe billing.
## Getting started
git clone git@github.com:acme/invoices.git
cd invoices
pnpm install
cp .env.example .env.local
pnpm db:push
pnpm db:seed
pnpm dev
## Common tasks
- Run the test suite: `pnpm test`
- Run one test file: `pnpm test src/lib/billing/entitlements.test.ts`
- Generate and apply a migration: `pnpm db:generate && pnpm db:migrate`
- Reseed the local database: `pnpm db:seed`
- Reset the database from scratch: `pnpm db:reset`
## Where the docs live
- How we work in this codebase: [`AGENTS.md`](./AGENTS.md)
- Architectural decisions: [`/docs/adr/`](./docs/adr/)
- Data model: [`src/db/schema.ts`](./src/db/schema.ts)
- Environment variables: [`env.ts`](./src/env.ts)
## License
MIT — see [`LICENSE`](./LICENSE).

Title and description: one sentence on what the project is, one on the stack. A recruiter reads it in three seconds; a new hire knows what they just cloned. A second paragraph would add nothing.

# Acme Invoices
A multi-tenant invoice management SaaS built on Next.js 16, Postgres, and Stripe billing.
## Getting started
git clone git@github.com:acme/invoices.git
cd invoices
pnpm install
cp .env.example .env.local
pnpm db:push
pnpm db:seed
pnpm dev
## Common tasks
- Run the test suite: `pnpm test`
- Run one test file: `pnpm test src/lib/billing/entitlements.test.ts`
- Generate and apply a migration: `pnpm db:generate && pnpm db:migrate`
- Reseed the local database: `pnpm db:seed`
- Reset the database from scratch: `pnpm db:reset`
## Where the docs live
- How we work in this codebase: [`AGENTS.md`](./AGENTS.md)
- Architectural decisions: [`/docs/adr/`](./docs/adr/)
- Data model: [`src/db/schema.ts`](./src/db/schema.ts)
- Environment variables: [`env.ts`](./src/env.ts)
## License
MIT — see [`LICENSE`](./LICENSE).

Getting started: the shortest copy-paste path to a running dev server, each command on its own line with no prose between them. Narration makes a reader doubt the steps; a bare command list reads as tested. This is the only tutorial in the repo.

# Acme Invoices
A multi-tenant invoice management SaaS built on Next.js 16, Postgres, and Stripe billing.
## Getting started
git clone git@github.com:acme/invoices.git
cd invoices
pnpm install
cp .env.example .env.local
pnpm db:push
pnpm db:seed
pnpm dev
## Common tasks
- Run the test suite: `pnpm test`
- Run one test file: `pnpm test src/lib/billing/entitlements.test.ts`
- Generate and apply a migration: `pnpm db:generate && pnpm db:migrate`
- Reseed the local database: `pnpm db:seed`
- Reset the database from scratch: `pnpm db:reset`
## Where the docs live
- How we work in this codebase: [`AGENTS.md`](./AGENTS.md)
- Architectural decisions: [`/docs/adr/`](./docs/adr/)
- Data model: [`src/db/schema.ts`](./src/db/schema.ts)
- Environment variables: [`env.ts`](./src/env.ts)
## License
MIT — see [`LICENSE`](./LICENSE).

Common tasks: the four or five commands a daily contributor actually reaches for, not every script in package.json.

# Acme Invoices
A multi-tenant invoice management SaaS built on Next.js 16, Postgres, and Stripe billing.
## Getting started
git clone git@github.com:acme/invoices.git
cd invoices
pnpm install
cp .env.example .env.local
pnpm db:push
pnpm db:seed
pnpm dev
## Common tasks
- Run the test suite: `pnpm test`
- Run one test file: `pnpm test src/lib/billing/entitlements.test.ts`
- Generate and apply a migration: `pnpm db:generate && pnpm db:migrate`
- Reseed the local database: `pnpm db:seed`
- Reset the database from scratch: `pnpm db:reset`
## Where the docs live
- How we work in this codebase: [`AGENTS.md`](./AGENTS.md)
- Architectural decisions: [`/docs/adr/`](./docs/adr/)
- Data model: [`src/db/schema.ts`](./src/db/schema.ts)
- Environment variables: [`env.ts`](./src/env.ts)
## License
MIT — see [`LICENSE`](./LICENSE).

Where the docs live: links, not duplication. Four pointers cover conventions, decisions, the data model, and the env vars, so the rest of the README can carry none of it.

# Acme Invoices
A multi-tenant invoice management SaaS built on Next.js 16, Postgres, and Stripe billing.
## Getting started
git clone git@github.com:acme/invoices.git
cd invoices
pnpm install
cp .env.example .env.local
pnpm db:push
pnpm db:seed
pnpm dev
## Common tasks
- Run the test suite: `pnpm test`
- Run one test file: `pnpm test src/lib/billing/entitlements.test.ts`
- Generate and apply a migration: `pnpm db:generate && pnpm db:migrate`
- Reseed the local database: `pnpm db:seed`
- Reset the database from scratch: `pnpm db:reset`
## Where the docs live
- How we work in this codebase: [`AGENTS.md`](./AGENTS.md)
- Architectural decisions: [`/docs/adr/`](./docs/adr/)
- Data model: [`src/db/schema.ts`](./src/db/schema.ts)
- Environment variables: [`env.ts`](./src/env.ts)
## License
MIT — see [`LICENSE`](./LICENSE).

License: one line. A recruiter checks it; a new hire never thinks about it.

1 / 1

Every section either points to a running project (clone, install, the command list) or points to where the real content lives (the docs-live links). None is itself reference material: the README is a map, and the moment a section starts describing something instead of pointing at it, it has left its job.

One detail pays off later: cp .env.example .env.local assumes a committed .env.example exists. We build that file in the env-var documentation, where three separate sections close into one loop.

Knowing what goes in is half the skill; knowing what to evict, and where it belongs instead, is what keeps the README thin. This is where people slip: they feel the pull to be thorough, add a section, and a year later it’s the stale part nobody trusts.

The rule isn’t “don’t add things.” It’s that every non-first-contact line dilutes the README’s one job, and every one of those lines has a real home. Route each candidate to its owner:

  • Full API reference goes to the source: the signature is the contract, its TSDoc the description. (Syntax in the next chapter.)
  • Full env-var list goes to env.ts plus .env.example, two sections from now.
  • Contribution guidelines go to AGENTS.md, which the next lesson builds, or to CONTRIBUTING.md in the one case that warrants it (the last section of this lesson).
  • A “Philosophy” or “Architecture” section goes to /docs/adr/, the decision log. Watch this one: an “Architecture” heading is explanation leaking into a first-contact document, last lesson’s mixing trap in a respectable disguise.
  • Changelog goes to CHANGELOG.md or your platform’s release notes.
  • Team conventions go to AGENTS.md.
  • Deployment notes go to /docs/how-to/deploy.md or a runbook.
  • TODOs, roadmap, meeting notes go to the issue tracker; neither the recruiter nor the new hire wants them, so they fail the audience test outright.
  • Screenshots beyond one or two for context get cut: the UI changes, the image doesn’t, and nobody updates it.

A word on badges. They sit at the top, in the recruiter’s line of sight. A CI-status badge and a license badge earn their place: each answers a question a reviewer actually has at a glance, is it green, and can I use it? Everything else, coverage percentages, dependency versions, last-commit date, is noise dressed as signal. The rule: zero or two badges, never a wall.

Now route the lines yourself. Each belongs to exactly one artifact, and the test isn’t what type of doc is this but which file owns the fact.

Each line below was found in a real README. Route it to the artifact that should own it — not what type of doc it is, but which file in the repo is responsible for the fact. Drag each item into the bucket it belongs to, then press Check.

Stays in the README First-contact only
A source file schema.ts / env.ts / the action
AGENTS.md or /docs/adr/ Conventions or decisions
Issue tracker / changelog Not for the README's readers
A one-paragraph description of what the project is
The local setup commands: clone, install, run
The test command
The license line
The invoices table’s column list
DATABASE_URL and what it’s for
How to rotate the Stripe webhook secret
Why we chose Drizzle over Prisma
A “Roadmap” section listing next-quarter features
A “Recent changes” list of the last few releases

The file that owns a truth is its documentation

Section titled “The file that owns a truth is its documentation”

The rule: the file that owns a truth is the documentation for that truth. The schema file is the data-model doc, the env.ts file is the env-var doc, and a Server Action, with its Zod input schema and TSDoc, is the API doc for that action. The README links to these files; it never copies them.

A paraphrase has nothing keeping it in sync with what it paraphrases. The instant the code changes, the paraphrase is silently wrong: no test fails, no build breaks, the doc just starts saying something false and keeps saying it until someone notices. Source-as-doc can’t drift, because editing the code is updating the doc. Add a column and you edit the schema file, so the data-model doc updated itself, riding along on a change the developer had to make anyway.

This extends the last lesson’s case for links: a link can’t drift because it points at the source instead of copying it, and here the doc and the code are the same file.

The schema gains a column. The paraphrase silently falls behind; the link has nothing to fall behind on.

This stack also avoids a whole artifact: a REST or tRPC codebase typically generates a separate API reference that can fall out of sync, but because Server Actions are the API surface, the function signature already is the contract. Three worked examples make this concrete.

Here’s a Drizzle table. Read it top to bottom, the way a new contributor would, scanning for the shape of the data.

// The invoices table — one row per issued invoice; tenant-scoped on
// organization_id; status transitions are append-only via invoice_events.
export const invoices = pgTable(
'invoices',
{
id: uuid('id').primaryKey().$defaultFn(() => uuidv7()),
organizationId: uuid('organization_id')
.notNull()
.references(() => organizations.id, { onDelete: 'cascade' }),
amountCents: integer('amount_cents').notNull(),
status: text('status').notNull().default('draft'),
createdAt: timestamp('created_at', { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => [index('idx_invoices_org_status').on(t.organizationId, t.status)],
);

The file shows every column, every constraint, every foreign key, and the index that keeps the tenant-scoped query fast. Because it’s executable code, it can’t go stale: a wrong column breaks the build, not just a paragraph.

// The invoices table — one row per issued invoice; tenant-scoped on
// organization_id; status transitions are append-only via invoice_events.
export const invoices = pgTable(
'invoices',
{
id: uuid('id').primaryKey().$defaultFn(() => uuidv7()),
organizationId: uuid('organization_id')
.notNull()
.references(() => organizations.id, { onDelete: 'cascade' }),
amountCents: integer('amount_cents').notNull(),
status: text('status').notNull().default('draft'),
createdAt: timestamp('created_at', { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => [index('idx_invoices_org_status').on(t.organizationId, t.status)],
);

The one-paragraph header comment is the addition worth making. The columns tell you what the table is; the comment tells you why it exists and names the one non-obvious invariant: status changes are append-only, recorded elsewhere. It sits inches from the code it describes, in the file you edit to change that code, so it can’t drift.

// The invoices table — one row per issued invoice; tenant-scoped on
// organization_id; status transitions are append-only via invoice_events.
export const invoices = pgTable(
'invoices',
{
id: uuid('id').primaryKey().$defaultFn(() => uuidv7()),
organizationId: uuid('organization_id')
.notNull()
.references(() => organizations.id, { onDelete: 'cascade' }),
amountCents: integer('amount_cents').notNull(),
status: text('status').notNull().default('draft'),
createdAt: timestamp('created_at', { withTimezone: true })
.notNull()
.defaultNow(),
},
(t) => [index('idx_invoices_org_status').on(t.organizationId, t.status)],
);

This column has no comment, and shouldn’t: its name and type say everything. The rule is one header comment per table and no per-column narration, unless a column’s purpose isn’t obvious from its name and type. A comment restating amount_cents: integer is noise that will one day contradict the code.

1 / 1

Tenant-scoped means every row belongs to one organization and every invoice query filters on organization_id, so one tenant can never see another’s data.

Environment variables are the classic place docs rot. The temptation is a table in the README plus a second copy in ENVIRONMENT.md, while the real list lives in code: three sources of truth, at least two wrong at any moment.

Instead, keep one: a typed env.ts built on @t3-oss/env-nextjs and Zod. The two files below are the entire env-var documentation surface, two halves of one contract: env.ts defines and validates the variables, and .env.example is the committed copy a new contributor starts from.

export const env = createEnv({
server: {
DATABASE_URL: z.url(),
STRIPE_SECRET_KEY: z.string().min(1),
// Webhook signing secret — without it, every Stripe event is rejected.
STRIPE_WEBHOOK_SECRET: z.string().min(1),
},
client: {
NEXT_PUBLIC_APP_URL: z.url(),
},
experimental__runtimeEnv: {
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
},
});

The validator, and the doc. Each variable’s Zod type is its reference: z.url() means “must be a URL,” and the server/client split says where it can be read. This runs at build time, so a missing DATABASE_URL fails pnpm build — a contract the toolchain enforces rather than one someone must remember to update. The lone comment carries the why the type can’t.

The loop closes: Getting started points at .env.example, that file lists what the build requires, and env.ts enforces it at build time. The README’s entire env-var section is a single link to env.ts. Three artifacts, one contract, zero duplication — and no ENVIRONMENT.md, since a fourth copy is just a fourth thing to get wrong.

Server Actions are this project’s API surface, so each action documents its own endpoint.

Here’s the createInvoice action with the mutation body left out, so you read only what a caller needs: the contract.

const createInvoiceSchema = z.object({
customerId: z.uuid(),
amountCents: z.number().int().positive(),
});
/**
* Creates a draft invoice for the active organization.
*
* @throws If the customer belongs to a different organization.
*/
export const createInvoice = authedAction(
'member',
createInvoiceSchema,
async (input, { orgId }): Promise<Result<Invoice>> => {
// …
},
);

The Zod schema is the input contract: a valid call is a customer UUID and a positive integer amount. It validates at runtime too, so no separate “parameters” table can quietly disagree with it.

const createInvoiceSchema = z.object({
customerId: z.uuid(),
amountCents: z.number().int().positive(),
});
/**
* Creates a draft invoice for the active organization.
*
* @throws If the customer belongs to a different organization.
*/
export const createInvoice = authedAction(
'member',
createInvoiceSchema,
async (input, { orgId }): Promise<Result<Invoice>> => {
// …
},
);

The return type is the success-and-failure contract. Because Result<Invoice> is a discriminated union, the type alone tells a caller this can fail in expected ways that must be handled, not just wrapped in a try/catch.

const createInvoiceSchema = z.object({
customerId: z.uuid(),
amountCents: z.number().int().positive(),
});
/**
* Creates a draft invoice for the active organization.
*
* @throws If the customer belongs to a different organization.
*/
export const createInvoice = authedAction(
'member',
createInvoiceSchema,
async (input, { orgId }): Promise<Result<Invoice>> => {
// …
},
);

The TSDoc carries the one thing the types can’t. Its first sentence is what an editor shows on hover, and the @throws line names a failure mode no signature would reveal: passing another org’s customer.

1 / 1

The function alone gives the complete contract: the input shape from the Zod schema, the success-and-error shape from the Result<T> return type, and the non-obvious failures from the TSDoc. Three habits stacked together get you there: keep the action small, type it precisely, and comment only the surprises.

A REST or tRPC project generates an API-reference document that can drift from its handlers; this codebase generates none, because the function is the doc. Docs live next to the truth.

CONTRIBUTING.md: when a separate file earns its place

Section titled “CONTRIBUTING.md: when a separate file earns its place”

One routing-list artifact is still unplaced: contribution guidelines. A closed-source SaaS, which this course assumes, needs no CONTRIBUTING.md. Everyone in the repo is on your team, and how the team works lives in AGENTS.md.

External contributors flip that. An open-source library, or any repo where strangers open pull requests, earns a CONTRIBUTING.md covering the PR process, a code-of-conduct pointer, and how to claim an issue. So: closed-source folds into AGENTS.md; open to outsiders gets its own CONTRIBUTING.md.

Two reflexes carry the lesson. The README has one job, first contact, and five sections: title and description, getting started, common tasks, where the docs live, and the license. And docs live next to the truth: link to the schema, env.ts, or a Server Action’s signature, and paraphrase none of them, since a paraphrase drifts the moment the code changes. The one place the README defers to is AGENTS.md, home for conventions and heavier how-to, and that file is next.