All writing

Contract-first APIs with Protocol Buffers

Designing the Protobuf service and message contract before implementation makes ownership, compatibility and interaction semantics visible while they are still inexpensive to change.

about 44 minutes min read

Suppose the basket needs product information from catalog.

An implementation-first approach might begin with a server method:

func (s *Server) GetProduct(
	ctx context.Context,
	productID string,
) (*Product, error) {
	// Implementation.
}

Once the method works, I can expose it remotely.

Perhaps I generate a schema that resembles the code:

service CatalogService {
  rpc GetProduct(GetProductRequest)
      returns (GetProductResponse);
}

message GetProductRequest {
  string product_id = 1;
}

message GetProductResponse {
  Product product = 1;
}

That may produce a perfectly usable API.

But important decisions have already begun to solidify inside the implementation:

  • what the operation is called;
  • which input it requires;
  • which data it returns;
  • which errors can occur;
  • which component owns the meaning;
  • whether the operation is safe to retry;
  • how old clients will coexist with new servers.

The schema arrived after those decisions.

It documented an interface that the code had already shaped.

A contract-first approach reverses the order.

understand the interaction
        │
        ▼
design the contract
        │
        ▼
review it with consumers
        │
        ▼
check compatibility and conventions
        │
        ▼
generate implementation boundaries
        │
        ▼
write the application code

The schema becomes the first shared implementation artefact.

Not because the schema contains the whole service, but because it makes the boundary visible before either side builds too much around it.

Contract-first API design begins with the agreement between systems, then implements each participant against that agreement.

The Protobuf schema is the executable structural core of that agreement. Behavioural guarantees such as authorisation, idempotency, deadlines, durability and freshness still require documentation, policy and implementation.

Contract-first begins with the interaction

The contract sits between producer and consumer

Suppose catalog owns product information.

Basket needs enough catalog data to decide whether an item may be added.

BASKET
   │
   │ product contract
   ▼
CATALOG

Catalog may be called the producer or provider of the API.

Basket is a consumer or client.

Each sees the interaction differently.

Catalog may ask:

  • Which product facts do I own?
  • Which rules determine whether a product is purchasable?
  • Which internal details must remain private?
  • How may the response evolve?

Basket may ask:

  • Which identifier should I send?
  • Can the product be absent?
  • How do I know it is purchasable?
  • Is the returned price current enough?
  • What should I do when catalog is unavailable?

A contract-first review places those questions around one shared artefact.

CATALOG CONCERNS
       │
       ▼
   .proto contract
       ▲
       │
BASKET CONCERNS

Without that review, catalog may expose what is convenient to implement while basket quietly assumes behaviour that was never promised.

The schema gives those assumptions somewhere to be challenged.

The schema is an executable structural agreement

A prose API document might say:

Basket sends a product ID and catalog returns product information.

That is useful.

It leaves structural questions unanswered:

  • Is the identifier a string or an integer?
  • Can several product IDs be requested?
  • Is the product nested in the response?
  • How is money represented?
  • Which service and package own the operation?

A Protobuf schema makes those choices explicit:

syntax = "proto3";

package bfstore.catalog.v1;

service CatalogService {
  rpc GetPurchasableProduct(
      GetPurchasableProductRequest
  ) returns (
      GetPurchasableProductResponse
  );
}

message GetPurchasableProductRequest {
  string product_id = 1;
}

message GetPurchasableProductResponse {
  PurchasableProduct product = 1;
}

message PurchasableProduct {
  string product_id = 1;
  string name = 2;
  Money current_price = 3;
}

message Money {
  int64 amount_minor = 1;
  string currency = 2;
}

For this contract:

  • NOT_FOUND means the product does not exist;
  • FAILED_PRECONDITION means the product exists but cannot currently be purchased;
  • a successful response always contains one PurchasableProduct.

The field declaration alone cannot force product to be present on the wire. That is a behavioural invariant enforced by the service and its contract tests.

The schema can be compiled, linted, compared with earlier versions, used to generate clients and servers, and reviewed before application code exists.

That gives it more force than an example request copied into a wiki and abandoned beside the meeting notes.

Contract-first is not schema-first-and-domain-later

Beginning with a schema does not mean inventing messages before understanding the business operation.

A poor contract-first process looks like:

open empty .proto file
        │
        ▼
guess some nouns
        │
        ▼
generate code

The contract should follow a conversation about:

  • the capability being exposed;
  • the owner of the data;
  • the consumer’s actual need;
  • the operation’s business meaning;
  • the required consistency;
  • the expected failure behaviour.

Basket might initially ask catalog:

Give me the product row.

That request leaks implementation language.

What basket really needs may be:

Give me the current catalog facts required to decide whether this product can enter a basket.

That produces a more deliberate operation:

