Contract-First API Development with Protobuf

An exploration of the contract-first development methodology using Protocol Buffers, showing how defining schemas before writing implementation code leads to better APIs, faster integration, and fewer production incidents.

technical9 min readBy Klivvr Engineering
Share:

Most software teams build APIs in the same way: write the server code, expose the endpoints, then document what was built. The API contract emerges as a side effect of the implementation. This approach is natural, intuitive, and frequently wrong. It produces APIs shaped by internal data models rather than consumer needs, with inconsistent naming, redundant fields, and missing edge cases that only surface during integration testing.

Contract-first development inverts this sequence. The API contract is designed, reviewed, and agreed upon before any implementation code is written. With Protocol Buffers as the contract language and the Nebula schema registry as the shared repository, this methodology becomes practical even for large organizations with dozens of teams working in parallel.

This article explains why contract-first development matters, how to practice it effectively with protobuf, and the organizational changes that make it sustainable.

Why Contract-First Matters

In a microservices architecture, every service is both a producer and a consumer of APIs. A payment service produces a payment creation API and consumes an account validation API. An identity service produces user authentication and consumes audit logging. The web of dependencies grows with every new service, and every dependency is a potential integration failure.

When contracts are defined after implementation, integration failures are discovered late. The consuming team builds against assumptions about the API, the producing team ships something slightly different, and the mismatch surfaces in a staging environment (if you are lucky) or in production (if you are not).

Contract-first development catches these mismatches before any code is written. The contract serves as a shared specification that both teams agree on. The producer implements to the contract. The consumer generates client code from the contract. If the implementation diverges from the contract, the generated code will not compile.

Consider a concrete example. Two teams need to integrate: Team A builds a lending decision engine, and Team B builds a loan application service. In a code-first approach, Team A might write their gRPC service, share the proto file, and Team B would adapt. If Team A's field naming does not match Team B's expectations, or if the response structure is awkward for Team B's use case, the discovery happens late.

In a contract-first approach, both teams collaborate on the proto definition in the Nebula registry before either writes implementation code:

syntax = "proto3";
 
package nebula.lending.v1;
 
import "google/protobuf/timestamp.proto";
 
// LendingDecisionService evaluates loan applications and returns
// a lending decision with associated terms.
service LendingDecisionService {
  // EvaluateApplication performs a lending decision for the given
  // application. The decision is synchronous and typically completes
  // within 2 seconds.
  rpc EvaluateApplication(EvaluateApplicationRequest)
      returns (EvaluateApplicationResponse);
}
 
message EvaluateApplicationRequest {
  // Unique identifier for the loan application.
  string application_id = 1;
  // The applicant's verified identity identifier from the identity service.
  string applicant_id = 2;
  // Requested loan amount in the smallest currency unit.
  int64 requested_amount_minor = 3;
  // ISO 4217 currency code.
  string currency_code = 4;
  // Requested loan term in months.
  int32 term_months = 5;
  // Purpose of the loan, used for risk categorization.
  LoanPurpose purpose = 6;
}
 
message EvaluateApplicationResponse {
  string decision_id = 1;
  string application_id = 2;
  Decision decision = 3;
  // Only populated when decision is DECISION_APPROVED.
  ApprovedTerms approved_terms = 4;
  // Human-readable explanation of the decision.
  string reason = 5;
  google.protobuf.Timestamp decided_at = 6;
}
 
enum Decision {
  DECISION_UNSPECIFIED = 0;
  DECISION_APPROVED = 1;
  DECISION_DECLINED = 2;
  DECISION_REFERRED = 3;
}
 
enum LoanPurpose {
  LOAN_PURPOSE_UNSPECIFIED = 0;
  LOAN_PURPOSE_PERSONAL = 1;
  LOAN_PURPOSE_EDUCATION = 2;
  LOAN_PURPOSE_MEDICAL = 3;
  LOAN_PURPOSE_HOME_IMPROVEMENT = 4;
}
 
message ApprovedTerms {
  int64 approved_amount_minor = 1;
  string currency_code = 2;
  int32 term_months = 3;
  // Annual percentage rate, expressed as basis points (e.g., 1250 = 12.50%).
  int32 apr_basis_points = 4;
  int64 monthly_payment_minor = 5;
}

This contract was designed collaboratively. Both teams reviewed it, agreed on the field names, discussed edge cases (what happens when the decision is REFERRED?), and documented the non-obvious fields (why basis points instead of a float?). By the time implementation begins, the integration surface is fully specified.

The Contract-First Workflow

The Nebula team follows a five-step workflow for contract-first development.

Step 1: Design the schema. The team responsible for the new capability drafts the .proto file and opens a pull request against the Nebula schema registry. The PR description explains the business context, the expected consumers, and any design decisions that are not self-evident from the schema.

Step 2: Review with stakeholders. Both the producing and consuming teams review the schema PR. The review focuses on the contract's usability from the consumer's perspective: Are the fields sufficient? Are the names intuitive? Are the types appropriate? Is the error handling clear?

// Review comment: "Should we include a 'retry_after' field in the
// response for REFERRED decisions, so the consumer knows when to
// check back?"
 
message EvaluateApplicationResponse {
  string decision_id = 1;
  string application_id = 2;
  Decision decision = 3;
  ApprovedTerms approved_terms = 4;
  string reason = 5;
  google.protobuf.Timestamp decided_at = 6;
  // Added during review: suggested delay before retrying a REFERRED decision.
  google.protobuf.Duration retry_after = 7;
}

Step 3: Generate and distribute client code. Once the schema PR is merged, the CI pipeline generates client code in all target languages and publishes it to the respective package registries. The consuming team can immediately start writing integration code against the generated client.

