gRPC and Protocol Buffers give you a strongly-typed, versioned API contract that generates client and server code in any language. The schema is the contract — both sides of the API know exactly what fields exist, what types they are, and what’s optional. Claude Code designs proto schemas correctly, implements service handlers, and understands where gRPC outperforms REST (bidirectional streaming, generated clients, tight performance requirements).
Proto Schema Design
CLAUDE.md for gRPC Projects
## gRPC/Protobuf Configuration
- protoc 25+, protoc-gen-go 1.33, protoc-gen-go-grpc 1.4
- Proto files: proto/ directory, organized by service
- Generated code: gen/ directory (committed to repo)
- Style: Google API Design Guide — camelCase fields, snake_case in proto
- Versioning: v1/v2 packages in proto paths (proto/orders/v1/orders.proto)
- Validation: protoc-gen-validate for request validation
- Connect protocol also supported (grpc-web-compatible via connectrpc)
// proto/orders/v1/orders.proto
syntax = "proto3";
package orders.v1;
option go_package = "github.com/mycompany/proto/gen/orders/v1;ordersv1";
import "google/protobuf/timestamp.proto";
import "google/protobuf/field_mask.proto";
import "validate/validate.proto";
// Order status enum — exhaustive, with UNSPECIFIED as zero value
enum OrderStatus {
ORDER_STATUS_UNSPECIFIED = 0;
ORDER_STATUS_PENDING = 1;
ORDER_STATUS_CONFIRMED = 2;
ORDER_STATUS_SHIPPED = 3;
ORDER_STATUS_DELIVERED = 4;
ORDER_STATUS_CANCELLED = 5;
}
message Order {
string order_id = 1; // UUID
string customer_id = 2; // FK to customer service
repeated OrderItem items = 3;
OrderStatus status = 4;
int64 total_cents = 5; // Avoid floats for money
google.protobuf.Timestamp created_at = 6;
google.protobuf.Timestamp updated_at = 7;
// Optional: only set when shipped
optional string tracking_number = 8;
}
message OrderItem {
string product_id = 1;
string product_name = 2;
int32 quantity = 3 [(validate.rules).int32 = {gt: 0, lte: 1000}];
int64 unit_price_cents = 4;
}
// Request/Response messages — one per RPC, never reuse
message CreateOrderRequest {
string customer_id = 1 [(validate.rules).string = {uuid: true}];
repeated CreateOrderItem items = 2 [(validate.rules).repeated = {min_items: 1, max_items: 50}];
string payment_method_id = 3 [(validate.rules).string = {min_len: 1}];
}
message CreateOrderItem {
string product_id = 1 [(validate.rules).string = {uuid: true}];
int32 quantity = 2 [(validate.rules).int32 = {gt: 0, lte: 100}];
}
message CreateOrderResponse {
Order order = 1;
}
message GetOrderRequest {
string order_id = 1 [(validate.rules).string = {uuid: true}];
// Field mask: client specifies which fields to return
google.protobuf.FieldMask field_mask = 2;
}
message GetOrderResponse {
Order order = 1;
}
message ListOrdersRequest {
string customer_id = 1;
OrderStatus status_filter = 2;
int32 page_size = 3 [(validate.rules).int32 = {gte: 1, lte: 100}];
string page_token = 4;
}
message ListOrdersResponse {
repeated Order orders = 1;
string next_page_token = 2; // Empty = no more pages
}
// Streaming: server pushes order status updates to client
message WatchOrderRequest {
string order_id = 1 [(validate.rules).string = {uuid: true}];
}
message OrderStatusUpdate {
string order_id = 1;
OrderStatus old_status = 2;
OrderStatus new_status = 3;
google.protobuf.Timestamp occurred_at = 4;
}
service OrderService {
rpc CreateOrder(CreateOrderRequest) returns (CreateOrderResponse);
rpc GetOrder(GetOrderRequest) returns (GetOrderResponse);
rpc ListOrders(ListOrdersRequest) returns (ListOrdersResponse);
// Server-streaming: client subscribes, server pushes updates
rpc WatchOrder(WatchOrderRequest) returns (stream OrderStatusUpdate);
// Client-streaming: upload batch of orders at once
rpc ImportOrders(stream CreateOrderRequest) returns (ImportOrdersResponse);
}
message ImportOrdersResponse {
int32 imported_count = 1;
int32 failed_count = 2;
repeated ImportError errors = 3;
}
message ImportError {
int32 index = 1; // Which request failed
string message = 2;
}
Go Service Implementation
Implement the OrderService gRPC server in Go.
Use the generated types from the proto.
// internal/grpc/orders_server.go
package grpc
import (
"context"
"io"
"sync"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/timestamppb"
ordersv1 "github.com/mycompany/proto/gen/orders/v1"
"github.com/mycompany/order-service/internal/repository"
"github.com/mycompany/order-service/internal/service"
)
type OrderServer struct {
ordersv1.UnimplementedOrderServiceServer // Forward-compatible: callers of unimplemented methods get UNIMPLEMENTED error
orderRepo *repository.OrderRepository
orderSvc *service.OrderService
// Watchers: order_id → list of channels awaiting updates
mu sync.RWMutex
watchers map[string][]chan *ordersv1.OrderStatusUpdate
}
func (s *OrderServer) CreateOrder(ctx context.Context, req *ordersv1.CreateOrderRequest) (*ordersv1.CreateOrderResponse, error) {
// Validation is handled by the validate interceptor from protoc-gen-validate
order, err := s.orderSvc.CreateOrder(ctx, service.CreateOrderInput{
CustomerID: req.CustomerId,
Items: convertItems(req.Items),
PaymentMethodID: req.PaymentMethodId,
})
if err != nil {
// Map domain errors to gRPC status codes
switch err {
case service.ErrCustomerNotFound:
return nil, status.Errorf(codes.NotFound, "customer %s not found", req.CustomerId)
case service.ErrInsufficientInventory:
return nil, status.Errorf(codes.FailedPrecondition, "insufficient inventory")
case service.ErrPaymentDeclined:
// Include structured error details
st := status.New(codes.FailedPrecondition, "payment declined")
return nil, st.Err()
default:
return nil, status.Errorf(codes.Internal, "internal error creating order")
}
}
return &ordersv1.CreateOrderResponse{Order: orderToProto(order)}, nil
}
// Server-streaming RPC: push status updates to client
func (s *OrderServer) WatchOrder(req *ordersv1.WatchOrderRequest, stream ordersv1.OrderService_WatchOrderServer) error {
orderID := req.OrderId
// Verify order exists and caller has access
if _, err := s.orderRepo.Get(stream.Context(), orderID); err != nil {
return status.Errorf(codes.NotFound, "order %s not found", orderID)
}
// Register this stream as a watcher
ch := make(chan *ordersv1.OrderStatusUpdate, 10)
s.mu.Lock()
s.watchers[orderID] = append(s.watchers[orderID], ch)
s.mu.Unlock()
defer func() {
// Deregister on stream close
s.mu.Lock()
watchers := s.watchers[orderID]
for i, w := range watchers {
if w == ch {
s.watchers[orderID] = append(watchers[:i], watchers[i+1:]...)
break
}
}
s.mu.Unlock()
close(ch)
}()
for {
select {
case update, ok := <-ch:
if !ok {
return nil
}
if err := stream.Send(update); err != nil {
return err // Client disconnected
}
case <-stream.Context().Done():
return stream.Context().Err()
}
}
}
// Called by order service when status changes
func (s *OrderServer) NotifyStatusChange(orderID string, oldStatus, newStatus ordersv1.OrderStatus) {
s.mu.RLock()
watchers := s.watchers[orderID]
s.mu.RUnlock()
update := &ordersv1.OrderStatusUpdate{
OrderId: orderID,
OldStatus: oldStatus,
NewStatus: newStatus,
OccurredAt: timestamppb.Now(),
}
for _, ch := range watchers {
select {
case ch <- update:
default:
// Channel full — drop update (client is too slow)
}
}
}
// Client-streaming RPC: accept batch of orders
func (s *OrderServer) ImportOrders(stream ordersv1.OrderService_ImportOrdersServer) error {
var imported, failed int32
var errors []*ordersv1.ImportError
var index int32
for {
req, err := stream.Recv()
if err == io.EOF {
break
}
if err != nil {
return status.Errorf(codes.Internal, "failed to receive: %v", err)
}
_, createErr := s.orderSvc.CreateOrder(stream.Context(), service.CreateOrderInput{
CustomerID: req.CustomerId,
Items: convertItems(req.Items),
})
if createErr != nil {
failed++
errors = append(errors, &ordersv1.ImportError{
Index: index,
Message: createErr.Error(),
})
} else {
imported++
}
index++
}
return stream.SendAndClose(&ordersv1.ImportOrdersResponse{
ImportedCount: imported,
FailedCount: failed,
Errors: errors,
})
}
gRPC Interceptors
Add request logging, metrics, and auth validation
to all gRPC endpoints without touching each handler.
// internal/grpc/interceptors.go
// Unary interceptor (for non-streaming RPCs)
func LoggingUnaryInterceptor(logger *zap.Logger) grpc.UnaryServerInterceptor {
return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
start := time.Now()
resp, err := handler(ctx, req)
duration := time.Since(start)
code := status.Code(err)
logger.Info("gRPC request",
zap.String("method", info.FullMethod),
zap.Duration("duration", duration),
zap.String("code", code.String()),
zap.Error(err),
)
// Record Prometheus metrics
grpcRequestDuration.WithLabelValues(info.FullMethod, code.String()).Observe(duration.Seconds())
return resp, err
}
}
// Auth interceptor — validates JWT from metadata
func AuthUnaryInterceptor(verifier *jwt.Verifier) grpc.UnaryServerInterceptor {
return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
// Skip auth for reflection and health check
if info.FullMethod == "/grpc.reflection.v1alpha.ServerReflection/ServerReflectionInfo" {
return handler(ctx, req)
}
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, status.Error(codes.Unauthenticated, "missing metadata")
}
authHeaders := md.Get("authorization")
if len(authHeaders) == 0 {
return nil, status.Error(codes.Unauthenticated, "missing authorization header")
}
token := strings.TrimPrefix(authHeaders[0], "Bearer ")
claims, err := verifier.Verify(token)
if err != nil {
return nil, status.Errorf(codes.Unauthenticated, "invalid token: %v", err)
}
// Add claims to context for handlers
ctx = context.WithValue(ctx, ctxKeyUserID, claims.Subject)
return handler(ctx, req)
}
}
TypeScript gRPC Client
Generate a TypeScript client for the OrderService.
Use Connect RPC for browser compatibility.
// src/lib/ordersClient.ts
import { createClient } from '@connectrpc/connect';
import { createConnectTransport } from '@connectrpc/connect-node';
import { OrderService } from './gen/orders/v1/orders_connect';
import { OrderStatus_Value } from './gen/orders/v1/orders_pb';
const transport = createConnectTransport({
baseUrl: process.env.ORDER_SERVICE_URL ?? 'https://orders.internal:8443',
httpVersion: '2',
});
export const ordersClient = createClient(OrderService, transport);
// Usage
async function createOrder(customerId: string, items: OrderItem[]) {
const response = await ordersClient.createOrder({
customerId,
items: items.map(i => ({ productId: i.productId, quantity: i.quantity })),
paymentMethodId: 'pm_xxx',
});
return response.order;
}
// Server-streaming subscription
async function watchOrder(orderId: string, onUpdate: (status: string) => void) {
const stream = ordersClient.watchOrder({ orderId });
for await (const update of stream) {
onUpdate(OrderStatus_Value[update.newStatus]);
}
}
For the microservices architecture that gRPC services typically form part of, see the Go microservices guide. For generating TypeScript client code from OpenAPI instead of Protobuf, the OpenAPI codegen guide covers that workflow. The Claude Skills 360 bundle includes gRPC skill sets for proto design, service implementation in multiple languages, and interceptor patterns. Start with the free tier to try proto schema generation.