rpc GetPurchasableProduct(
    GetPurchasableProductRequest
) returns (
    GetPurchasableProductResponse
);

The name expresses the capability rather than the storage mechanism.

The contract comes before code.

The domain meaning comes before the contract.

Begin with the interaction, not the fields

Before deciding which fields belong in a message, I should write the interaction in plain language.

For example:

Basket provides a catalog product identifier. Catalog determines whether the product exists and is currently available for purchase. If it is purchasable, catalog returns the description and current listed price needed by basket. If it does not exist, catalog returns NOT_FOUND. If it exists but cannot currently be purchased, catalog returns FAILED_PRECONDITION.

This reveals several decisions:

  • catalog owns product existence;
  • catalog owns publication and purchasability;
  • catalog owns the current listed price;
  • basket does not reconstruct purchasability;
  • missing differs from known but unavailable;
  • success always returns one product.

Only then do the message shapes become useful.

A field list without an interaction model is merely organised uncertainty.

Design operations around authority

Expose a capability, not internal retrieval steps

An implementation-first API can inherit the shape of internal code.

Suppose catalog contains functions such as:

loadProductRow()
loadProductPrice()
loadPublicationStatus()
loadProductImages()

Copying those methods into the remote contract would produce:

service CatalogService {
  rpc GetProductRow(GetProductRowRequest)
      returns (GetProductRowResponse);

  rpc GetProductPrice(GetProductPriceRequest)
      returns (GetProductPriceResponse);

  rpc GetPublicationStatus(GetPublicationStatusRequest)
      returns (GetPublicationStatusResponse);

  rpc GetProductImages(GetProductImagesRequest)
      returns (GetProductImagesResponse);
}

Basket may now need four remote calls to understand one product.

BASKET
   │
   ├──► GetProductRow
   ├──► GetProductPrice
   ├──► GetPublicationStatus
   └──► GetProductImages

The contract exposes internal retrieval steps rather than one coherent remote capability.

A contract-first discussion asks:

What complete decision is the consumer trying to make?

That may lead to one GetPurchasableProduct call.

The service remains free to use several tables or functions internally.

The consumer receives one supported result designed for its interaction.

Avoid designing a remote object model

It is tempting to treat every domain object as a remote object with methods.

service ProductService {
  rpc GetName(GetNameRequest)
      returns (GetNameResponse);

  rpc GetPrice(GetPriceRequest)
      returns (GetPriceResponse);

  rpc GetDimensions(GetDimensionsRequest)
      returns (GetDimensionsResponse);

  rpc IsPublished(IsPublishedRequest)
      returns (IsPublishedResponse);
}

Across a network, this becomes chatty and failure-prone.

Each call introduces latency, a deadline, authentication, authorisation, partial failure and another compatibility surface.

A contract-first design treats the network as part of the interface.

It asks which values belong together in one useful remote exchange.

message ProductSummary {
  string product_id = 1;
  string name = 2;
  Money current_price = 3;
  ProductAvailability availability = 4;
}

That message is not automatically correct because it contains more fields.

It is useful when the fields form one coherent consumer-facing result with one ownership and freshness model.

Requests describe caller authority

Consider:

rpc CreateProduct(Product)
    returns (Product);

The caller appears able to send a complete Product.

But who owns:

  • the product ID;
  • creation time;
  • publication status;
  • revision;
  • calculated attributes?

If catalog owns those values, the request should not imply that the caller may choose them.

A dedicated request makes authority clearer:

message CreateProductRequest {
  string name = 1;
  string description = 2;
  Money initial_price = 3;
  string operation_id = 4;
}

The response contains the accepted catalog state:

message CreateProductResponse {
  Product product = 1;
}

The distinction is:

Direction Meaning
Request Facts the caller is permitted to propose
Response State the service accepted or produced

Separate request and response messages make authority visible even where an existing domain message appears reusable.

An operation ID is only an input to idempotency

Adding operation_id does not make CreateProduct idempotent by itself.

The contract must also define:

  • the scope of the identifier, such as per caller or tenant;
  • how long the service retains it;
  • whether the same identifier with different input is rejected;
  • what a duplicate receives while the first attempt is still running;
  • how concurrent attempts are reduced to one winner;
  • how the identifier and business effect are recorded atomically.

A useful policy is:

same operation ID
same request meaning
    │
    └── return the original result


same operation ID
different request meaning
    │
    └── reject as conflict

The schema gives the logical operation an identity.

The implementation must make that identity authoritative.

Responses should expose meaning, not database records

Suppose catalog stores:

  • products;
  • product_descriptions;
  • product_prices;
  • product_publication.

A persistence-shaped response might be:

message ProductRow {
  int64 internal_id = 1;
  string public_id = 2;
  int32 status_code = 3;
  int64 price_row_id = 4;
  int64 description_row_id = 5;
}

