Contracts
The contract layer is the part of the stack that makes everything else replaceable. Protos are the only hand-written source for types that cross a process boundary; every language target is generated from them with Buf. This layer is the stack’s core: the other documents describe validated implementations built around it, and every one of them can be swapped in a way the contract cannot.
Layout
Hand-written protos live at the repo root under proto/<slug>/, outside any
package, because more than one package consumes them:
proto/<slug>/
common/v1/ shared value types (tags, windows) used by several resources
resources/v1/ the domain nouns: one file per resource, messages only
service/v1/ request/response envelopes + service.proto
resources/v1holds data shapes with no RPCs. These are the types the database schema mirrors and the interface renders.service/v1holds one file of envelopes per resource, plusservice.proto, which imports all of them and declares the service.common/v1stays small on purpose. A type moves here on its second consumer, never speculatively.
The service rule
Each backend owns exactly one service block, named <AppName>Service, in
its own proto package. RPCs are grouped with comment headers inside that one
service rather than split across services, because both backends key their
exhaustiveness checks off a single service type: TypeScript types the
implementation map as ServiceImpl<typeof AppService>, and connect-go
generates one handler constructor per service.
When a second backend joins the repo, it gets a second service package (for
example proto/<slug>/ingest/v1 with IngestService) and shares
resources/v1 and common/v1. The unit of backend choice is the proto
service. See Choosing a runtime.
Message conventions
- Every RPC has a dedicated
<Verb><Resource>Requestand<Verb><Resource>Response, including empty ones (message HealthRequest {}), so fields can be added later without a breaking change.google.protobuf.Emptyis never used. - Create and update requests carry the whole resource:
CreateNoteRequest { resources.v1.Note note = 1; }. Clients read, modify, and write. Field masks arrive only when a real conflict problem demands them. - Get requests take an id. List requests carry the paging trio and then
a bag of
optionalfilter scalars; list responses hold onerepeatedfield andnext_page_token. - The standard resource tail is
created_by,created_at,optional updated_at, and whatever the resource’s answer to the next point needs (optional archived_atfor an archive). - How a resource leaves the default view is the app’s decision, not the
stack’s. Ask it when the resource arrives: what should happen to the
resource, and to the rows that point at it? Some common answers, as
illustrations only: a hard
Delete<Resource>RPC that removes the row (leaf data nothing references); a soft delete, adeleted_atthe reads filter on, with no way back; archive as a first-class state through a dedicated verb RPC (Archive<Resource>(id)setsarchived_at,Listtakesinclude_archived, andUnarchive<Resource>mirrors it when a screen needs it). The list is not exhaustive and an app may do otherwise. What the stack asks is that the answer be its own RPC with its rule in its comment, never a flag onUpdate. The Note example gives one answer,ArchiveNote. The stack takes no position because the right answer depends on what points at the row and on what the people using the app expect to get back; a default would be wrong for half of them. - Timestamps are
google.protobuf.Timestamp. Seconds-since-epoch integers and floats are forbidden; they are the single worst fossil observed in ported systems. optionalon a scalar means presence matters. The adapter normalizes the empty-string/NULL boundary in one place. Plainstringmaps to aNOT NULLcolumn;optional stringmaps to a nullable one.- Enums start at
<NAME>_UNSPECIFIED = 0, values carry the enum-name prefix, and database enums are generated from the proto enum names so the two can never drift. - Cross-resource links get dedicated link messages (
BookAuthorLink) instead of embedded resources. - Comments on fields are load-bearing documentation. Rules like “populated on read, ignored on write” live next to the field they govern.
- Keys are surrogate ids. A REST URL that addressed a resource by a unique name does not make the name the contract’s key; the id is rename-stable, and the name rides along as a read-only display field.
- A derived read-only field’s home is decided by whom it describes. A flag
about the caller (
viewer_may_edit) goes on the response envelope; a flag about a member of the resource (has_accesson a linked person) stays on the resource as output-only. - List requests page by cursor from the first version:
page_size(validated, 0 for the default), an opaquepage_token, andnext_page_tokenon the answer, empty on the last page; no offset and no total. The token is the keyset of the last row (created_at, id), so an insert between two pages cannot shift the rest. For time-ordered data add a window. An empty list request that reads a whole table is cheap to fix before a client exists and expensive after. - Calendar dates are
YYYY-MM-DDstrings and today is the client’s to supply; the server has no time zone to resolve a day in.
Validation in the contract
protovalidate constraints (buf.validate field options) are the input
validation, and the contract is where they live. Both paths enforce them
in an interceptor (connectrpc.com/validate in Go, @bufbuild/protovalidate
behind a Connect interceptor in TypeScript), so handlers and stores check
nothing the contract states and the two backends refuse the same requests
with the same code. On the Go path that interceptor is innermost and logging sits outside
auth, so a request refused for either reason is still one log line with
its request id; a refusal that leaves no trace is the harder kind to
debug.
Constraints distribute by message role: always-valid invariants (lengths,
enum membership) on the resource, presence on the request envelopes (ids
uuid, page_size bounded), and a message-typed request field the handler
dereferences marked required, because that is the only nil guard once
handlers stop checking.
Audit is opt-in per service: the contract carries no marker for it, and an audit interceptor keeps its own list of the RPCs it records.
Field number policy
A removed field’s number and name are reserved, from the first commit.
Reusing numbers while a project is unshipped is tempting; the moment it
ships is easy to miss and the cost of missing it is corrupted
deserialization on clients already on the network, so there is one rule. Add buf breaking --against the
default branch to CI once clients you do not control exist.
Codegen
buf.yaml declares two modules: proto/, and third_party/protovalidate,
which vendors buf/validate/validate.proto as a lint-ignored module so
the constraints resolve with no registry dependency, in CI and in the Go
image build alike. Each language target gets a plugin block, and the
validated shape is one template per toolchain:
# buf.gen.yaml at the repo root: the TypeScript target, run by pnpm
version: v2
plugins:
- local: node_modules/.bin/protoc-gen-es
out: packages/protocol-buffers/src
include_imports: true # emit the modules the contract imports (protovalidate)
opt: [target=ts]
# go/buf.gen.yaml: the Go targets, run from go/ as `go tool buf generate ../proto --template buf.gen.yaml`
version: v2
plugins:
- local: ["go", "tool", "protoc-gen-go"]
out: gen
opt: paths=source_relative
- local: ["go", "tool", "protoc-gen-connect-go"]
out: gen
opt: paths=source_relative
A third target, protoc-gen-connect-openapi as another local plugin in
the Go template, produces an OpenAPI document for readers who will never
open a proto; the scaffolded docs site answers the same need by rendering
the descriptor set. Either is optional.
The split is what lets the Go image build with no JavaScript toolchain and
the TypeScript package generate with no Go on a pure-frontend machine.
Every plugin is a local, pinned binary (go tool in go.mod, pnpm for
protoc-gen-es), so generation makes no network call; remote: plugins on
the Buf Schema Registry work too and cost egress on every build.
Every proto file carries an explicit go_package option of the form
github.com/<org>/<repo>/go/gen/<slug>/service/v1;servicev1, and
paths=source_relative makes the Go output mirror the proto directory
under go/gen/.
Two lint facts to know before the first buf lint: the standard rules
require every RPC’s request and response to be unique types, so a shared
empty message is refused (each empty response is its own named message,
which is what the conventions above want anyway); and buf refuses to
generate an empty module, so a fresh contract starts with one RPC.
Two rules that keep codegen honest:
- Generated code is never committed. The TypeScript package gitignores
its
src/, the Go module gitignoresgen/.pnpm proto:gen(which runsbuf generate --clean) is wired into the rootpostinstall, and CI runs the identical command. There is no “did you regenerate?” review comment in this stack. buf lintgates every proto change. The standard category, no exceptions accumulated.
Consuming generated types
- TypeScript consumers deep-import through the package export map:
@<org>/protocol-buffers/<slug>/service/v1/service_pb. - Go consumers import
<module>/gen/<slug>/resources/v1and the generated<module>/gen/<slug>/service/v1/servicev1connecthandler package. - TypeScript consumers never re-export the generated tree through a barrel file: Metro does not tree-shake, and a barrel over the contract drags every message into every bundle. Deep imports only.
- The interface uses the same generated schema objects for
create(<Message>Schema, {...})construction that the backend uses for responses. One contract, one construction idiom, every language.