Adds a command, query, consumer, provider or module to the Go backend end to end, from the generator to the flow file. Use when a backend ticket adds or changes behavior under apps/backend/internal/modules.
Install
npx skillscat add munyon-canyon/monaco/go-backend-module Install via the SkillsCat registry.
Go backend module
Every job has one paved path, and a generator writes its first copy. Start from the generator, then shape the output to look like flow 00 (the system module). Run every command from apps/backend unless a step says otherwise.
The worked example: flow 00
| Piece | Path |
|---|---|
| Flow file | packages/flows/backend/00.tsv |
| Module wiring | apps/backend/internal/modules/system/module.go |
| Domain type with a parse constructor | apps/backend/internal/modules/system/domain/note.go |
| Command | apps/backend/internal/modules/system/app/record_ping.go |
| Query | apps/backend/internal/modules/system/app/get_ping_query.go |
| HTTP adapter | apps/backend/internal/modules/system/adapters/http.go |
| Consumer | apps/backend/internal/modules/system/adapters/echo.go |
| SQL | apps/backend/queries/system/pings.sql, generated into apps/backend/internal/modules/system/sqlc/ |
| Migration | apps/backend/migrations/20260929120000_system.sql |
| Event | apps/backend/internal/events/system.go, registered in apps/backend/internal/events/system_registrations.go |
| Contract | apps/backend/api/spec/system.yaml, bundled into apps/backend/api/openapi.yaml and generated into the package apps/backend/internal/platform/httpx/api/systemapi |
| Flow scripts | apps/backend/internal/testkit/flows/f00.go, registered by its generated apps/backend/internal/testkit/flows/f00.gen.go. Each f<id>.go gets a generated f<id>.gen.go |
| Flow tests | apps/backend/internal/modules/system/flow00_test.go, apps/backend/internal/modules/system/flow00_crash_test.go |
Generators
just gen <kind> <args> runs go run ./cmd/monacoctl gen <kind> <args> in apps/backend. The generator table is apps/backend/internal/tools/gen/gen.go and the templates are in apps/backend/internal/tools/gen/templates/. A generator never overwrites a file. just gen help prints the list.
| Command | Writes |
|---|---|
just gen module <name> |
module.go, main_test.go, the domain, app and adapters packages, queries/<name>/, an api/spec/<name>.yaml stub with paths: {}, a CHANGELOG line, the first migration migrations/<timestamp>_<name>_init.sql with the rewritten atlas.sum, and the regenerated sqlc.yaml, .golangci.yml and cmd/*/module_<name>.gen.go |
just gen command <module> <Name> |
app/<name>.go and <name>_test.go |
just gen query <module> <Name> |
queries/<module>/<name>.sql and app/<name>_query.go, then runs sqlc |
just gen migration <module> <name> |
migrations/<timestamp>_<module>_<name>.sql, empty, and the rewritten atlas.sum. just gen migration --rebase moves the branch's own migrations above the newest on the branch's Graphite parent |
just gen consumer <module> <name> |
adapters/<name>.go and <name>_test.go, and appends the consumer to Consumers() in module.go |
just gen provider <name> |
a client, its fake and fixtures under internal/providers/<name>/ and internal/testkit/fakes/testdata/fakes/<name>/ |
just gen flow <id> |
flow<id>_test.go with one failing test per outcome of the row |
Generated stubs are placeholders. The command and consumer templates append or take events.SystemPinged, and every generated test ends in t.Fatal("not implemented"). Replace both before you push.
Add a command
- Run
just gen command <module> <Name>. - Give the command struct the fields the caller supplies. The template adds an
IdempotencyKeyfield. Drop it when the only caller is HTTP: theIdempotency-Keyheader is deduped by the middleware inapps/backend/internal/platform/httpx/idempotency.go, which is whyRecordPinghas no such field. Parse raw input into domain types at the adapter, asdomain.ParseNotedoes. The app layer takes typed values. - Do the write inside
h.uow.Do. Use the module's sqlc queries throughsqlc.New(tx.Queries()). Append the event withtx.Events.Appendin the same function, so the row and the event commit together. The event's actor comes from the context, and a poller tick runs assystem:poller.<name>, so a poller never sets its own actor. - Add the event if the command needs a new one. Follow "Add an event" in the
nats-consumerskill; it touches five files besides the event type. - Add the SQL. Put the statement in
queries/<module>/*.sqland regenerate with../../.bin/sqlc generate(installed byscripts/install-sqlc.sh). A state change is a guarded update:UPDATE ... WHERE id = $1 AND <expected state>with:execrows, and zero rows means the state moved. - A schema change is a new atlas migration. Run
just gen migration <module> <name>: it picks the timestamp prefix (now, or one second past the newest prefix when the clock is behind it) and rerunsatlas migrate hash. Never choose a prefix by hand and never edit an applied migration. After a restack conflict inmigrations/, runjust gen migration --rebase, which moves the branch's own migrations above the newest on the branch's Graphite parent. - Expose it. Add the route and the schemas only it uses to
apps/backend/api/spec/<module>.yaml, never to the generatedopenapi.yaml, and rungo generate ./...inapps/backend. It bundles the spec files and regenerates the module's package,apps/backend/internal/platform/httpx/api/<module>api. A schema that more than one module uses goes inapi/spec/base.yaml, and its Go type lands in the base packageinternal/platform/httpx/api. Implement the operation in the module's HTTP adapter against<module>api.StrictServerInterface, asapps/backend/internal/modules/system/adapters/http.godoes. The compiler then names every operation the adapter still lacks. Errors areerrscodes, never ad hoc HTTP statuses. A new code goes in a new file,apps/backend/internal/errs/codes_<module>_<feature>.go, holding the constants and exactly onefunc (codeFiles) <ModuleFeature>() map[Code]Rownamed for the file (codes_governance_drafts.godeclaresGovernanceDrafts), plus a sortedapps/backend/internal/errs/testdata/codes_<module>_<feature>.goldenlisting its wire values. The table finds the method by reflection, so no other file changes. Do not add rows toTestAccessorsReadTheRow. Thengo generate ./...rewrites theErrorCodeenum inopenapi.yamland the Swift case list. Never editapi/spec/base.yamlfor a new code. - Wire the handler in
module.go:Mountpasses the adapter to<module>api.Mount, as system'sMountdoes forRecordPing.
Add a query
Reads bypass the domain. just gen query <module> <Name> writes the SQL and a function that takes a sqlc.DBTX. The HTTP adapter calls it with the pool, as GetSystemPing does with Reads. A query never appends an event and never runs inside uow.Do.
Add a consumer
Run just gen consumer <module> <name>, then follow the nats-consumer skill.
Add a provider
Run just gen provider <name>. Wire types stay in the provider package. The fake and its JSON fixtures are what tests and monacoctl verify talk to. Tests never call the real service.
Test layers
| Layer | Where | Runs against |
|---|---|---|
| Domain and app unit tests | <module>/*_test.go |
testkit.DB on the postgres-test container |
| Consumer tests and the chaos suite | echo_test.go, echo_chaos_test.go |
embedded NATS through testkit.Main(m, testkit.WithNATS()) |
| Flow acceptance tests | flow<id>_test.go, calling a script in internal/testkit/flows |
api and worker in one process through scenario.New |
| Crash tests | flow<id>_crash_test.go, //go:build faultpoints |
the same scenario with a fault point armed |
| End to end | monacoctl verify in stage 1 |
real binaries, see the verify-backend skill |
Every TestMain lives in main_test.go and is exactly testkit.Main(m, ...). It runs goleak.
The flow file
A flow is one file, packages/flows/backend/<id>.tsv: the tab-separated header line and one row whose id equals the file name stem. List cells use ;. monacoctl flows check reads every file and fails one with a second row, a wrong header or an id that differs from its name. The command cell may list several commands. Then each outcome needs a test from at least one of them, each command needs at least one test, and only the pairs a command can return get a test. The trigger cell then holds one trigger shared by every command, or one per command in the same order, and monacoctl verify checks each command's outcomes against its own trigger.
| Status | Needs |
|---|---|
planned |
Valid shape. The module directory, trigger, events, consumers, outcome codes, crash points and doc anchor all resolve. |
built |
A command, a passing test per outcome named TestFlow<id>_<Command>_<Outcome> (OK for ok, Crash<Point> for crash:<point>), and a script for every non-crash outcome in internal/testkit/flows/f<id>.go, registered by running go generate ./.... |
verified |
A script for every remaining outcome, including each crash outcome not already registered. Run go generate ./... to register them. |
- Add
packages/flows/backend/<id>.tsvwith the row asplannedwhen the ticket starts. - Run
just gen flow <id>. Write the non-crash scripts ininternal/testkit/flows/f<id>.go, rungo generate ./...to write itsf<id>.gen.go, and make each test call its script. A poller flow's worker env is aWorkerEnvF<id>method in itsf<id>.go, and an app flow's seeders are aSeeds<Stem>method there. Then setbuilt. Scripts for the non-crash outcomes ship with thebuiltrow. - The
e2ejob runs a crash pass for every point that abuiltorverifiedflow has a script for, so a new crash script needs no CI edit. Register the rest and setverified. - Regenerate the docs and the flow's feature map page with
go generate ./...fromapps/backend.scripts/ci/ready.shfails when either is stale.
A test named TestFlow<id>_... with no row fails the check. Deleting a flow deletes its file and its tests together.
Before you push
Lines stay under 120 characters. golines in the pre-commit hook rejects longer ones.
go run ./cmd/monacoctl lint commentspasses. Go has no comments except//go:directives.go run ./cmd/monacoctl flows check --structure-onlypasses.go run ./cmd/monacoctl agents checkpasses. It builds, vets and runs the short tests of the packages affected since the base. A change to a shared package such asinternal/eventsor a module's sqlc output affects most of the module, so the test row runs nearly everything. Each package in that row has a 120 s budget. The row as a whole has none. A package over 60 s fails CI and is slow code: fix it. Never raise the budget.