DEV Community

Cover image for The Contract Is a .proto File
Anton Brilliantov
Anton Brilliantov

Posted on

The Contract Is a .proto File

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

Two machine-made snapshots side by side - an environment catalog of 59 variables split into 45 from the platform and 14 from the service configuration, and a metrics snapshot of 67 entries from the platform, the service and a dynamic factory - with the drift check below them and the one uncovered zone marked separately

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
Enter fullscreen mode Exit fullscreen mode

One .proto box in the centre listing messages, calls, required fields, bounds and refusal codes, with two arrows down to a gRPC call box and an HTTP handle box marked role annotation and derived spec, and a crossed-out box for a hand-written HTTP specification

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
Enter fullscreen mode Exit fullscreen mode

Five case rows - success, refusal, conflict, empty response and page boundary - each in monospace with a one-line meaning, above a hairline and the note that the list is written before a single field is named

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;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

A two-step ladder - the upper step, an edit in the schema, priced at one line; the lower step, an edit after generation, priced across five artifacts: contract, generated code, service code, tests and 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)