This gives consumers catalog’s storage structure rather than catalog’s supported meaning.

A better contract exposes:

message Product {
  string product_id = 1;
  string name = 2;
  string description = 3;
  Money current_price = 4;
  ProductStatus status = 5;
}

The service translates its internal model into its public API model.

DATABASE TABLES
      │
      ▼
CATALOG DOMAIN
      │
      ▼
PROTOBUF RESPONSE

That translation lets catalog change table names, normalisation, indexes, internal identifiers or storage technology without turning every persistence change into an API change.

Model identifiers deliberately

A field named:

string id = 1;

leaves the consumer to infer:

  • ID of what?
  • Assigned by whom?
  • Globally unique or local?
  • Stable for how long?
  • Safe to display?
  • May it be reused?

A more explicit request uses:

message GetProductRequest {
  string product_id = 1;
}

A custom identifier message can sometimes add value:

message ProductId {
  string value = 1;
}

That can prevent accidental mixing of product, basket and order IDs.

Wrapping every scalar can also make simple APIs heavy.

The contract-first decision is not “always wrap identifiers.”

It is to make identity, ownership and interchange rules unambiguous enough for the boundary.

Model messages deliberately

Model money as money

This contract is ambiguous:

message Product {
  double price = 1;
}

Questions appear immediately:

  • Which currency?
  • Are fractional values permitted?
  • How is rounding handled?
  • Is 249.00 pounds or pence?
  • Can binary floating-point represent the value safely?

A clearer model is:

message Money {
  int64 amount_minor = 1;
  string currency = 2;
}

Then Money current_price = 3; can represent £249.00 as:

amount_minor = 24900
currency = "GBP"

The message does not enforce every monetary rule.

It makes the unit and currency part of the contract rather than folklore attached to one language’s float64.

Model absence explicitly when it matters

Suppose an update request contains:

message UpdateProductRequest {
  string name = 1;
}

If name is empty, does that mean leave the name unchanged, set it to an empty string, or report that the client did not know the current value?

When the distinction matters, the contract should track presence:

message UpdateProductRequest {
  string product_id = 1;
  optional string name = 2;
}

Now the service can distinguish:

  • name absent;
  • name present with a value;
  • name present but empty.

In Protobuf, optional controls presence tracking. It does not decide whether the business operation considers the field mandatory.

The schema gives the implementation enough information to enforce the intended rule.

Partial updates need more than optional fields

An update message can grow:

message UpdateProductRequest {
  string product_id = 1;
  optional string name = 2;
  optional string description = 3;
  Money price = 4;
  optional ProductStatus status = 5;
}

Money is a message field, so it already has presence in proto3 and does not need the optional label.

Questions still remain:

  • Does absence mean leave unchanged?
  • May every field be changed together?
  • Which fields may this caller update?
  • How are concurrent updates detected?
  • What if the caller read an older revision?

A field mask can express patch intent:

import "google/protobuf/field_mask.proto";

message UpdateProductRequest {
  Product product = 1;
  google.protobuf.FieldMask update_mask = 2;
}

For example:

update_mask:
    name
    description

The contract must still define:

  • whether an empty mask means no changes or all mutable fields;
  • whether unknown paths are rejected;
  • what happens when immutable or output-only fields are named;
  • how nested paths are interpreted;
  • whether values outside the mask are ignored or rejected;
  • how concurrent updates are detected.

A field mask expresses update scope.

It does not replace domain policy.

Enums should leave room for uncertainty

Consider:

enum ProductStatus {
  PRODUCT_STATUS_DRAFT = 0;
  PRODUCT_STATUS_PUBLISHED = 1;
  PRODUCT_STATUS_DISCONTINUED = 2;
}

An absent value can appear as the zero-valued member, PRODUCT_STATUS_DRAFT.

That can accidentally treat unspecified input as a real draft state.

A safer definition begins with:

enum ProductStatus {
  PRODUCT_STATUS_UNSPECIFIED = 0;
  PRODUCT_STATUS_DRAFT = 1;
  PRODUCT_STATUS_PUBLISHED = 2;
  PRODUCT_STATUS_DISCONTINUED = 3;
}

The zero value now communicates that no meaningful status was supplied.

The service can reject it where a real status is required.

Consumers should also tolerate enum values added by newer producers. A generated client may receive a numeric value that did not have a name when its code was generated. Exhaustive switches and validators should not assume the known list can never grow.

Avoid Boolean contracts that are likely to grow

Suppose catalog returns:

bool available = 1;

Today the product appears to have two states.

Tomorrow the domain may need:

  • available;
  • unavailable;
  • preorder;
  • backorder;
  • regional restriction;
  • temporarily unknown.

An enum may preserve future options:

