One handle, one source of truth - the HTTP spec is derived, not written.
👋 Hi, I'm Anton - a software engineer working mostly in PHP/Symfony and Go, currently carving a live PHP monolith into Go services. This series is about the road from a requirement to a contract: what has to be true before anybody writes code. This part is the short one, with a single claim in it. Running notes live on my GitHub: github.com/brilliant-almazov.
This is how I do it right now, with the price attached - maybe you already do it better, maybe you see it differently.
Primer, in one paragraph
Every service-to-service call in this system is declared in a schema file - a .proto - kept in one shared contract repository. The build turns that file into typed clients and servers for both sides of the call. The same file also carries the HTTP role of a handle: the door service exposes it over HTTP, and the HTTP specification is produced from the schema rather than maintained beside it. One file per interface; everything else in the picture is output.
The claim
The contract is declared in the schema, and nowhere else. Messages, calls, the meaning of each field, which fields are required, the bounds a value may take, the codes a refusal can carry - all of it lives in the .proto. Nothing about the interface is described in a second place.
That reads like a style preference until you name the alternative out loud: two documents that mean the same thing, kept in step by a person remembering to do it.
The case: a declaration that is checked by the build
I did not learn this on handles. I learned it on the two most boring artifacts a service has - its environment variables and its metrics - where the same principle is already running.
What it was. Both were described by hand, in files next to the code, and both drifted from the code quietly. Nothing failed. The description was simply wrong, and it stayed wrong for as long as nobody happened to read it against the source.
What was done. Both descriptions are now taken off the code by machine and kept as repository artifacts. For a variable declared by the service configuration the catalog records defined_in - the file and the line of the declaration; for one declared by the platform, the owning catalog. The metrics snapshot has one row per metric plus a <dynamic> row for the factory that registers metrics at runtime.
| Artifact | Entries | Where they come from | An entry records |
|---|---|---|---|
| Environment catalog | 59 variables | 45 platform · 14 service config | where it is declared |
| Metrics snapshot | 67 entries | 59 platform · 6 service · <dynamic>
|
how it is registered |
What holds it. Drift is a build failure. The check runs the same generator in --check mode whenever any .go file, the manifest, the modules file or the snapshot itself changes, and the generator is installed at the same platform version the service has in its modules. Not a review convention, not a linter warning - a red build.
Where it stops. Resource variables are not in the catalog: the platform builds their names by concatenation at runtime, so there is nothing static to read. That is a stated hole rather than a silent one, and it is the honest part of the claim.
The conclusion is one line, and it is the whole article:
A description written by hand drifts. A description that is derived and checked does not.
The schema does exactly that, for a call and for an HTTP handle.
How this is usually done
The common arrangement is three files describing one interface. The schema declares the call. A separate document describes the HTTP surface - an OpenAPI file, hand-written or half-generated. Human-facing documentation is the third. All three are true on the day they are written, and after that, keeping them equal is manual work backed by an agreement.
The agreement is the failure point. Nothing in the pipeline notices when the schema gains a field the HTTP document has never heard of.
What we do instead: one handle, one source
The HTTP handle is not a second interface. It is the same contract with its role marked: the method carries an HTTP annotation in its declaration, and the HTTP specification is produced from the schema.
.proto (one file)
messages · calls · required fields · bounds · refusal codes
│
┌───────────┴───────────┐
▼ ▼
gRPC call HTTP handle
typed client role marked by an annotation
typed server spec derived from this same file
✗ hand-written HTTP spec, kept in step by a person
Stated as a prohibition, which is how I actually apply it: there are no two sources of truth for one handle. If a fact about the interface is written in a second place, that place is either generated or wrong.
Compatibility is not my judgement either. The contract build tool checks every change against the published schema, so a breaking change is visible before a line of implementation exists. That is what makes the gate at the end of the previous part enforceable: a requirement moves on to specification only with an accepted contract, and accepted means the compatibility check is green.
Contract analytics is its own step
Because the schema is the only place, arguing about it early is cheap and arguing about it late is not. So the field-by-field discussion - names, enums, what a zero value means, what a refusal returns - happens on the schema, before generation, where an edit costs a line.
The part I insist on is the order. The set of cases the contract has to be able to express is written before a single field is named:
success the ordinary answer, and what it contains
refusal which codes exist, and what the caller does with each
conflict two inputs that each exist but disagree
empty response a valid empty list, not an error
page boundary how the caller asks for more, and how it learns there is no more
Five rows. They take minutes to write and they settle most of the message shape: the empty case decides that an empty list is an answer and not an error, the page-boundary case decides that the response carries a cursor, and the refusal case decides that codes are an enum rather than free text in a message string.
The example
Neutral package, neutral domain - this is the shape, not our schema:
syntax = "proto3";
package example.v1;
service EntityService {
// The HTTP role is declared on the method; the HTTP spec is derived from it.
rpc ListEntities(ListEntitiesRequest) returns (ListEntitiesResponse);
}
message ListEntitiesRequest {
string parent_id = 1; // required; a refusal, not an empty list, when it does not exist
int32 page_size = 2; // 1..200, clamped server-side; 0 means the default
string page_token = 3; // cursor from a previous response; empty asks for the first page
}
message ListEntitiesResponse {
repeated Entity entities = 1; // an empty list is a valid answer
string next_page_token = 2; // empty only when this is the last page
}
enum RefusalCode {
REFUSAL_CODE_UNSPECIFIED = 0;
REFUSAL_CODE_PARENT_NOT_FOUND = 1;
REFUSAL_CODE_PAGE_TOKEN_INVALID = 2;
REFUSAL_CODE_SELECTOR_CONFLICT = 3;
}
Four of the five cases are visible there without reading any implementation, and the fifth - conflict - has a code reserved for it. Lists are cursor-paginated with a clamped page size; there are no unbounded lists here.
What stays data, and not contract
Not everything that looks like an enum belongs in the schema. A category - a type, a status, the level at which something is attached - is a dictionary row and a string code, not a .proto enum. A new category is then added as data: no contract change, no regeneration, no rollout. An enum is reserved for the things that change together with the code, where a new member means nothing until some branch handles it.
The test I use is one question: if a new member arrives, does any code have to change? No - dictionary. Yes - enum.
What it costs
Three costs, all real:
- The schema becomes a bottleneck. Until the contract is accepted there is no code and no work to hand out. That is deliberate, and it is still a bottleneck.
- Field discussion looks like bureaucracy - right up to the first breaking change that gets caught before implementation instead of after release.
- The contract build tool is one more dependency in the build, with its own version to keep in step with everything else.
The cost that is easiest to underrate is the one this ladder prices - the same edit, before and after generation:
edit in the schema, before generation 1 line
───────────────────────────────────────────────────────────────
edit after generation contract
generated code
service code
tests
documentation
When not to do this
Three cases where I would not pay for any of it: an interface with exactly one consumer and that consumer inside the same binary; a one-off internal handle with a life expectancy of a week; and exploratory work where the shape of the answer is genuinely not known yet and the first few versions are meant to be thrown away.
The multiplier line
A schema that both the call and the HTTP specification are derived from is the same move as putting a fact into the text of a specification, one level up: the executor does not choose the shape of the answer, it receives it. What is derived cannot drift, and what cannot drift needs nobody to remember it.
From requirement to contract — Part 2. Next: the tracker - the only place where the price of a requirement is actually visible, and what that price looks like once it is written down honestly.
If you do this better, tell me where you keep the HTTP description when the schema is the source. If you have been through this, what did your second source of truth turn out to cost? If you see it differently, say where a hand-written spec beats a derived one. How is it solved on your side, and what broke there?




Top comments (0)