REST and JSON have been the lingua franca of web APIs for decades. They are easy to read in a browser console, human-readable, and supported by every programming language on earth.
However, when microservices inside a private cluster make thousands of internal requests per second, the overhead of JSON serialization, string parsing, and verbose HTTP/1.1 headers begins to take a measurable toll on CPU and latency.
gRPC changes this by running over binary HTTP/2 with Protocol Buffers (Proto3). Payloads are serialized into compact binary streams, connection multiplexing eliminates TCP handshake overhead, and contracts are enforced by strict .proto definitions.
A few weeks ago, Amir and I refactored an internal billing and reporting pipeline from REST to gRPC. Payloads dropped by 72% in wire size, and inter-service latency dropped from 85 milliseconds down to 12 milliseconds.
Here is the exact architecture we used to write clean Proto3 definitions, enforce Buf protovalidate constraints, and prevent backward compatibility breaks.
Why File Placement and Package Structure Matter in Protobuf
When teams start with Protocol Buffers, they often dump .proto files into a single flat directory.
Under the modern Buf tooling standard, package naming and directory paths must align strictly. If your schema declares package billing.v1;, the file must live inside a directory structure like proto/billing/v1/billing_service.proto.
Failing to follow this convention breaks automated code generators and module resolution across different repositories.
Writing Validated Proto3 Contracts with protovalidate
Historically, developers had to write manual validation code in every service to check whether strings were empty, emails were valid, or numbers were positive.
Modern Proto3 integrates with Buf's protovalidate standard, embedding validation rules directly into the .proto contract:
// proto/billing/v1/billing_service.proto
syntax = "proto3";
package billing.v1;
import "buf/validate/validate.proto";
// Service definition for processing client invoices
service BillingService {
rpc ProcessInvoice (ProcessInvoiceRequest) returns (ProcessInvoiceResponse);
rpc StreamTransactions (StreamTransactionsRequest) returns (stream TransactionRecord);
}
message ProcessInvoiceRequest {
// UUID validation rule
string invoice_id = 1 [(buf.validate.field).string.uuid = true];
// Must be a valid email format
string customer_email = 2 [(buf.validate.field).string.email = true];
// Price must be strictly greater than zero cents
int64 amount_cents = 3 [(buf.validate.field).int64.gt = 0];
// ISO currency code (exactly 3 uppercase letters)
string currency = 4 [(buf.validate.field).string = { min_len: 3, max_len: 3 }];
}
message ProcessInvoiceResponse {
string transaction_id = 1;
string status = 2;
int64 processed_at_unix = 3;
}
message StreamTransactionsRequest {
string organization_id = 1 [(buf.validate.field).string.uuid = true];
}
message TransactionRecord {
string transaction_id = 1;
int64 amount_cents = 2;
string status = 3;
}Because validation rules are defined in the schema itself, generated TypeScript and Go stubs enforce input boundaries before business logic ever executes.
Preventing Breaking Changes with Reserved Fields
In Protocol Buffers, fields are identified by their numeric tag numbers (e.g., = 1, = 2), not by their variable names.
If an engineer deletes an obsolete field (like discount_code = 5) and another developer later reuses tag 5 for a new property, older clients reading the message will deserialize the data into the wrong type. This can cause severe silent data corruption.
When deprecating fields, always mark both the field name and its numeric tag as reserved:
message UserAccount {
// Never reuse deleted tags or names
reserved 4, 7, 12 to 15;
reserved "legacy_token", "deprecated_tax_id";
string user_id = 1;
string display_name = 2;
string primary_email = 3;
}The Protobuf compiler will now reject any pull request attempting to reuse tag 4 or 7, preserving backward compatibility across multiple deployed client versions.
Implementing the gRPC Service in TypeScript
Using @connectrpc/connect or @grpc/grpc-js, implementing the strongly-typed service becomes concise:
// services/billing-handler.ts
import { ServiceImpl } from '@connectrpc/connect';
import { BillingService } from '../gen/billing/v1/billing_service_connect';
export const billingServiceImpl: ServiceImpl<typeof BillingService> = {
async processInvoice(req) {
// Validation is already guaranteed by protovalidate middleware
const result = await recordPayment({
id: req.invoiceId,
email: req.customerEmail,
cents: req.amountCents,
});
return {
transactionId: result.transactionId,
status: 'PAID',
processedAtUnix: BigInt(Date.now()),
};
},
async *streamTransactions(req) {
const transactions = await getRecentTransactions(req.organizationId);
for (const tx of transactions) {
yield {
transactionId: tx.id,
amountCents: tx.amountCents,
status: tx.status,
};
}
},
};Production Rules for gRPC Topologies
- Use HTTP/2 connection pooling: Keep a persistent gRPC channel open between microservices rather than establishing new connections per RPC call.
- Set explicit deadlines: Every gRPC client call should define a timeout deadline to prevent resource exhaustion during backend delays.
- Automate linting with Buf: Run
buf lintandbuf breaking --againstin your CI pipeline to catch backward-incompatible changes before code is merged.
Comments
Comments are reviewed before appearing publicly.
No comments yet — be the first.