proto/services/ say what crosses the wire: which services exist, which RPCs they offer and what the messages look like. The migrations under db/migrations/ say what is stored: tables, columns, constraints. Nearly everything else in the repository is a projection of one or both, and forge generate rewrites those projections whenever an input changes.
This page explains the split, shows what comes out of it, and covers the one rule that decides whether an RPC gets generated CRUD behavior. Project anatomy has the directory layout, and Adding a feature has the day-to-day loop.
Why there are two
Putting the schema in the proto, or the API in the schema, looks tidier on paper, and forge used to do the first. Schema was declared with annotations on proto messages, and migrations were derived from them. The two have different lifetimes in practice. A column is added the day a feature starts and may never be shown to a client. A wire field is renamed for a new client while the column keeps its name for years. Tying them together meant every change to one forced a decision about the other, so forge removed the entity annotations and made SQL the only place a table is declared. The result is that the two sides evolve on independent clocks, and the generated conversion code maps the intersection of wire fields and columns by name. A column with no matching wire field never leaks onto the wire. A wire field with no matching column never reaches the database. You can add either side first. For an agent this removes a class of mistakes. There is no second schema language to keep in sync, so there is nothing to forget to update. Proto errors are caught bybuf, schema errors by Postgres itself, and anything left over by forge generate, which fails loudly instead of guessing.
Read the diagram from the two inputs on the left. Everything on the right is generated, with the one exception of the rpc_<name>.go stubs, which forge writes once and you then own. The only place the two inputs meet is the match-by-name step, which produces the per-entity CRUD wiring.
What forge generate projects
forge generate applies your migrations to a real, disposable Postgres, reads the schema back, and combines it with the proto descriptor. Because the schema comes from Postgres itself, anything Postgres accepts works: JSONB, arrays, generated columns, CHECK constraints, triggers.
Every generated file says which input it came from in its header. These are the Source: lines from a project with one service and one table, lightly trimmed:
The proto side of an entity is ordinary protobuf. This is the whole
customers service from the scratch project used for this page:
forge db migration new create_customers created with a UTC version so parallel branches never collide:
forge generate, the row type is a projection of that table (the id line is trimmed here):
created_at and updated_at are columns with no wire field, so they stay off the message:
forge generate, and the struct follows. If clients should see the new column, add the field to the proto message too. Migrations are forward-only and carry a forge:backward-compatible or forge:breaks-rollback marker, which forge lint --migration-safety checks. See Adding a feature.
forge scaffold can write both halves for you. Mark a proto message with // forge:entity and run bare forge scaffold: it injects the five CRUD RPCs and writes the create-table migration. That migration is yours from birth. Forge never reads the proto again to regenerate or edit it, so evolving the table is always a new migration.What makes an RPC CRUD
An RPC is wired as CRUD only when all of these hold:- it is unary;
- its name is exactly
Create<E>,Get<E>,Update<E>,Delete<E>orList<Es>; <E>is an entity: there is a table for it in the applied schema (the snake_case plural of the name) and a proto message with that name.
The rule is exact on purpose. Name RPCs for the domain operation, and never rename one to dodge or trigger detection: renaming
UpdateJobStatus to ChangeJobStatus buys nothing. The one real collision is an RPC whose remainder is another entity’s name, such as UpdateJobNote when JobNote is an entity: that is JobNote’s Update.
The two kinds get different treatment. In the scratch project, the three CRUD RPCs got delegating methods in the owned handlers_crud.go, with the per-entity wiring in the regenerated handlers_crud_ops_gen.go. The custom ArchiveCustomer got a stub in its own file, rpc_archive_customer.go, plus a test row that goes red when you implement it:
// forge:query-scope <Entity> above the custom read RPC.
When a CRUD-named RPC isn’t CRUD
Sometimes the name fits but the behavior doesn’t:CreateTask that also wires up dependencies, for example. Put // forge:custom on its own line above the RPC:
Customizing a CRUD RPC
You do not edit or disownhandlers_crud_ops_gen.go to change what a CRUD RPC does. The method in handlers_crud.go is yours: replace its delegation with real code, or take the generated op, change the one field you need, and keep delegating so the shared lifecycle in forge/pkg/crud keeps handling errors, pagination and update masks. Row scoping and permission checks go here too, since the delegation reads no caller. The db/crud-overrides skill (forge skill load db/crud-overrides) has the worked recipe.
Which side holds which rule
A few constraints belong on one side only, and putting them on the wrong one is the usual source of drift:forge project annotations prints the whole vocabulary, and the db/write-policy skill is the decision table for the write-policy markers.