API Design & Microservices Patterns
gRPC and GraphQL
REST is not the only way to build APIs. gRPC dominates internal microservice communication, while GraphQL excels at flexible client-driven queries. Knowing when to use each is a key interview differentiator.
Protocol Buffers (Protobuf)
gRPC uses Protocol Buffers as its interface definition language and serialization format. Protobuf is a binary format — smaller and faster to parse than JSON.
// user.proto — Proto3 syntax
syntax = "proto3";
package userservice;
// Message definitions with numbered fields
message User {
string id = 1; // Field number 1 — never reuse after deletion
string name = 2;
string email = 3;
UserRole role = 4;
repeated Address addresses = 5; // repeated = list/array
optional string phone = 6; // optional field (proto3)
}
enum UserRole {
USER_ROLE_UNSPECIFIED = 0; // Proto3 requires 0 as default
USER_ROLE_ADMIN = 1;
USER_ROLE_MEMBER = 2;
}
message Address {
string street = 1;
string city = 2;
string country = 3;
}
Backward Compatibility Rules
- Never change the field number of an existing field
- Never reuse a deleted field number — use
reservedinstead - Adding new fields is safe — old clients ignore unknown fields
- Removing fields is safe — new clients see default values for missing fields
- Renaming fields is safe — only the field number matters on the wire
// Reserving deleted field numbers to prevent accidental reuse
message User {
reserved 7, 8; // These field numbers are retired
reserved "legacy_username"; // This field name is retired
string id = 1;
string name = 2;
}
gRPC Service Definitions
gRPC supports four communication patterns, each suited for different use cases.
// service.proto
syntax = "proto3";
package orderservice;
service OrderService {
// 1. Unary: simple request-response (like REST)
rpc GetOrder(GetOrderRequest) returns (Order);
// 2. Server streaming: server sends multiple responses
rpc WatchOrderStatus(WatchRequest) returns (stream OrderStatus);
// 3. Client streaming: client sends multiple requests
rpc UploadOrderDocuments(stream Document) returns (UploadSummary);
// 4. Bidirectional streaming: both sides send streams
rpc OrderChat(stream ChatMessage) returns (stream ChatMessage);
}
message GetOrderRequest {
string order_id = 1;
}
message Order {
string id = 1;
string customer_id = 2;
repeated OrderItem items = 3;
OrderStatus status = 4;
double total = 5;
}
message OrderItem {
string product_id = 1;
int32 quantity = 2;
double price = 3;
}
message OrderStatus {
string order_id = 1;
string status = 2;
string timestamp = 3;
}
message WatchRequest {
string order_id = 1;
}
message Document {
string filename = 1;
bytes content = 2;
}
message UploadSummary {
int32 files_received = 1;
int64 total_bytes = 2;
}
message ChatMessage {
string sender = 1;
string content = 2;
string timestamp = 3;
}
The Four Patterns Explained
| Pattern | Use Case | Example |
|---|---|---|
| Unary | Standard request-response | Fetch an order by ID |
| Server Streaming | Server pushes multiple results | Stock price ticker, live order status updates |
| Client Streaming | Client sends data in chunks | File upload, batch data ingestion |
| Bidirectional Streaming | Real-time two-way communication | Chat, collaborative editing, gaming |
Implementing a gRPC Server (Go Example)
// server.go — Unary and server streaming implementation
package main
import (
"context"
"log"
"time"
pb "myapp/proto/orderservice"
)
type orderServer struct {
pb.UnimplementedOrderServiceServer
}
// Unary RPC
func (s *orderServer) GetOrder(ctx context.Context, req *pb.GetOrderRequest) (*pb.Order, error) {
// Fetch order from database
order, err := db.FindOrder(req.OrderId)
if err != nil {
return nil, status.Errorf(codes.NotFound, "order %s not found", req.OrderId)
}
return order, nil
}
// Server streaming RPC
func (s *orderServer) WatchOrderStatus(req *pb.WatchRequest, stream pb.OrderService_WatchOrderStatusServer) error {
orderID := req.OrderId
for {
status, err := db.GetOrderStatus(orderID)
if err != nil {
return err
}
if err := stream.Send(status); err != nil {
return err
}
if status.Status == "delivered" {
return nil // Stream complete
}
time.Sleep(2 * time.Second) // Poll interval
}
}
HTTP/2 Benefits
gRPC runs on HTTP/2, which provides significant performance advantages over HTTP/1.1.
| Feature | HTTP/1.1 | HTTP/2 |
|---|---|---|
| Multiplexing | One request per connection (or pipelining with head-of-line blocking) | Multiple concurrent streams on one connection |
| Headers | Sent as plain text every request | Compressed with HPACK, only deltas sent |
| Format | Text-based | Binary framing layer |
| Server Push | Not supported | Server can push resources proactively |
| Connection | Multiple TCP connections needed | Single TCP connection for all streams |
gRPC vs REST Performance
| Aspect | REST (JSON/HTTP 1.1) | gRPC (Protobuf/HTTP 2) |
|---|---|---|
| Payload size | ~2-10x larger (text JSON) | Compact binary encoding |
| Serialization speed | Slower (text parsing) | ~5-10x faster (binary) |
| Streaming | Requires WebSocket or SSE | Native bidirectional streaming |
| Browser support | Universal | Requires gRPC-Web proxy |
| Tooling | curl, Postman, any HTTP client | Needs protoc compiler, gRPC tools |
| Contract | OpenAPI/Swagger (optional) | .proto files (required, strongly typed) |
| Code generation | Optional | Built-in (Go, Java, Python, etc.) |
GraphQL
GraphQL lets clients request exactly the data they need — no more, no less. It uses a single endpoint and a type system to define what queries are possible.
Schema Definition
// schema.graphql
type User {
id: ID!
name: String!
email: String!
orders(status: OrderStatus, first: Int): [Order!]!
}
type Order {
id: ID!
total: Float!
status: OrderStatus!
items: [OrderItem!]!
createdAt: String!
}
type OrderItem {
product: Product!
quantity: Int!
price: Float!
}
type Product {
id: ID!
name: String!
price: Float!
}
enum OrderStatus {
PENDING
PROCESSING
SHIPPED
DELIVERED
CANCELLED
}
type Query {
user(id: ID!): User
users(first: Int, after: String): UserConnection!
order(id: ID!): Order
}
type Mutation {
createOrder(input: CreateOrderInput!): Order!
cancelOrder(id: ID!): Order!
}
type Subscription {
orderStatusChanged(orderId: ID!): Order!
}
input CreateOrderInput {
userId: ID!
items: [OrderItemInput!]!
}
input OrderItemInput {
productId: ID!
quantity: Int!
}
# Relay-style pagination
type UserConnection {
edges: [UserEdge!]!
pageInfo: PageInfo!
}
type UserEdge {
node: User!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
endCursor: String
}
The N+1 Problem and DataLoader
The most critical GraphQL performance issue. Without DataLoader, fetching a list of orders with their products makes 1 query for orders + N queries for products.
// WITHOUT DataLoader: N+1 queries
// Query: { users { orders { items { product { name } } } } }
// 1 query for users
// N queries for each user's orders
// M queries for each order's items
// K queries for each item's product
// WITH DataLoader: batched queries
import DataLoader from 'dataloader';
// DataLoader batches individual loads into a single query
const productLoader = new DataLoader<string, Product>(async (productIds) => {
// One query: SELECT * FROM products WHERE id IN (id1, id2, id3, ...)
const products = await db.products.findMany({
where: { id: { in: productIds as string[] } }
});
// Return in the same order as the input IDs
const productMap = new Map(products.map(p => [p.id, p]));
return productIds.map(id => productMap.get(id) || new Error(`Product ${id} not found`));
});
// Resolver uses the loader
const resolvers = {
OrderItem: {
product: (item: OrderItem) => productLoader.load(item.productId)
// DataLoader automatically batches all loads within the same tick
}
};
When to Use Each: Decision Matrix
| Criterion | REST | gRPC | GraphQL |
|---|---|---|---|
| Best for | Public APIs, simple CRUD | Internal microservices, high-performance | Flexible client queries, mobile apps |
| Client type | Any HTTP client | Generated clients from .proto | Web/mobile with varying data needs |
| Data shape | Fixed by server | Fixed by .proto contract | Chosen by client per query |
| Performance | Good | Excellent (binary, HTTP/2) | Good (but resolver overhead) |
| Streaming | SSE or WebSocket (extra) | Native, bidirectional | Subscriptions (WebSocket-based) |
| Browser support | Universal | Requires proxy (gRPC-Web) | Universal |
| Learning curve | Low | Medium (proto, code gen) | Medium-High (schema, resolvers, caching) |
| API evolution | Versioning needed | Built-in backward compatibility | Schema evolution (deprecation) |
| Caching | HTTP caching (GET) | Custom (no HTTP caching) | Complex (query-dependent) |
| Error handling | HTTP status codes | gRPC status codes | Always 200, errors in response body |
| Contract | OpenAPI (optional) | .proto (required) | Schema (required) |
Quick Interview Answer
"I'd use REST for a public-facing API where simplicity and broad client support matter. For internal microservice communication, I'd choose gRPC because of its binary encoding, HTTP/2 multiplexing, and native streaming — latency between services is critical. For a mobile or frontend-heavy application with diverse data requirements, I'd put GraphQL as an aggregation layer on top of the microservices, so clients can request exactly what they need in one round trip."
Next: We'll dive into microservices architecture patterns — sagas, circuit breakers, CQRS, and event sourcing. :::