CanteenCrowd is a multi-tenant SaaS for canteen operators, with a Go backend on Postgres and a React frontend. I build it with Claude Code: I decide the design and review every change, and Claude Code writes most of the code. A small open-source sample of the backend, Shrin210-98/CanteenCrowd-go-api, is written without Claude Code.
It moves fast. The first commit was on 12 October; three weeks later there are 312 commits, about 32,000 lines of Go (not counting generated code), 50,000 lines of TypeScript, and ten working modules: staff, diners, menus, events, purchasing, inventory, an operations hub, a support desk with live chat, users and roles, and settings.
Here’s the backend in numbers:
- 82 database migrations
- 321 SQL queries in 33 files
- nearly 17,000 lines in the database package, almost all of it generated
- 5 direct dependencies in
go.mod: pgx, golang-jwt, gorilla/websocket, the validator andx/crypto. The router is the standard library’shttp.ServeMux.
Writing code quickly is the easy part. The hard part is that an AI assistant makes the same kinds of mistakes a new teammate makes: it forgets a convention, guesses a field name, skips the one check that mattered. In a multi-tenant app, one of those can show a customer someone else’s data.
So the stack is chosen, and the repository is set up, so that mistakes fail loudly and early: at compile time or in CI, and never quietly in production. Here’s how.
1. Write the stack and the conventions down
The project’s rules live in CLAUDE.md files in the repository, next to the code they describe.
They’re specific, and they say what not to do as clearly as what to do:
## Fixed stack (do not substitute)
- Go 1.27+, stdlib `uuid`, stdlib `net/http` router (no chi/gin/echo), validator/v10
- PostgreSQL 18, pgx/v5, sqlc
- Frontend: Vite, TypeScript, Tailwind v4, shadcn/ui, npm
## How to run things
- Use `make` targets. Never run `go`, `sqlc` or `golangci-lint` on the host; they run in the
`tools` container via `make <target>`.
Left to itself, an assistant reaches for whatever it has seen most: a popular router, an ORM, a different test runner. Written down, each of those choices is made once. The files also hold:
- The module pattern, with reference modules to copy: where pages, queries, schemas and pure logic go, how query keys are nested, which caches a write refreshes.
- The API contracts: the list response shape, the error codes, which permission each route needs.
The rules cost upkeep: every phase ends by updating them. A wrong rule is followed as faithfully as a right one.
2. Plan in documents, and record every decision
Each feature starts as a plan in docs/plans/: the decisions and why, the schema, the phases. Code
starts only once the plan is agreed, one feature/* branch per phase.
Anything deliberately left out or deferred becomes a numbered entry in docs/known-issues.md: what
the problem is, what was decided, and when it gets revisited. There are 85 of them now, from “event
alerts are in the app only” to “stock counts are partial”. A settled decision stays settled,
instead of being re-decided by accident halfway through a feature.
3. Let the types carry the contract, end to end
This is where most mistakes get caught. A value in CanteenCrowd crosses four layers on its way to the screen: a Postgres column, generated Go, JSON, and TypeScript. At every layer a type says what the value is, so a wrong guess breaks the build instead of the product.
Follow one value through: the kind of an event’s plan item (a vendor, a supply, a task, a payment line…).
Postgres: the first type is a constraint
The database refuses a kind it doesn’t know, whatever the code above it does:
CONSTRAINT event_items_kind_check CHECK (
kind IN ('vendor', 'shopping', 'supply', 'catering', 'timeline', 'task', 'payment', 'budget', 'guest_group')
),
Generated Go: NULL means a pointer, money means cents
sqlc reads the schema and the queries and writes the Go structs, so the struct always matches the table. Trimmed:
type EventItem struct {
ID uuid.UUID `json:"id"`
Kind string `json:"kind"`
DueOn *time.Time `json:"dueOn"`
Quantity pgtype.Numeric `json:"quantity"`
PlannedCents *int64 `json:"plannedCents"`
IngredientID *uuid.UUID `json:"ingredientId"`
Data json.RawMessage `json:"data"`
}
Every nullable column is a pointer, so “no due date” can’t be confused with the zero date. Money is
an int64 of cents, never a float. A quantity stays an exact decimal. Add a column, and the struct changes with it; forget to
regenerate, and CI fails on the diff (the previous post covers
that).
The JSON contract, mirrored in TypeScript
On the frontend, the same list is a const array, and the type is derived from it:
/** Display order (domain.EventItemKinds). supply, task and payment are CC3. */
export const ITEM_KINDS = [
"vendor",
"shopping",
"supply",
"catering",
"timeline",
"task",
"payment",
"budget",
"guest_group",
] as const
export type ItemKind = (typeof ITEM_KINDS)[number]
export const ITEM_KIND_LABELS: Record<ItemKind, string> = {
vendor: "Vendors",
supply: "Supplies",
payment: "Payment schedule",
// … one entry per kind
}
Record<ItemKind, …> is the part that pays off. When supply, task and payment lines were added, the
compiler listed every place that needed them: the section labels, the “Add a supply” wording, the
table that says which form fields each kind uses. A kind can’t ship with a missing label or form.
This is the one seam that isn’t generated: the TypeScript types are written to mirror the Go ones, with a comment naming their Go source, and the backend docs keep a “known drift” section for any difference. Generating them from the Go types is the obvious next step.