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_FOUNDmeans the product does not exist;FAILED_PRECONDITIONmeans 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 returnsFAILED_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.00pounds 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:
nameabsent;namepresent with a value;namepresent 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_idandproduct_idto be present;quantityto be greater than zero;quantitynot 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
ProductPublishedevent 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:
- add the field to the schema;
- deploy readers that tolerate its absence;
- deploy producers that populate it;
- 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
productalways present on success? - Does
current_priceinclude 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:
GetPurchasableProductreturnsNOT_FOUNDwhen the product does not exist andFAILED_PRECONDITIONwhen it exists but cannot be purchased.- A successful
GetPurchasableProductresponse always containsproduct. CreateProduct.operation_idis scoped to the authenticated caller, retained for the agreed deduplication period, and rejected if reused with different request data.CreateProductcommits the product and the operation record atomically.PublishProductis 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.