This is Go Fast Stack, a Go + Datastar monorepo template: one Go module, one static binary, server-rendered templ pages with Datastar, a JSON API for the mobile app, Postgres through pgx + sqlc + goose, and Go-native auth modelled on Better Auth. It replaces the Next.js template jayleaton/ai-patterns-monorepo and keeps the same patterns.
Shared vocabulary lives in CONTEXT.md. Use those words.
- Template first. People clone this and diverge right away. Keep it generic and minimal. The example flows (the users API, the dashboard) are documentation as code: keep them canonical and small. If a change only makes sense for one project, it does not belong here.
- Performance without compromise. Render on the server, keep client JavaScript to what Datastar needs, and treat binary size, memory and verification time as features. Prefer the standard library; every dependency needs a reason.
- Network-ready dev servers. The server binds
0.0.0.0:3000, so it works the same on localhost, the LAN, and Tailscale (http://<machine-hostname>:3000). Never bakelocalhostinto code, emails or auth callbacks. - Monorepo-aware.
packages/database,packages/auth,apps/webandapps/mobile-appmove together. Every change gets a decision per workspace, even if it is "no change needed".
These are good defaults, not hard rules. The developer's preferences override anything here.
The template's value is its layers. Do not route around them.
packages/database (migrations → sqlc queries → repository) → apps/web/internal/services → apps/web/internal/api (the JSON API: access gate, validation, envelope) → apps/web/ui (HTML/Datastar), which reaches data only through the API's client.
browser ── form / Datastar action ──▶ UI action route (CSRF) ──▶ apiclient.Client ──┐ in-process
mobile app, servers ── JSON over HTTP ──▶ api.Endpoint.Serve ───────────────────────┤
▼
api.Endpoint: gate (policy) → validate → handler → service → repository
-
Each layer talks only to the layer below. The UI calls the API, never the services: pages and actions use
apiclient.Client(apps/web/internal/apiclient, the only app packageui/**may import for data), whose implementationapi.Clientruns eachapi.Endpointin-process throughEndpoint.Call: the same access policy, the same validation and the same handler as the HTTP route, with no network hop, so every read and write passes the one gate. Nothing calls its own API over HTTP:apiclienthas no HTTP implementation or base URL (depguard rejectsnet/httpthere, forbidigo rejects HTTP clients inui/**). A client over HTTP for a split deploy would implement the same interface; build it only when a deploy needs it. -
Browser interactions go through UI server actions, never to the API. A form post or Datastar action targets a UI action route (
action(policy, ...): CSRF-protected, inconfig.AppRoutes/ui/routes), which calls the API client and answers with HTML. For example, the Account page's profile form:// ui.go: the action route, with its policy action(apiclient.SignedIn, "POST "+routes.AccountProfile, ui.updateProfile) // handlers_account.go: the server action calls the API in-process name := request.PostFormValue("name") updated, err := ui.api.UpdateMe(ctx, apiclient.UpdateUserSettings{Name: &name}) // gate → Validate → api handler → UserService // api/users.go: the endpoint both ways in share users.updateSelf = Endpoint[validate.UpdateUserSettings, UserUpdated]{"PATCH", routes.Me, access.SignedIn, users.updateMine}
The browser never calls
/api/..., never receives the API's JSON, and no rendered HTML or JS names an API path: forbidigo rejectsconfig.APIRoutesinui/**, andscripts/check-ui-api-paths.sh(inmake lintandmake check-fast; probed bymake lint-layering) fails on any/api/in UI templates, code or static assets. -
Sign-in flows are API endpoints too. Sign-up, sign-in, sign-out, verification, password reset and 2FA are
api.Endpoints (api.Sessions) over anapi.Authenticatorport thatserver.Authenticatoradapts frompackages/auth. The UI server action calls the client and sets the cookies the result carries withsetCookies(ui/cookies.go); auth signs them, the UI only copies them onto its response. -
The UI never imports
services,validate,access,api,packages/auth, repositories,packages/databaseor sqlc output, andservernever imports its app'sui(no adapters for UI-owned ports); transports never importpgx, sqlc output or repositories. Services never importnet/http, templ or Datastar.packages/databasenever importsapps/.depguardin.golangci.ymlenforces this, andmake lint-layeringproves the rules still bite. -
Ports are defined by the consumer. A service declares the interfaces it needs (
services.UserRepository,services.AccountCredentials); repositories inpackages/databaseand adapters inapps/web/internal/serverimplement them. The API in turn declares the service it calls (api.UserService); the UI declares nothing below the API: it takesapiclient.Client. -
Authorization has two layers. Route access is the coarse gate: who may reach a route at all (signed in, has a role, belongs to the tenant). Every route declares an
access.Policyon its registration line (anapi.Endpoint{..., access.SignedIn, handler}orapi.Route(gate, access.SignedIn, handler);page(apiclient.SignedIn, ...)in the UI), and oneaccess.Gateper app resolves the caller and enforces it, for UI pages, for HTTP API requests and for the UI's in-process API calls alike (it resolves a request's caller once): 401 in the envelope or a redirect to sign-in when signed out, 403 when denied. Resource authorization (may this caller act on this record) is a business rule: it lives in the service, from itsservices.Actor, never in a handler or a policy. Roles and tenants extendapps/<app>/internal/accessin one place (Caller, the Resolver inserver, a new Policy); see docs/route-access.md. Handlers never read the session themselves: forbidigo rejectsauth.UserFromoutsideapps/*/internal/serverandGate.Admitoutside the route handlers; services never importaccess(depguard). -
Services return
*services.Error{Kind, Message}, declared as named values (services.ErrEmailTaken) so callers match them witherrors.Is(the UI picks a translation per error); the kind sentinelsErrValidation,ErrForbidden,ErrNotFound,ErrConflictmatch every error of their kind. The transport maps the kind to a status code; the message is safe to show. Adapters translate their package's errors into these values; transports never see rawauthorpgxerrors. -
JSON responses always use the envelope
{"data": T|null, "error": string|null}(plusdetailson validation errors). Declare every endpoint once as anapi.Endpoint(method, path, policy, handler) and register it withServe; the UI calls the same value throughapi.Client. A handler is about five lines: take the validated input, call the service, return. -
Input validation lives in
apps/web/internal/validate, one type per input with CRUD names (UpdateUserSettings). Decode withvalidate.Decode[T]. Never validate inline. -
Routes and toggles live in
apps/web/internal/config:AppRoutes,APIRoutes, andFeatures(email verification off in dev, on in production;TemplateWelcome). Never write a path literal or branch on the environment anywhere else. -
Env is read once, in
config.Load, and validated at boot. Nothing else callsos.Getenv. Secrets that must rotate without a redeploy are the one exception to "read once":config.Loadbuilds apackages/secretsmanager fromSECRETS_PROVIDER, and consumers get those values only through typed accessors onconfig.Secrets(DatabaseURL/OnDatabaseURL,AuthSecrets/OnAuthSecrets,OnResendAPIKey; the admin app has its own), wired incmd/*. Services and transports never importpackages/secrets(depguard); they get the client a secret enables through a port. -
packages/authowns theuser,session,accountandverificationtables (Better Auth names). App migrations may add tables that reference"user"(id), and repositories may update non-credential user columns (name,image). Auth migrations run before app migrations (goose_auth, thengoose_app). -
apps/web/internal/serverandcmd/serverare the only places that wire packages together. -
The browser only talks to the UI. Pages and Datastar actions return HTML (fragments over SSE); the JSON API is for the mobile app and servers, and
serveranswers browser requests to it (and/healthz) with a bare 404 (apiOnly: Fetch Metadata,Origin,RefererorAccept: text/htmlmean a browser; no CORS headers; a wrong method is a 404 too; see docs/security.md). The UI's own access to data goes through the API in-process, so these protections stay as they are. Never link to, fetch or render an/api/path, a JSON payload, a token or internal error text from the UI. Emailed auth links for the UI land on UI pages (auth.Config.VerifyEmailPath, relativeredirectTo). -
UIs share
packages/uikit. Build pages from its components and semantic color utilities (bg-surface,text-muted-foreground,border-input,bg-primary, ...), never raw palette classes ordark:variants, so a theme stays a one-file change (packages/uikit/theme.css). The apps still never import each other; anything both need goes in uikit, which takes already translated strings. -
Templates are CSP-clean.
ui.Handlersends a nonce CSP with nounsafe-*: no inlinestyle=/on*=attributes or inline scripts (Datastardata-*expressions are fine; hide withclass="hidden"+data-class:hidden). Never bypass templ's escaping (templ.Raw,templ.Unsafe*): Datastar runs any injecteddata-*expression with the page nonce, so escaping is the XSS defence, not the CSP. Redirect targets from a request go throughsafeNext. A GET never changes state or spends a token (mail scanners prefetch emailed links): a GET handler calls no state-changing port, nodata-initposts on load, and a query token is rendered only in its hidden form field.
The most common defect is a change that works on the path you tested and is missing everywhere else. Before calling work done, walk this list and say which entries applied:
- Entry points. A new page needs its handler, its route in
config.AppRoutes, a Datastar loading indicator, and translations in every locale. A new endpoint needs its route inconfig.APIRoutes. - Workspaces. A feature usually spans
packages/database(migration, query, repository) plus each surface that shows it:apps/web/ui,apps/web/internal/api, andapps/mobile-app. Doing one half is doing half the feature. - Contracts. Migrations and sqlc queries are the contract. Change them, run
make generate, and follow every consumer: repository, service, validator, both transports. - Reverse states. If you add a way in, add the way out and a way to see it. Delete needs restore, disable needs enable, set needs clear.
- Environments. Dev (Docker Postgres, email verification off, email links logged) and production (Resend, verification on) differ only through
config.Featuresand env. Both paths must work. - Placeholders. When you replace a template example with the real thing, or port an app onto the template, delete the example as you go. Don't leave it beside the real route, where an unported route can look done. The template's placeholders are: the starter welcome pages
/and/why(config.Features.TemplateWelcome: set it false, then delete the template-welcome UI); the dashboard's example cards (LiveCard's live stream andquickActionsinapps/web/ui/pages_dashboard.templ: documentation as code, replace them with your app's); the admin Analytics page (services.AnalyticsService, messagepages.analytics.placeholder); and the Infisical and 1Password secret providers (packages/secrets/infisical.go,onepassword.go: stubs outside the default build). None answers501. If you add a stub route or page while porting, make it501 Not Implementedand track it, so a client test or crawl fails on any501left over. Delete it in the same PR that lands the real route. - Docs. Behaviour a template user would notice goes in
README.mdor its topic file indocs/; patterns go here.
Killing by pattern. Never pkill -f, pgrep | kill, or kill a PID found by matching a name, path or port. Many servers run on this machine and yours can match. Kill only the PID you captured at spawn.
Trusting the wrong database. The Docker Postgres from make db-up is the sandbox: seed it, reset it, break it. A hosted database (Railway, Neon, production) is never a playground: don't point dev at one, migrate one, or copy real data in or out unless explicitly asked. make db-down keeps the volume; docker compose down -v deletes it.
Baking in origins. Never hardcode http://localhost:3000 in code, emails or auth callbacks. Origins come from PUBLIC_URL, INTERNAL_URL and TRUSTED_ORIGINS through config.Config, or every non-local client breaks.
make db-up && make migrate && make devstarts Postgres + pgweb (bound to 127.0.0.1), runs migrations, and serves on0.0.0.0:3000..envis created from.env.exampleon first run.- Each checkout (clone or git worktree) gets its own compose project and dev database ports, derived from its path; the test Postgres takes a free port on every start. Parallel worktrees never share or collide.
make envprints the values; overrideCOMPOSE_PROJECT_NAME,POSTGRES_PORT,PGWEB_PORT,TEST_POSTGRES_PORTorDATABASE_URLin.envonly when you need fixed ones. Always go throughmake: baredocker composeuses the shared default project.PORT(the app) is not derived; set it in.envwhen two worktrees runmake devat once. - Auth is real in every environment. In development email verification is off and the dev mailer logs verification and reset links to the server log, so sign up through
/api/auth/sign-up/email(or the UI) and copy links from the log.packages/auth/README.mdhas the endpoints, the security model and how to port more Better Auth features. - A running dev server is fine to leave up. Stop only what you started, by PID.
- Inspect data with pgweb (URL from
make env). Seed realistic data before verifying a user-facing flow; an empty database is a bad test. Secrets go in.envonly when the flow needs them, and never get committed.
make check-fastbefore every push (mandatory). It runs gofmt, golangci-lint (whosegovetrunsgo vet's analyzers), the test-location check and plaingo test(no-race) on only the packages your changes can affect, plus the generators whose inputs changed, thedeploy/module (lint, names andmake deploy-vars) when it,.env.exampleor the Makefile changed, the migration order when a migration changed (see "Migrations"), andmake vulncheckwhen ago.mod,go.sum,package.jsonorpnpm-lock.yamlchanged. Changes mean everything that differs from the merge base with origin/main: commits, staged, unstaged and untracked files, mapped to packages byscripts/affected.sh(make affectedprints them), so a shared input (go.mod, tooling, lint config, migrations) checks everything. It takes seconds (a docs-only change: under a second; a full run: under 10 s warm), so it fits machines where a fullmake ciis not allowed. Database tests skip unlessTEST_DATABASE_URLis set;make testruns them with-raceagainst Postgres.make hooksinstalls a pre-push hook that runs it from every worktree of the clone (make hooks-removeundoes it); a failing check is fixed before the push, never skipped. Anything that runs git or go from a hook drops git's repository env first (git rev-parse --local-env-vars), as the hook andscripts/check-fast.shdo.- Release smoke (
make release-smoke; run it whenever a build input changes: a Dockerfile, or code a Dockerfile's binary is built from): builds every binary that has a Dockerfile with that Dockerfile's own build-stageENVandRUNline (scripts/dockerfile-builds.shreads them, so never copy build flags elsewhere) for linux/amd64 (the image's platform, on any host) and fails on a dynamically linked one, or whenfilecannot tell, runsserver migrateandadmin migrateon fresh databases, and boots web and admin withAPP_ENV=productionand only the config production requires until/healthzanswers 200 from the very processes it started. It refuses to start while a smoke port (3390 web, 3391 admin;SMOKE_WEB_PORT,SMOKE_ADMIN_PORT) already answers, because a leftover server would pass a build that cannot boot, and a 200 counts only while the started process still runs. It catches what tests cannot: an image that will not start on distroless (a dependency that links dynamically even withCGO_ENABLED=0), and a production config that refuses to boot without an optional integration. A new required production setting goes in its env list. make lint testbefore every commit when you can run the full suite.make ciruns every check on the checkout as it is: build, generated-code diff (sqlc, mocks, templ), gofmt + golangci-lint, the test-location check,go test -raceagainst a throwaway Postgres with per-boundary coverage floors, and the probes that prove the layering and test rules still bite (make lint-layering lint-tests).make vulncheck(also inmake ci) fails on any known vulnerability in a dependency of any Go module or the mobile lockfile (docs/CI.md).- A pull request is verified locally, on its exact head, then attested (mandatory; docs/CI.md). Run what the change needs ("What to run" there):
make check-prruns it and prints the statement to attest with; by hand, for Go,make lint testscoped withLINT_DIRS/TEST_PKGSfromscripts/affected.sh, plusmake check-generated,make check-migrations,make deploy-check,make actionlintormake release-smokefor what you touched,make cifor a change to the tooling (make ci-pgruns it,make check-migrationsand the release smoke on a throwaway Postgres 18 where there is no Docker). Commit, push, thenmake ci-local RAN="<what you ran>"in the clean worktree. It runs nothing and takes seconds: it refuses a dirty, unpushed or already-failed head, posts thelocal-cicommit status and fills the PR body's "Local verification" section with your statement, which is never edited by hand. Every push or rebase needs a new attestation. Stack PRs and merge right away. - A failed head is final. A failure is fixed forward with a new commit, never attested until green: a head attested as failed (
make ci-local FAILED="<what>") is refused for good bymake ci-local, theLocal verificationcheck andmake pr-merge. A failure in what the branch changed is fixed in the PR; any other is fixed forward at once in its own PR offmainwith a regression test that fails before the fix, and the branch rebases on it. - GitHub runs no verification: on a PR only
Secret scanandLocal verification(which shows "Expected" untilmake ci-localclaims the head), started bymake ci-local's body edit, and onmainonlyDeploy staging, which ships a commit only whenmake ci-localattested it. Avoid adding workflow jobs: a new check is a make target the agent runs, listed in docs/CI.md "What to run". The PR jobs run onlymain's code (pull_request_target) and read the head as data; never check out or run the head there (docs/CI.md). - Run the narrowest thing first:
go test ./apps/web/internal/services/.... - Behaviour changes ship with tests at the boundaries below, and only there.
- A test that needs a sleep or timeout to pass is wrong. See "Fast tests".
- Test memory:
auth.Newhashes passwords with cheap argon2 settings inside test binaries (testing.Testing()), so suites that sign users up stay small under-race; onlyTestPasswordHashDefaultsexercises production cost. A full coldmake testpeaks around 2 GB, almost all of it compiling, so don't lower-p. If a test or compile dies withsignal: killed, check the cgroup'smemory.events:oom_kill 0means something outside the repo sent the kill (another job on a shared machine), not a memory limit. - User-visible UI changes get one pass in a real browser, over the LAN or Tailscale URL when origins matter.
The business logic is the testing layer. Tests live only in these six directories; everything else (UI, config, server wiring, repositories, packages/database, packages/secrets, deploy/) has no tests and must not get any. The database is assumed working: migrations are checked against real state before they ship, and services see repositories only through mocked ports. All of it is enforced by make lint, not by review: scripts/check-test-boundaries.sh rejects a _test.go anywhere else (there is no allowlist; do not add one), depguard rules in .golangci.yml reject the wrong imports in test files, and scripts/check-coverage.sh holds a coverage floor per boundary.
| Boundary | Directory (not subdirectories) | What a test does | It may use | Floor |
|---|---|---|---|---|
| service | apps/web/internal/services, apps/admin/internal/services |
unit-tests every method: happy path, 403 for another user, each rule, every port error mapped | generated mocks of the ports (services/mocks) |
90 % |
| route handler | apps/web/internal/api, apps/admin/internal/api |
per route: no session → 401, other user → 403, invalid input → 400, happy path → 2xx + envelope; each api.Client method (the UI's in-process path) reaches its service; validators are covered here |
generated mock of the service (api/mocks), apitest.AsUser(u) / apitest.AsAdmin(a) / apitest.Anonymous through apitest.Gate(), the real router and api.Route; route_test.go covers the access outcomes (public, 401, 403 from a custom policy, 500 from the resolver) |
85 % |
| backend auth | packages/auth |
its own suite: sessions, passwords, rate limits, rotation | real Postgres | 80 % |
| local verification | scripts/cilocal/verify |
the rules make ci-local and its GitHub checks enforce: the head and failed-head refusals, the PR section and its checks, the merge decision, the staging verdict, the post-merge report's list |
the standard library only; the package stays pure (depguard rejects os/exec and net/http in it) |
85 % |
If behaviour outside these directories matters, move the logic into a service and test it there. Service tests may not import pgx, packages/database (services re-export User, ErrRecordNotFound, ...), packages/auth or api; handler tests may not import pgx, packages/database, packages/auth or services/mocks (mock the service, never what is underneath it); production code may not import mocks or apitest.
Mocks are generated, never written: //go:generate go tool mockgen -source=<file>.go -destination=mocks/<file>.go -package=mocks next to the interfaces, make generate, commit the output (make check-generated fails on a stale mock). mockgen is a tool in the root go.mod so it always matches the go.uber.org/mock version the mocks import.
The suite runs on every change it can affect, under -race on machines as small as a 2-vCPU box, so its speed is a feature that has to survive growth. Lint and make test enforce these; make lint-tests proves the budgets trip.
- Parallel. Every test starts with
t.Parallel()(paralleltest). Slow table tests make their cases parallel subtests too;tparallelkeeps a test and its subtests consistent. Build expensive fixtures once per group and share them with the parallel subtests: a sandbox or runner may have 2 vCPUs, so a split that doubles CPU time gains nothing, and a long subtest trips the budget. Judge CPU cost withGOMAXPROCS=2; on a big machine idle parallel workers inflate it. Never share a gomock controller,httptest.Serveror recorder across parallel subtests; build them per case. A test that must touch process-wide state stays serial with//nolint:paralleltest // <why>; prefer removing the global instead. - No sleeps.
time.Sleepin a test fails lint (forbidigo). Wait on a channel, a condition orsynctest.Wait; for intervals, expiry and backoff usetesting/synctestor an injected clock (auth'stestClock). - One database per test, cloned.
packages/auth(the only suite on Postgres) clones a database migrated once per server in itsTestMain(newDatabase), so tests never migrate; tests on its shared database use unique data (uniqueEmail). - Cheap crypto.
auth.Newuses cheap argon2 inside test binaries; onlyTestPasswordHashDefaultspays production cost. - Table tests. One test with a case table, not a copy per case.
- Budgets.
make testpipesgo test -jsonthroughscripts/testbudget: plaingo testoutput, then the slowest tests, then a failure if a package's test binary runs over 60 s or a single test (a leaf test or subtest) over 10 s, under-race(about twice today's slowest on a 2-vCPU runner, so a doubling fails). The budget times test binaries, not their compilation; run lint and the timed tests one after the other, so they never share the CPUs. Over budget means split or speed up the test. Raise a budget (constants inscripts/testbudget) only with the reason in the commit. - Measure, don't guess.
make bench-devloopprints the timing tables (build, generate, lint per linter, probes, tests per package with and without-race);BENCH_COLD=1adds cold caches,BENCH_PKGSnarrows the packages, andBENCH_BASE=<ref>adds an A/B of the test run against that ref on the same machine (medians of alternating rounds). Separate runs land on different machines and vary by tens of percent, so compare a perf change with the A/B: push it to abench/<name>branch (the "Bench dev loop" workflow compares it with main) and delete the branch afterwards.
- A new migration takes the next number above the highest on origin/main, per goose set (
packages/database/migrationsgoose_app,packages/auth/migrationsgoose_auth,packages/database/adminrepo/migrationsgoose_admin). If it moves past it before your branch merges, renumber it. Goose refuses out-of-order migrations: once a deployed database has applied 00007, a later-merged 00005 failsmigrateon every deploy. A gap is fine; a number below an applied one is not. - Append-only once merged: fix a mistake with a new migration, never by editing or renumbering a merged one (deployed databases record its version).
scripts/check-migration-order.shenforces it and names the number to renumber to:make check-fastruns it when a migration changed (against the tip of origin/main, so a branch main has moved past fails too), andmake check-migrationsruns it alone (run it before attesting a migration). Two open branches may take the same next number:make landrenumbers the one that lands second itself, inside the CI lock right after its rebase (cilocal land-migrations: in order above main's highest, with every exact mention of the old file stem, committed as "chore: renumber migration"), and stops instead on a changed or deleted existing migration or two new ones with one number.
apps/admin is optional and copies the web app's layers exactly: packages/database/adminrepo (sqlc) and adapters over packages/auth → apps/admin/internal/services → apps/admin/internal/api (/api/admin/v1, not mounted on the network by cmd/admin) → apps/admin/ui, which calls that API in-process through its own apps/admin/internal/apiclient (same rules as the web app), wired in apps/admin/internal/server and apps/admin/cmd/admin (admin serve | migrate | invite <email>). Config, routes and validators are its own (apps/admin/internal/{config,validate}).
- Two databases. The admin database (
ADMIN_DATABASE_URL) holds a secondpackages/authinstance (admins:goose_auth, cookie prefixadmin) andadmin_audit_log(goose_admin). The app database (DATABASE_URL) holds the users it administers, reached throughauth.UserAdmin(packages/auth owns those tables, ban rules and session revocation) and read-onlyadminrepo.Stats. - Isolation is enforced.
apps/web/**never importsapps/adminoradminrepo;apps/admin/**never importsapps/web(copy patterns, not code, so either app can be deleted); admin transports never importpackages/autheither: the admin API declares its ports (api.Authenticator) andserveradapts auth to them.make lint-layeringproves every rule both ways. - Every admin is equal;
services.Policy(any admin, 2FA whenADMIN_REQUIRE_2FA) is checked first in every service method. Every mutation goes throughservices.mutateand fails closed: a pending intent row is appended before the change (if that write fails, the action is refused withErrAuditUnavailable, 503, and nothing changes), then the outcome (succeeded, orfailedwith a safe reason) is appended toadmin_audit_outcome. If the outcome write fails after the change, the caller gets a 500, the row shows "outcome unknown", andservices.AuditReconciler(startup and every 5 min,ADMIN_AUDIT_RECONCILE_AFTER) resolves it from the current state. Both tables are append-only (trigger). Authentication events (sign-in, sign-out, refused 2FA) are different on purpose: packages/auth emits them after the fact (auth.Config.Events→server.NewAuditEvents→audit/Append), best-effort, never blocking a sign-in; a failed write is logged and auth counts dropped events. Never route an admin mutation through that path. The CLI acts asservices.SystemActor. - New admin feature: same steps as "How to add a service", in
apps/admin, with the change wrapped inmutatein the service, an action constant (services.Action*) translated in the UI, and a reconcile verdict inreconcile.gowhen the end state can be read back.
Copy the user account flow; it is the reference for every service and repository. For a Widget owned by a user:
- Port. In
apps/web/internal/services/widgets.go, declare what the service needs from below:type WidgetRepository interface { ... }with model types re-exported (type Widget = model.Widget). Anything that is not your database (auth, email, payments) gets its own port andErr*values the implementation must return (seeAccountCredentials). Add the//go:generate go tool mockgen ...line. - Repository. Migration in
packages/database/migrations, queries inqueries/widgets.sql,make generate, thenpackages/database/widgets.go: a struct whose method set is exactly the port, returningmodel.ErrNotFoundfor a missing row. A non-database port gets a thin adapter inapps/web/internal/serverthat only converts types and errors. - Service.
WidgetServicewithNewWidgetService(ports...). Every method takes(ctx, actor Actor, <target id>, in validate.X), checks the actor may act on the target (403 otherwise) before touching a port, applies the business rules, and returns named*services.Errorvalues (ports report their failures as those values; unknown errors pass through as 500s). - Validators. One type per input in
apps/web/internal/validate(CreateWidget,UpdateWidget) for shape only; rules that need state stay in the service. - API. In
apps/web/internal/api/widgets.go, declaretype WidgetService interface(the methods the handlers call, plusvar _ WidgetService = (*services.WidgetService)(nil)) with itsgo:generateline, routes inconfig.APIRoutes, and each endpoint once as anapi.Endpoint[validate.CreateWidget, Widget]{"POST", routes.Widgets, access.SignedIn, widgets.create}(or a stricter policy) registered withServe. Handlers are about five lines. The policy says who may reach the endpoint; the service still checks the actor may act on the widget. - Client and UI. Add the methods the UI needs to
apiclient.Client(and the types and errors it uses, as aliases) and implement them onapi.ClientwithEndpoint.Call. UI pages and actions callui.api.CreateWidget(ctx, input)from anaction(apiclient.SignedIn, ...)route; never a service, never an/api/path from the browser. - Wire.
cmd/server/main.gobuilds the repository and adapters and passes them to the service, the service to the API, andapi.NewClient(...)to the UI. - Tests, at the boundaries only:
services/widgets_test.go(mocks of the ports),api/widgets_test.go(mock service +apitest, the four cases per route, and eachapi.Clientmethod),packages/database/widgets_test.go(real Postgres).make generate lint testmust pass, including the coverage floors. - Every layer. Walk "Hit every layer" above: the UI, the mobile app, docs.
Secrets never reach templates, SSE, JSON or logs:
- Typed. A credential in
config.Config(or anywhere) is asecrets.Secret, never astring(DatabaseURL,AuthSecret,DOPPLER_TOKEN...): it prints, logs and marshals as[redacted]. Validate withLen,IsZero,Equal. - Revealed only in
cmd. NeitherSecretnorKeyringhas a method that returns a value, so no interface can reach one (TestNoMethodReturnsTheValuefails if one is added). The only way to read them isreveal.Value(s)/reveal.Keyring(k)(packages/secrets/reveal), called inapps/*/cmdwhere the client is built (database.Open,auth.Config,NewResendMailer); depguard rejects importingrevealanywhere else, and it reads the value throughpackages/secrets/internal/access, which Go's internal rule keeps private. Insidepackages/secretscode uses the field directly. UIs and JSON APIs never importpackages/secrets, and a UI never imports its app's JSON API (depguard;make lint-layeringprobes each rule and proves the bypasses do not compile). - Redacting logger. Both binaries log through
secrets.NewRedactingHandler: values of credential-named keys (password, secret, token, api_key, authorization, cookie, dsn, database_url, otp, backup codes...) and, in any string,Bearertokens, URL passwords, token/code/key query values, emailed-link tokens and Cookie headers become[redacted]. Outside production it keeps the dev mailer'surlreadable. It is a net, not a licence: logFingerprint().
- It rotates live. Every secret does except
DOPPLER_TOKEN; pick the mechanism by consumer: an outbound API key rebuilds its client (auth.SwapMailer), a database URL goes through adatabase.Pool(Swap: probe, atomic switch, drain), a signing/encryption key goes through a keyring with a grace period (secrets.Keyring+auth.Auth.SetSecrets; data at rest under a wrapped data key, never directly under the secret). Non-secret settings stay plain env vars inconfig.Load. - Live secret. Add the name to
liveSecretsin the app'sinternal/config/secrets.go(a keyring addssecrets.KeyringNames(name)), its checks tovalidateLive(they run on the env at boot and on every fetch before it is published; useLen/IsZero/Equal, neverReveal), and a typed accessor next toOnDatabaseURL/OnAuthSecrets. - Consumer. In
cmd/server, register the hook: build the new client and swap it in atomically (seeauth.SwapMailer); on a zero value fall back or keep the old client; return an error to keep the previous client (the manager retries it after each later fetch). A hook must not call back into the manager (OnChange,Refresh,Stop). Logv.Fingerprint(), never the value. Never cache the value elsewhere. - Tests and docs. Cover the swap at the consumer's boundary (like
TestSwapMailerUsesTheLatestMailer), add the name to.env.exampleand docs/secrets.md, and add it to every Doppler config.
Only when the developer asks: it creates billed resources in their Railway account.
- Preflight.
bin/railway-setupis built by anymake railway-*target. Runmake railway-doctor ARGS=--jsonand fix every check with"status": "fail"; each check carries afix. Typical fixes:railway login(the developer's browser),--workspacewhen they belong to several, andrailway ssh keys addwhen there is more than one environment. - Setup.
make railway-setup ARGS="--yes --json --environments staging,production --email-from noreply@<their domain>". A real sender is required: plan, setup and apply refuse an unset or exampleemail.from(production refuses to boot with one), andnextStepssays so. Ask the developer for the sender on their verified email domain; never invent one. Add--admin true,--region,--projector--workspaceas asked. Every flag has aPLATFORM_*env equivalent (PLATFORM_YES=1,PLATFORM_JSON=1,PLATFORM_ENVIRONMENTS, ...). The flags are written intodeploy/platform.yaml: commit that file. Without--yes, and with no terminal to ask on, creating a project stops with exit 3. - Read the summary. stdout is one JSON object:
ok,project.{id,url},source(githuborupload), each environment'sbranch(only withgithub, which this template leaves off: Environments) and services withurl,deploymentandhealth, andnextSteps. Logs go to stderr. RelaynextStepsto the developer verbatim. They are things only a person can do, such as installing the GitHub app, Doppler tokens or a realRESEND_API_KEY. Never invent or fill in secrets yourself. - Doppler (
secrets.provider: doppler): when the developer has rundoppler login, setup also moves the app secrets into Doppler (docs/secrets.md); otherwisenextStepssays so, andmake doppler-setup ARGS="--json"does it later. Its output names configs, secret names and fingerprints only. Keep it that way: never print, log or paste a token or secret value. - Hand-set values. Never set a Railway variable by hand without listing it in
unmanaged_variablesor asmanualinper_environment(docs/deploy.md): the next apply deletes it.make deploy-varsmust pass. - Workflows deploy, not setup. Once the project exists, merges ship through
Deploy stagingand releases throughDeploy productionwith environment-scoped project tokens (docs/RELEASES.md);make railway-setupchanges infrastructure and variables only. - Re-run anytime.
make railway-setupis idempotent: no changes, no redeploys when healthy. Removing an environment oradminnever deletes anything withoutCONFIRM_DESTRUCTIVE=1; ask before passing it.
Exit codes:
| Code | Meaning |
|---|---|
| 0 | ok |
| 1 | error: read error |
| 2 | plan: changes pending |
| 3 | needs --yes |
| 4 | doctor: a required check failed |
| 5 | applied, but a public service is unhealthy |
make itself turns any failure into exit 2, so read exitCode from the JSON, or run bin/railway-setup <command> -config deploy/platform.yaml -iac deploy/.railway/railway.go directly.
- The backup job (
apps/backup+packages/backup) is deletable like the admin app: it imports no app, no app imports it, and it reaches Postgres only throughpg_dump/pg_restore(depguard, probed). It runs in its ownpostgres:18-alpineimage so the tools match the server. - A new database (a third service with its own Postgres) gets backed up by passing its URL to the backup service and adding it to the job's database list; keep
<db>/<time>.dump.ageids and the restore guards (--yes,--forceover a non-empty database, an explicit target). - Never weaken the restore guards or log a value: logs carry ids, sizes and fingerprints. The encryption key is a keyring (
BACKUP_ENCRYPTION_KEY,_PREVIOUS), validated withLenand revealed only inapps/backup/cmd. - Retention is the job's (Railway buckets have no lifecycle rules). Before changing
Retention.Expired, prove the change with a local prune run: plant old backup ids in a local store and check whatbackup prunekeeps.
- Staging always runs the latest
main.mainruns no CI: every merge of a headmake ci-localattested deploys to staging automatically (Deploy staging,.github/workflows/deploy-staging.yml), and the hourly "Staging catch-up" deploysmainwhenever staging is behind (docs/RELEASES.md). - Production may lag staging, never lead it. Production only ever runs a commit that staging has already run and passed its readiness check on. No hotfixes straight to production, no production deploys of branches, and never a production commit that staging has not run.
- A failed staging deploy, or an unattested
mainthat staging refuses, is the top priority. After a merge, check its deploy once withmake pr-merge ARGS="--check-deploy <squash sha>"(one read, not a polling loop); "Post-merge report" also comments on the merged PR when Deploy staging fails onmain. If it failed, fix it, or attest and dispatchmain(docs/CI.md), before merging anything else. Never leave staging behindmain. - Production ships only as a GitHub release (docs/RELEASES.md): publishing a
vX.Y.Zrelease starts "Deploy production", whose gate proves staging ran that commit, still runs it or a latermaincommit and is healthy, and that the publisher is inPRODUCTION_RELEASERS. An agent publishes or dispatches one only when the developer explicitly says so. - Railway never deploys on its own:
deploy/platform.yamlkeepsrepo: ""(upload mode), so Railway's GitHub auto-deploy stays off and only the workflows (ormake railway-upby hand) ship code.
main is the only long-lived branch. Every PR targets it; staging runs it and production runs releases tagged on it. The deployed branch is named once, in scripts/branches.sh (and landingBranch in scripts/cilocal):
make ci-localattests a branch head or (withCOMMIT=) a commit on origin/main; a new project's first attestation runsmake ci(docs/CI.md).Deploy stagingruns on pushes tomain.make check-fast,make affectedand the bench A/B compare with origin/main.- The migration order check needs a new migration above the highest on origin/main.
- Never open a PR unless the developer asks.
- Conventional commit titles in plain language:
fix(api): PATCH /users no longer drops the image. - Body: the problem in a sentence or two, then the fix. UI changes need before/after images; motion needs a short video.
- One concern per PR. If the description says "also", split it.
- How to verify, attest, open, stack and merge, step by step: the github-prs skill.
- Open it from .github/pull_request_template.md, ready (not draft), then run what it needs and
make ci-local RAN="<what you ran>"on its pushed head; it fills "Local verification". A new push or rebase needs a new attestation. - Merge with
make pr-merge PR="<number>..."only: it squash-merges each PR whoselocal-ciand section hold for its exact head and whose own checks are green (or, where Actions cannot run, onlocal-cialone), reports the rest without waiting, and retargets PRs stacked on it. After each merge, once its deploy has run,make pr-merge ARGS="--check-deploy <squash sha>"(it prints the command): a follow-up, never a gate. The ruleset requiresSecret scanandLocal verification(docs/CI.md). When babysitting, verify each finding against the source, fix real ones, and dismiss false positives with a reason.
apps/web/cmd/server: the binary (server serve,server migrate).apps/web/internal/config: env config,AppRoutes/APIRoutes,Features.apps/web/internal/server: middleware (apiOnlyhides the API and/healthzfrom browsers),/healthz, the mounts (auth at/api/auth/, API, UI at/), and the adapters frompackages/authto service ports (AccountCredentials) and to the access gate (Callers, theaccess.Resolver).apps/web/internal/access: route access:Caller,Policy(Public,SignedIn, yours),Gate. Both transports register routes with a policy; the gate decides.apps/web/internal/apiclient: the API as the UI calls it:Client, and aliases of the types, inputs, errors and access policies it uses. A contract only (nonet/http).apps/web/internal/api: JSON API/api/core/v1: envelope,RouteandEndpoint(served over HTTP, called in-process byapi.Client), handlers, the service ports they call;api/mocks(generated) andapi/apitest(mock session) for handler tests.apps/web/internal/services: business rules and the ports they need;services/mocks(generated) for service tests.apps/web/internal/validate: input types.apps/web/ui: templ pages, Datastar handlers (server actions that callapiclient.Client), i18n, static assets, and the browser security headers (security.go).packages/uikit: the templ components and design tokens both UIs share (theme.cssis the one file to re-theme). It renders translated strings and values only; depguard keeps it from importingapps/or the data, auth and secrets packages.packages/storage: the optional S3 client,safefetch(SSRF-safe fetch of user URLs, rules injected fromservices.PublicAddr/FetchURLAllowed) andimaging(pure-Go decode/normalise to WebP; build with-tags nodynamic). Services reach it only through ports (ObjectStore,ImageFetcher,ImageCodec; depguard). docs/storage.md.apps/backup,packages/backup: the nightly encrypted Postgres backup and restore (docs/backups.md).packages/database: pool (Pool: swaps itself whenDATABASE_URLrotates),migrations/(goose, embedded),queries/(sqlc input),sqlc/(generated, never edit), repositories,model/.packages/secrets: live secrets:Manager(polling, validation, last-good cache, staleness, change hooks) and providers (Env,Doppler; Infisical/1Password stubs behind build tags),Secret, and the redacting log handler. Self-contained; never reads the env.packages/auth: Better Auth–style auth: its own migrations, queries, endpoints (Better Auth paths and response shapes, not the envelope), sessions, mailers. Self-contained: it never importsapps/orpackages/database(depguard).apps/mobile-app: the Expo app, which talks to the JSON API with bearer tokens.apps/admin: the optional admin dashboard (see "Admin app");packages/database/adminrepo: its data layer (audit log, stats; migrations, queries and sqlc output under it).deploy/: the Railway setup, its own Go module (the Railway IaC SDK never enters the app'sgo.mod).platform.yamlis what a team edits;platform/turns it into the IaC graph (pure, tested);.railway/railway.gois the shim the Railway CLI evaluates;cmd/railway-setupdoes what IaC cannot (project, environments, domains, sealed secrets, previews). Run it throughmake railway-plan/make railway-apply, never by hand-editing Railway. Railway bugs we work around are indocs/railway-go-iac-issues.md.- Root:
Makefile,docker-compose.yml,Dockerfile,.golangci.yml,tools/(pinned dev tools, run viago tool -modfile=tools/go.mod).
- Complexity belongs at the layer boundary. Repositories hide SQL, services hide business rules, the API's endpoints hide access, validation and envelopes for both ways in, and templates stay dumb.
- Small interfaces, defined by the consumer (
services.UserRepository,api.UserService). Concrete types out, interfaces in. - Typed values over
anyandmap[string]any. Generics only where they remove real repetition. - Errors are values: wrap with context, compare with
errors.Is/errors.As, and never log and return the same error. - Comments say how a thing is used and move with the code. Document functions, not every line.
- No magic numbers: a literal that carries meaning is a named const with a comment (
const maxRequestBody = 1 << 20 // 1 MiB cap on JSON bodies); 0 and 1 as plain counters and indexes are fine.mndin golangci-lint enforces it outside tests, andmake lint-layeringproves it bites. - No single-character names, anywhere: receivers, parameters, results, locals, loop and closure variables, type parameters, fields, in tests too. Name a thing for what it holds (
func (service *Service),writer http.ResponseWriter,request *http.Request,test *testing.T,for index := range); two letters are fine where they are the word (ctx,id,ok,tx). It reads better and makes code easy to search, for people and agents.scripts/namelintenforces it inmake lint,make check-fastand CI (golangci-lint has no linter that catches every case), skipping generated code but not.templsources, andmake lint-layeringproves it bites. - If a rule here fights the task in front of you, say so loudly and get a human sign-off before breaking it.