Skip to content

By Shrin4 min readDraft

Stable full-stack code with Claude Code: let the stack catch the mistakes

CanteenCrowd is a Go and React SaaS written with Claude Code: 312 commits in a few weeks. The speed is easy. Keeping it stable comes from written rules, recorded decisions and types that carry the contract from Postgres to the page.

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 and x/crypto. The router is the standard library’s http.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.

Written by Shrin, who builds CanteenCrowdGitHubLinkedIn