Step 4: Implement the server. The producing team implements the service, using the generated server stubs as the skeleton. The implementation must satisfy the contract exactly: the correct field types, the correct enum values, and the documented behavior for each RPC.

Step 5: Integration testing. Both teams test against the shared contract. The producing team verifies that their implementation handles all valid requests correctly. The consuming team verifies that their client code works with the real service. Because both sides were built against the same generated code, integration failures are rare.

Mock Generation and Parallel Development

One of the most powerful benefits of contract-first development is parallel implementation. Once the schema is agreed upon, the producing and consuming teams can work simultaneously. The consumer does not need to wait for the server to be built.

This is possible because the schema provides enough information to generate mock servers and test fixtures:

// Generated mock from the proto definition
type MockLendingDecisionServiceServer struct {
    pb.UnimplementedLendingDecisionServiceServer
    EvaluateApplicationFunc func(
        ctx context.Context,
        req *pb.EvaluateApplicationRequest,
    ) (*pb.EvaluateApplicationResponse, error)
}
 
func (m *MockLendingDecisionServiceServer) EvaluateApplication(
    ctx context.Context,
    req *pb.EvaluateApplicationRequest,
) (*pb.EvaluateApplicationResponse, error) {
    if m.EvaluateApplicationFunc != nil {
        return m.EvaluateApplicationFunc(ctx, req)
    }
    return &pb.EvaluateApplicationResponse{
        DecisionId:    "mock-decision-001",
        ApplicationId: req.ApplicationId,
        Decision:      pb.Decision_DECISION_APPROVED,
        ApprovedTerms: &pb.ApprovedTerms{
            ApprovedAmountMinor: req.RequestedAmountMinor,
            CurrencyCode:        req.CurrencyCode,
            TermMonths:           req.TermMonths,
            AprBasisPoints:       1250,
            MonthlyPaymentMinor:  calculateMonthly(req),
        },
        DecidedAt: timestamppb.Now(),
    }, nil
}

The consuming team writes their integration logic and tests against this mock. When the real server is ready, swapping the mock for the real client is a configuration change, not a code change.

Tools like buf curl and gRPC's reflection API further simplify development-time testing by allowing ad-hoc requests against running services without writing any client code.

Schema-Driven Documentation

A well-designed protobuf schema doubles as API documentation. The comments in the .proto file are propagated into generated code and can be extracted into standalone documentation.

The Nebula team uses protoc-gen-doc to generate HTML and Markdown documentation from proto files:

protoc \
  --doc_out=docs \
  --doc_opt=html,index.html \
  proto/nebula/lending/v1/*.proto

The generated documentation includes message structures, field types, enum values, service definitions, and all comments from the proto files. Because the documentation is generated from the same source as the client and server code, it is always accurate and always up to date.

This eliminates the "documentation drift" problem where a hand-maintained API reference gradually diverges from the actual implementation. With contract-first development, there is no separate documentation to maintain. The contract is the documentation.

Organizational Adoption

Contract-first development requires a cultural shift. Developers accustomed to building features end-to-end must learn to pause at the contract boundary, collaborate on the schema, and wait for agreement before proceeding.

Several practices help smooth this transition.

Designate schema reviewers. Each domain area in the Nebula registry has designated reviewers who are responsible for consistency across schemas in their domain. These reviewers catch naming inconsistencies, structural anti-patterns, and missing edge cases that individual teams might overlook.

Run schema design workshops. When a new service is being planned, the Nebula team hosts a short design session where the producing and consuming teams sketch the proto file together on a whiteboard. This collaborative drafting produces better contracts than asynchronous review alone.

Measure contract stability. Track how often schemas change after initial publication. Frequent post-publication changes suggest that the upfront design phase is being shortcut. The goal is not zero changes (requirements do evolve) but a low rate of changes driven by oversights.

Celebrate contract quality. When a schema enables a clean integration with zero surprises, recognize the teams that designed it. Positive reinforcement is more effective than process mandates.

Common Pitfalls

Teams new to contract-first development often make several predictable mistakes.

Over-designing the contract. It is tempting to anticipate every possible future requirement and include fields "just in case." This leads to bloated schemas with fields that are never populated. Design for the current requirements and use schema evolution to add fields as needs emerge.

Under-specifying behavior. The proto schema defines the data contract but not the behavioral contract. Comments should document expected latency, error conditions, idempotency guarantees, and side effects. Without this information, consumers are left guessing.

Skipping the review. Under deadline pressure, teams sometimes merge schema PRs without stakeholder review. This defeats the purpose of contract-first development and usually results in a follow-up breaking change when the consumer discovers the contract does not meet their needs.

Conclusion

Contract-first development with Protocol Buffers transforms API design from an afterthought into a deliberate, collaborative activity. By defining the schema before writing implementation code, teams surface integration issues early, enable parallel development, generate accurate documentation, and build services that compose reliably. The Nebula schema registry provides the infrastructure that makes this workflow practical: a shared repository for contracts, automated code generation, breaking change detection, and distribution to every language and team in the organization. The result is not just better APIs but a better engineering culture, one where clarity and collaboration precede implementation.

Related Articles

technical

Building a Schema Registry: Patterns and Best Practices

A comprehensive guide to building and operating a Protocol Buffers schema registry, covering architecture patterns, governance models, tooling integration, and the operational practices that keep a registry healthy as it scales.

9 min read
business

Using Protocol Buffers Across a Microservices Architecture

A business and architecture-focused guide to adopting Protocol Buffers as the standard contract language across a microservices ecosystem, covering shared types, dependency management, team workflows, and the role of a centralized schema registry.

10 min read
business

API Versioning Strategies with Protocol Buffers

A business-oriented guide to API versioning with Protocol Buffers, covering when and how to version, migration strategies, multi-version support, and the organizational processes that make versioning sustainable.

9 min read