enum ProductAvailability {
  PRODUCT_AVAILABILITY_UNSPECIFIED = 0;
  PRODUCT_AVAILABILITY_AVAILABLE = 1;
  PRODUCT_AVAILABILITY_UNAVAILABLE = 2;
  PRODUCT_AVAILABILITY_PREORDER = 3;
  PRODUCT_AVAILABILITY_BACKORDER = 4;
}

Not every Boolean is wrong.

A flag such as include_discontinued may genuinely represent one binary request preference.

The review should ask whether the concept is truly binary or merely appears binary in the first implementation.

Validation belongs to the contract discussion

A schema defines structural types.

It does not automatically enforce all application rules.

This request is structurally valid:

message AddBasketItemRequest {
  string basket_id = 1;
  string product_id = 2;
  uint32 quantity = 3;
}

The application may still require:

  • basket_id and product_id to be present;
  • quantity to be greater than zero;
  • quantity not to exceed a configured limit;
  • the basket to accept changes;
  • the product to be purchasable.

Those rules occur at different layers:

Layer Example
Structural validation Does the message contain a usable identifier?
Domain validation Does this basket exist and accept another item?
Cross-boundary validation Does catalog currently permit this product to be purchased?

Business-required does not mean wire-required.

The parser answers whether the message can be decoded.

The service answers whether the message is acceptable for this operation.

Define behavioural semantics

Design errors with the operation

A successful response is only half the contract.

For GetPurchasableProduct, the consumer needs to know how the operation can fail.

Condition Intended result
Product identifier missing or malformed INVALID_ARGUMENT
Product does not exist NOT_FOUND
Product exists but cannot be purchased FAILED_PRECONDITION
Caller lacks permission PERMISSION_DENIED
Catalog cannot complete temporarily UNAVAILABLE
Caller’s deadline expires DEADLINE_EXCEEDED
Unexpected internal defect INTERNAL

The exact choices belong to the API’s semantics.

What matters is that producer and consumer agree before they implement different interpretations.

Do not encode every failure as a Boolean

This response is tempting:

message GetProductResponse {
  bool success = 1;
  string error_message = 2;
  Product product = 3;
}

It allows ambiguous combinations:

  • success with no product;
  • failure with a product;
  • failure with no message.

It also duplicates RPC status mechanisms.

A unary gRPC contract normally uses the response message for successful results and gRPC status for unsuccessful completion.

That does not mean every non-ideal business state is an RPC failure.

A search can successfully return no matching products:

message SearchProductsResponse {
  repeated ProductSummary products = 1;
}

The empty result is valid.

The contract should distinguish a successful operation with an empty result from an operation that could not complete.

Business outcomes may belong in the response

Suppose inventory receives:

rpc ReserveStock(ReserveStockRequest)
    returns (ReserveStockResponse);

A reservation may be denied because stock is unavailable.

That could be modelled as a typed business outcome:

message ReserveStockResponse {
  ReservationOutcome outcome = 1;
  Reservation reservation = 2;
}

enum ReservationOutcome {
  RESERVATION_OUTCOME_UNSPECIFIED = 0;
  RESERVATION_OUTCOME_RESERVED = 1;
  RESERVATION_OUTCOME_INSUFFICIENT_STOCK = 2;
}

The RPC status is successful because inventory processed the command and reached a known business decision.

Transport or service failures still use RPC errors.

BUSINESS REJECTION

Inventory evaluated the request
and refused it for a known domain reason.


REMOTE FAILURE

Inventory could not provide
a reliable decision.

The correct boundary depends on how consumers should handle each result.

That decision belongs in contract design, not in whichever exception is easiest to return from server code.

Method names should communicate intent

Compare:

rpc Update(UpdateRequest)
    returns (UpdateResponse);

with:

rpc ChangeProductPrice(
    ChangeProductPriceRequest
) returns (
    ChangeProductPriceResponse
);

The second operation is more explicit.

It can express which behaviour is requested, which validation applies, which event may result and whether the operation is idempotent.

Generic CRUD methods can be appropriate for genuinely resource-oriented administrative interfaces.

Domain operations often deserve domain language:

rpc PublishProduct(PublishProductRequest)
    returns (PublishProductResponse);

rpc DiscontinueProduct(DiscontinueProductRequest)
    returns (DiscontinueProductResponse);

These operations protect transitions instead of asking callers to manipulate a status field correctly.

Commands need explicit retry semantics

A query asks for information:

rpc GetProduct(GetProductRequest)
    returns (GetProductResponse);

A command asks the owner to change state:

rpc PublishProduct(PublishProductRequest)
    returns (PublishProductResponse);

That distinction affects retry safety, idempotency, authorisation, response meaning, observability and audit requirements.

Publishing can be defined as a target-state operation:

Ensure this product is published and return the authoritative result.

Repeated calls can then return the already-published state rather than create a second publication effect.

That is natural idempotency.

