Adding your first business resource, from migration to e2e test
Your product is not authentication and billing. It is the object your customers came for: invoices, campaigns, deployments, patients, shipments. Adding one to a multi-tenant SaaS touches seven places, and forgetting any one of them is how tenant leaks happen.
The kit ships no example module to copy — deliberately, because an example module is code you have to read, understand and then delete. What it ships instead is the chain, used by its own tables and documented in build order. Here it is, end to end. Twenty minutes, and each step compiles before the next one needs it.
The chain, in build order
Take a resource called invoices, owned by an organization.
1. The migration. A goose file in migrations/, portable SQL so it runs on
both SQLite and PostgreSQL. The kit's whole schema is 00001_init.sql, so
yours is 00002_invoices.sql:
-- +goose Up
CREATE TABLE invoices (
id TEXT PRIMARY KEY,
organization_id TEXT NOT NULL REFERENCES organizations (id) ON DELETE CASCADE,
number TEXT NOT NULL,
amount_cents INTEGER NOT NULL,
status TEXT NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
CREATE INDEX idx_invoices_org ON invoices (organization_id, created_at);
Two things are not optional: organization_id with ON DELETE CASCADE, and
the index on it. The cascade is what makes deleting an organization complete;
the index is what keeps every listing query fast, since every listing query
filters on it — and orders by created_at, which is why it belongs in the
same index.
2. The queries. A .sql file in internal/db/queries/ that sqlc compiles
into typed Go. Every statement filters by organization — including the ones
that fetch a single row by primary key:
-- name: ListOrgInvoices :many
SELECT * FROM invoices
WHERE organization_id = $1
ORDER BY created_at DESC;
-- name: GetInvoice :one
SELECT * FROM invoices WHERE id = $1 AND organization_id = $2;
-- name: DeleteInvoice :execrows
DELETE FROM invoices WHERE id = $1 AND organization_id = $2;
GetInvoice takes the organization even though the id is unique. That is the
rule that makes a leak impossible rather than unlikely: no query can be called
without the tenant in hand. DeleteInvoice returns the number of rows so the
handler can answer 404 when the id belongs to someone else.
3. make sqlc. It regenerates internal/db/sqlcgen from those statements.
The generated code is committed, so a reviewer sees exactly what changed —
and if a query does not compile against the schema, you find out here rather
than at runtime.
4. The handler. Plain CRUD in internal/http/handlers/, reading the
organization from the request context, which the tenancy middleware has
already resolved:
func (h *Invoices) List(w http.ResponseWriter, r *http.Request) {
oc, _ := middleware.OrgFrom(r.Context())
rows, err := h.Q.ListOrgInvoices(r.Context(), oc.Org.ID)
// ...
}
No handler ever reads an organization id from the query string or the body.
It comes from the context, which came from the route, which was checked
against the caller's memberships. Why that matters.
handlers/orgs.go is the closest example to copy; introduce a service package
only when real business rules show up, not before.
5. The routes. One block in internal/http/server.go, mounted under the
{orgID} router so the middleware chain applies, with role requirements
where they belong:
one.Route("/invoices", func(inv chi.Router) {
inv.Get("/", invoicesH.List)
inv.Post("/", invoicesH.Create)
inv.With(middleware.RequireOrgRole(org.RoleAdmin)).Delete("/{id}", invoicesH.Delete)
})
one already carries orgMW.Context, which is what guarantees membership.
Routes live in one file on purpose: the tree is the documentation of who can
reach what.
6. The frontend. Four small files and one line: a response type in
web/src/api/types.ts, a Pinia store in web/src/stores/ calling the typed
fetch client (it carries the CSRF token and normalises API errors), a view in
web/src/views/, a route in web/src/router/index.ts, and a link from the
dashboard.
7. The tests, at both ends. An integration test against a real database
asserting CRUD, RBAC, and the one that matters most: a member of organization
B asking for organization A's invoice and receiving 404.
internal/http/orgs_test.go shows the shape. Then a Playwright spec that
creates, lists and deletes through the interface, against the real binary.
Step 7 is not optional. Row-level tenancy holds because every query filters by organization; the test is what keeps that true after the next edit — including the edits you make in a hurry, six months from now, on a Friday.
Why you write this by hand
A generator could produce those seven files. The kit had one, and it was removed.
The honest case for a generator is real: it saves twenty minutes and it never
forgets the organization_id. The case against it is that it adds a thing to
learn — its flags, its templates, its idea of what your fields are — in a
product whose entire pitch is that there is nothing to learn beyond Go, Vue
and SQL. And the output still has to be read line by line before you trust it,
because it is code you did not write and will nevertheless modify.
Twenty minutes, once per resource, buys a codebase where every layer is one obvious file and nothing in it was generated by a tool you have to keep around. After the second resource you stop thinking about the chain and start thinking about the product, which is the only outcome that matters.
If you do want the twenty minutes back, the chain is regular enough that your editor's snippets or your own script will cover it — on your terms, in your repository.
What to check before you ship it
The chain above is the safe version. Your edits are where the risk returns:
- Did you add a query that takes only an id? If yes, it needs the organization too.
- Did you add a listing endpoint outside the org router? It will not have a tenant in context.
- Does deletion require a role? Members can usually create and read; destroying things is normally owner or admin.
- Does the cross-tenant test still pass? It is the single test worth running after every change to a handler.
When you outgrow the pattern
Two situations justify leaving it:
Resources shared across organizations — a public template library, for
instance. Give them their own table without organization_id, and be explicit
that they are global. Do not model them as a magic organization.
Resources scoped to a user rather than an organization — personal API
tokens, notification preferences. They belong to user_id, and in solo mode
the distinction almost disappears anyway.
Everything else is the same seven steps.
New to the kit? Start with your first hour.