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 DeleteNoteResponse {}), 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 are a bag of
optionalfilter scalars; list responses hold onerepeatedfield. - The standard resource tail is
created_by,created_at,optional updated_at,optional archived_at. Archival is the norm; hard delete is an explicit, per-resource decision. - 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.
Field number policy
While a project is pre-launch, removed field numbers are reused and
reserved is unnecessary; the wire never leaves the building. The moment a
project has shipped clients you cannot atomically upgrade, removed numbers
become reserved N; permanently. Write the switch date down when it happens.
Codegen
buf.yaml declares the module and its deps (googleapis for well-known
types). buf.gen.yaml declares one plugin block per language target:
version: v2
plugins:
# TypeScript: types + service shapes for backend and interface
- local: node_modules/.bin/protoc-gen-es
out: packages/protocol-buffers/src
opt: [target=ts]
# Go: messages + connect-go service scaffolding (when a Go service exists)
- remote: buf.build/protocolbuffers/go
out: go/gen
opt: [paths=source_relative]
- remote: buf.build/connectrpc/go
out: go/gen
opt: [paths=source_relative]
Every proto file carries an explicit go_package option pointing into
go/gen/proto/..., so Go output lands inside the Go module.
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
go/gen/proto/<slug>/resources/v1and the generated<pkg>connecthandler package. - 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.