It differs from deduplicating a command using a supplied operation ID. An operation ID may still be useful for auditing or uncertain outcomes, but the contract should not require one merely because every state-changing method looks safer with an extra string.

Define what success means

For PublishProduct, a successful response could mean:

  • the request was validated;
  • the product status was committed;
  • a ProductPublished event was recorded;
  • downstream search indexes were updated;
  • every cache was invalidated.

Those are not equivalent.

A contract-first discussion should identify the success boundary.

For example:

Success means catalog committed the published state and durably recorded the event for delivery. Search and cache propagation may complete later.

The response can include the authoritative product:

message PublishProductResponse {
  Product product = 1;
}

Without that definition, consumers may assume that the response guarantees more than the service can truthfully promise.

Deadlines belong to the consumer contract

The .proto service definition does not normally specify one universal deadline.

The appropriate deadline depends on:

  • the caller’s time budget;
  • the operation;
  • the user journey;
  • expected service latency;
  • remaining downstream work.

A storefront request may allow catalog 200 milliseconds.

A batch export may allow several seconds.

A contract-first process should still discuss expected latency, maximum useful waiting time, cancellation and whether long-running work should be asynchronous.

The code generator can create a callable method.

It cannot decide the caller’s business deadline.

Choose the interaction shape

Streaming changes the relationship

A Protobuf service can define unary, server-streaming, client-streaming or bidirectional-streaming RPCs.

A unary method has one request and one response:

rpc GetProduct(GetProductRequest)
    returns (GetProductResponse);

A server-streaming method returns several messages:

rpc ExportProducts(ExportProductsRequest)
    returns (stream ProductExportRecord);

Streaming is not merely a performance switch.

It changes:

  • how partial results are handled;
  • what completion means;
  • how cancellation works;
  • whether ordering matters;
  • how retries resume;
  • how backpressure is managed.

A failed stream after 9,000 records raises a different recovery problem from a failed unary request.

Prefer a bounded response when it fits

Suppose catalog needs to return 20 featured products.

A server stream could work:

rpc ListFeaturedProducts(
    ListFeaturedProductsRequest
) returns (
    stream ProductSummary
);

A repeated field may be simpler:

message ListFeaturedProductsResponse {
  repeated ProductSummary products = 1;
}

A bounded unary response gives the caller one completion boundary.

A stream is useful when the result is large, can be processed incrementally, need not fit in memory, or represents a genuinely long-lived interaction.

Every richer interaction mode brings richer failure semantics.

Pagination belongs in the initial design

A list operation may begin small:

message ListProductsResponse {
  repeated Product products = 1;
}

As the catalog grows, returning every product becomes impractical.

A contract-first design can include pagination from the beginning:

message ListProductsRequest {
  uint32 page_size = 1;
  string page_token = 2;
  string category = 3;
}

message ListProductsResponse {
  repeated Product products = 1;
  string next_page_token = 2;
}

The service owns the token’s internal meaning.

Consumers treat it as opaque.

Documentation should define:

  • default and maximum page size;
  • ordering stability;
  • token lifetime;
  • behaviour when data changes between pages;
  • whether tokens are bound to the original filters, sort order, tenant and caller;
  • whether the token represents a snapshot or only a continuation position.

The fields create the structure.

They do not complete the pagination policy.

Design for compatibility and rollout

A service boundary means old and new participants can coexist.

OLD CLIENT
    │
    ▼
NEW SERVER


NEW CLIENT
    │
    ▼
OLD SERVER

Version overlap occurs during rolling rollout, partial failure, rollback, delayed upgrades and stored-message replay.

Contract-first design therefore treats compatibility as an initial property rather than a later maintenance task.

Additive change is the normal path

Suppose the first response is:

message Product {
  string product_id = 1;
  string name = 2;
}

A later version adds:

message Product {
  string product_id = 1;
  string name = 2;
  string description = 3;
}

Old consumers do not know field 3.

New consumers receiving an old message see no description.

A safe rollout may be:

  1. add the field to the schema;
  2. deploy readers that tolerate its absence;
  3. deploy producers that populate it;
  4. rely on it only after compatible participants exist.

The schema change and rollout plan belong together.

Field identities are permanent

In:

message Product {
  string product_id = 1;
  string name = 2;
}

the field numbers identify values in the binary wire format.

Changing name from field 2 to field 7 removes wire field 2 and introduces wire field 7.

Field numbers are permanent identities from the moment a schema is published.

If a field is retired, reserve its number and name:

message Product {
  reserved 3;
  reserved "legacy_code";

  string product_id = 1;
  string name = 2;
  string supplier_reference = 4;
}

The schema keeps a small graveyard so that old identities do not return wearing new labels.

Wire compatibility is only one test

Suppose a field changes from:

int64 listed_price_minor = 3;

to:

int64 warehouse_quantity = 3;

The bytes may still decode as an integer.

The meaning has been destroyed.

Compatibility layer Question
Wire Can old and new participants parse the bytes?
Generated source Will existing callers still compile and behave?
Semantic Does the field still mean what consumers were promised?

Automated tooling can detect many structural breaks.

It cannot decide that a price has quietly become stock.

Version packages represent incompatible generations

A package such as:

package bfstore.catalog.v1;

defines a schema namespace.

Compatible additions can remain within v1.

A deliberately incompatible replacement might use bfstore.catalog.v2, allowing old and new contracts to coexist during migration.

A new package should not be created for every small change.

A major version is useful when the interaction model changes incompatibly, field meanings must be redesigned, or consumers need parallel support and a migration period.

Versioning does not remove migration.

It makes the migration boundary explicit.

Use the contract during development

Review with consumers before generation

Catalog proposes:

message GetPurchasableProductResponse {
  PurchasableProduct product = 1;
}

Basket asks:

  • Is product always present on success?
  • Does current_price include currency?
  • Is the value authoritative at checkout?
  • How is an unavailable product reported?
  • Can I retry after a timeout?

Those questions can change the contract before generated types spread through both codebases.

They may also reveal that basket needs a smaller PurchasableProduct rather than the complete catalog Product.

Use generated code as a design probe

Before catalog exists, basket can generate the client interface and work against a fake:

type FakeCatalogClient struct {
	Product *catalogv1.PurchasableProduct
	Err     error
}

func (f *FakeCatalogClient) GetPurchasableProduct(
	ctx context.Context,
	request *catalogv1.GetPurchasableProductRequest,
	opts ...grpc.CallOption,
) (*catalogv1.GetPurchasableProductResponse, error) {
	if f.Err != nil {
		return nil, f.Err
	}

	return &catalogv1.GetPurchasableProductResponse{
		Product: f.Product,
	}, nil
}

This can reveal whether the request contains enough information, the response is convenient to consume, errors are meaningful, the operation is too chatty, and the naming makes sense in client code.

Generated code becomes a design probe.

It does not become the architecture.

Producer and consumer can work in parallel

Once the contract is agreed:

                         .proto schema
                              │
                ┌─────────────┴─────────────┐
                │                           │
                ▼                           ▼
        catalog implementation       basket implementation
                │                           │
                ▼                           ▼
        generated server API         generated client API

Catalog implements the generated server interface.

Basket integrates with the generated client interface or a fake.

The schema may still change.

It should become a visible design event rather than an accidental result of one implementation refactor.

Different tests answer different questions

Generated types prove that both sides can compile against a schema.

They do not prove that the running service honours its behaviour.

Evidence Question
Schema compilation Is the structure valid?
Breaking-change check Is the proposed schema structurally compatible?
Producer contract test Does the server honour promised outcomes?
Consumer test Can the client handle supported responses and errors?
Integration test Do deployed participants communicate correctly?

Contract tests can verify that:

  • a valid product returns expected fields;
  • an unknown product returns NOT_FOUND;
  • an unavailable product returns FAILED_PRECONDITION;
  • a missing ID returns INVALID_ARGUMENT;
  • currency is populated;
  • every successful response contains a product.

One green generated-code build does not cover those behaviours.

Linting and breaking checks support review

A contract repository can enforce conventions for packages, files, services, methods, messages, fields and enums.

buf lint

can detect style and structural issues.

It cannot decide whether:

string thing = 1;

has useful domain meaning.

It can only ensure that everybody spells the ambiguity in the approved case style.

Breaking-change detection can compare a proposed schema with an earlier version:

buf breaking \
  --against '.git#branch=main'

This can catch removed fields, incompatible type changes, removed methods and package changes.

It cannot understand every semantic change or the meaning of custom options.

The tools identify structural impact.

Humans decide whether the contract remains truthful.

Generation should be reproducible

The workflow should define how code is generated:

buf generate

The repository can specify which plugins and versions run, which options are supplied, and where generated files are written.

.proto files
    │
    ├── buf lint
    ├── buf breaking
    └── buf generate
           │
           ├── Go messages
           ├── Go gRPC interfaces
           └── bindings for other languages

The generated output is reproducible machinery.

The reviewed schema remains the source of the boundary.

Protect the boundary

Generated interfaces are seams, not architecture

Go may receive a generated server interface resembling:

type CatalogServiceServer interface {
	GetPurchasableProduct(
		context.Context,
		*GetPurchasableProductRequest,
	) (*GetPurchasableProductResponse, error)
}

Implementing it proves that catalog supplied the required method.

It does not prove that:

  • the method owns the right capability;
  • the transaction is correct;
  • the caller is authorised;
  • the deadline is honoured;
  • the operation is observable;
  • the errors are useful;
  • retries are safe.

Code generation creates an implementation seam.

The architecture still includes the domain model, application service, persistence, security, resilience, telemetry and deployment.

Keep generated messages near the transport boundary

It is tempting to use generated Protobuf types throughout the service.

gRPC handler
    │
    ▼
application service
    │
    ▼
domain model
    │
    ▼
database

A clearer design translates at the boundary:

func productToProto(
	product catalog.Product,
) *catalogv1.Product {
	return &catalogv1.Product{
		ProductId: product.ID().String(),
		Name:      product.Name(),
		CurrentPrice: &catalogv1.Money{
			AmountMinor: product.Price().AmountMinor(),
			Currency:    product.Price().Currency(),
		},
	}
}

And in the other direction:

func parseGetProductRequest(
	request *catalogv1.GetProductRequest,
) (catalog.ProductID, error) {
	return catalog.ParseProductID(request.GetProductId())
}

Translation adds code.

It protects the independence that justified the service boundary.

Do not make one schema own every context

Catalog may define a current product representation.

Order needs a purchase-time snapshot:

message OrderedProduct {
  string product_id = 1;
  string description_at_purchase = 2;
  Money unit_price_at_purchase = 3;
}

The values may look similar.

Their meanings and lifecycles differ.

CATALOG PRODUCT

current catalog representation


ORDERED PRODUCT

historical facts accepted for one order

Sharing one message would imply that catalog’s evolving current model and order’s historical record are one contract.

Contract-first design should strengthen boundaries, not create a universal Protobuf model that quietly dissolves them.

Events require their own contracts

An RPC response answers one caller.

An event reports a fact:

message ProductPriceChanged {
  string event_id = 1;
  string product_id = 2;
  Money previous_price = 3;
  Money new_price = 4;
  google.protobuf.Timestamp occurred_at = 5;
}

The event may be stored, retried, replayed months later, consumed by several systems and processed out of order.

Its lifecycle differs from an ephemeral RPC response.

Reusing the response as the event payload may save one message definition while coupling unrelated contracts.

The useful question remains:

What interaction does this schema represent?

Not:

Which generated type is already nearby?

Registries distribute contracts; ownership governs them

A schema registry can help consumers discover contracts, retrieve versions, generate clients, inspect documentation and compare changes.

It does not answer:

  • Who owns this API?
  • Who approves a semantic change?
  • Which consumers depend on it?
  • How long is an old version supported?
  • Who handles an incident?

A beautifully catalogued schema with no accountable owner is simply organised ambiguity.

Contract-first should not become committee-first

A contract affects consumers, so review is valuable.

That does not mean every field addition needs a week-long summit.

A small internal API may need one owner, one known consumer, automated checks and ordinary pull-request review.

A widely shared API may need consumer representatives, security and privacy review, migration planning and longer deprecation windows.

The goal is early feedback from the people affected.

It is not ceremonial paperwork.

Contract-first work should make change cheaper by moving discovery earlier, not make every schema edit feel like treaty negotiation at sea.

Worked contract and review framework

A small bfstore catalog contract could begin:

syntax = "proto3";

package bfstore.catalog.v1;

import "google/protobuf/timestamp.proto";

option go_package =
    "github.com/mantrobuslawal/bfstore/gen/catalog/v1;catalogv1";

service CatalogService {
  rpc GetPurchasableProduct(
      GetPurchasableProductRequest
  ) returns (
      GetPurchasableProductResponse
  );

  rpc CreateProduct(CreateProductRequest)
      returns (CreateProductResponse);

  rpc PublishProduct(PublishProductRequest)
      returns (PublishProductResponse);
}

message GetPurchasableProductRequest {
  string product_id = 1;
}

message GetPurchasableProductResponse {
  // Present on every successful response.
  PurchasableProduct product = 1;
}

message PurchasableProduct {
  string product_id = 1;
  string name = 2;
  Money current_price = 3;
}

message CreateProductRequest {
  string name = 1;
  string description = 2;
  Money initial_price = 3;

  // Stable identity for one logical creation request.
  string operation_id = 4;
}

message CreateProductResponse {
  // Present on every successful response.
  Product product = 1;
}

message PublishProductRequest {
  string product_id = 1;
}

message PublishProductResponse {
  // Present on every successful response.
  Product product = 1;
}

message Product {
  string product_id = 1;
  string name = 2;
  string description = 3;
  Money current_price = 4;
  ProductStatus status = 5;
  google.protobuf.Timestamp created_at = 6;
}

message Money {
  int64 amount_minor = 1;
  string currency = 2;
}

enum ProductStatus {
  PRODUCT_STATUS_UNSPECIFIED = 0;
  PRODUCT_STATUS_DRAFT = 1;
  PRODUCT_STATUS_PUBLISHED = 2;
  PRODUCT_STATUS_DISCONTINUED = 3;
}

The accompanying behavioural contract might state:

  • GetPurchasableProduct returns NOT_FOUND when the product does not exist and FAILED_PRECONDITION when it exists but cannot be purchased.
  • A successful GetPurchasableProduct response always contains product.
  • CreateProduct.operation_id is scoped to the authenticated caller, retained for the agreed deduplication period, and rejected if reused with different request data.
  • CreateProduct commits the product and the operation record atomically.
  • PublishProduct is a target-state operation. Repeating it for an already-published product returns the current published result without creating another publication effect.
  • Successful create and publish responses mean catalog committed the authoritative state and durably recorded any required event for later delivery.
  • Search and cache propagation may complete asynchronously.
  • Catalog assigns all identifiers and timestamps.
  • Authentication, authorisation, expected latency and error semantics are documented per method.

Before implementation, I should be able to answer:

Concern Question
Purpose What capability does this operation expose?
Owner Which service owns the meaning and state?
Consumer Which decision is the consumer trying to make?
Request authority Which values may the caller propose?
Response authority Which facts does the service promise?
Presence Must absence differ from a default value?
Units Are money, time, size and quantities unambiguous?
Errors Which outcomes are business decisions, and which mean no reliable decision was reached?
Success What exactly is complete when success returns?
Retry May the operation be repeated safely?
Identity Does state-changing work need an operation identity?
Compatibility Can old and new participants coexist?
Ownership boundary Does the schema expose a capability rather than storage internals?
Lifecycle Is a message reused across contracts with different retention and evolution needs?
Operations How will calls be timed, traced and monitored?

A contract has more dimensions than its field list.

The field list gives those dimensions somewhere precise to attach.

The mental model I am keeping

My old model was:

write service
      │
      ▼
expose service
      │
      ▼
document API

The new model is:

                    BUSINESS INTERACTION
                             │
                             ▼
                      CONTRACT PROPOSAL
                             │
            ┌────────────────┼────────────────┐
            │                │                │
            ▼                ▼                ▼
         provider         consumer       operations
          review           review          review
            │                │                │
            └────────────────┼────────────────┘
                             ▼
                      PROTOBUF SCHEMA
                             │
             ┌───────────────┼───────────────┐
             │               │               │
             ▼               ▼               ▼
            lint         compatibility     generation
                             │
                             ▼
                  CLIENT AND SERVER SEAMS
                             │
             ┌───────────────┴───────────────┐
             │                               │
             ▼                               ▼
      provider implementation        consumer implementation
             │                               │
             └───────────────┬───────────────┘
                             ▼
                       contract tests

Contract-first does not mean the schema is the whole API.

The full contract also includes field meaning, validation, error semantics, deadlines, idempotency, authorisation, compatibility and operational expectations.

Protocol Buffers gives those discussions a strongly typed structural centre.

The value is not merely that code can be generated sooner.

The value is that disagreement can be discovered sooner.

Catalog may believe:

a successful response means the product exists

while basket believes:

a successful response means the product
is currently purchasable at the returned price

That is an architectural problem.

It is cheaper to find it in a schema review than in production logs after checkout begins accepting discontinued furniture.

Contract-first API design is not about worshipping the .proto file.

It is about giving a network boundary a clear agreement before two implementations create their own versions of the truth.

The generated code helps each process comply with the structure.

The contract-first conversation decides whether the structure is worth complying with.

References and further reading

Protocol Buffers

Protocol Buffers overview Introduces .proto definitions, generated language bindings, serialisation and schema evolution.

Proto3 language guide Documents messages, fields, packages, enums, services, RPC definitions, reserved identifiers and compatible schema changes.

Protocol Buffers encoding Explains how field numbers and wire types identify fields in binary Protobuf data.

Protocol Buffers field presence Explains implicit and explicit presence, default values and proto3 optional fields.

Protocol Buffers best practices Provides guidance on stable field numbers, reserved identifiers, enum defaults, compatible evolution and separating API messages from storage messages.

Protocol Buffers style guide Documents naming, file organisation and formatting conventions for .proto schemas.

gRPC service behaviour

gRPC status codes Defines standard status codes including INVALID_ARGUMENT, NOT_FOUND, FAILED_PRECONDITION, PERMISSION_DENIED, UNAVAILABLE and DEADLINE_EXCEEDED.

gRPC deadlines Explains caller deadlines, propagation and cancellation behaviour.

Buf schema workflow

Buf CLI documentation Introduces Buf configuration for schema modules, linting, breaking-change detection and generation.

Buf lint documentation Documents automated checks for schema conventions and best practices.

Buf breaking-change detection Explains comparing proposed Protobuf schemas with an earlier version to detect source-level and wire-level compatibility problems.

Buf code generation Documents reproducible generation of language bindings using configured plugins.