Designing Microservice Communication: REST, gRPC, and Event-Driven Patterns With Python, Go, and TypeScript Examples
Choosing how your microservices talk to each other is one of the most consequential architectural decisions you will make. Get it wrong and you end up with tight coupling, cascading failures, and a debugging nightmare that spans a dozen log streams. Get it right and each service becomes an independently deployable unit that can evolve at its own pace. This article walks through three communication patterns — synchronous REST, binary RPC with gRPC, and asynchronous event-driven messaging — with concrete code examples in Python, Go, and TypeScript.
Synchronous REST: The Familiar Default
REST APIs remain the most common integration surface between services, largely because every runtime has excellent HTTP libraries and the mental model is well understood. The tradeoff is that the caller blocks until the callee responds, which means latency compounds across service chains and a slow downstream dependency can hold threads hostage.
A Go service exposing a simple order-lookup endpoint might look like this:
package main
import (
"encoding/json"
"net/http"
)
type Order struct {
ID string `json:"id"`
Status string `json:"status"`
}
func orderHandler(w http.ResponseWriter, r *http.Request) {
id := r.URL.Query().Get("id")
order := Order{ID: id, Status: "shipped"}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(order)
}
func main() {
http.HandleFunc("/orders", orderHandler)
http.ListenAndServe(":8080", nil)
}
A TypeScript client calling that endpoint with fetch keeps things equally straightforward:
async function fetchOrder(id: string): Promise<{ id: string; status: string }> {
const response = await fetch(`http://order-service:8080/orders?id=${id}`);
if (!response.ok) throw new Error(`HTTP error: ${response.status}`);
return response.json();
}
When writing integration tests against REST endpoints, spin up the service inside a Docker network alongside your test runner. This keeps tests isolated without mocking the HTTP layer, giving you confidence that serialization and routing behave exactly as they will in production.
gRPC: Contracts First, Performance Second
gRPC uses Protocol Buffers to define a strict contract before a single line of implementation is written. The payoff is smaller payloads, built-in streaming, and generated client code that enforces the contract at compile time — a genuine advantage when a single team owns multiple services in different languages.
Define a minimal service in a .proto file:
syntax = "proto3";
package inventory;
service Inventory {
rpc GetStock (StockRequest) returns (StockResponse);
}
message StockRequest { string sku = 1; }
message StockResponse { string sku = 1; int32 quantity = 2; }
A Python server implementation using the generated stubs:
import grpc
from concurrent import futures
import inventory_pb2
import inventory_pb2_grpc
class InventoryServicer(inventory_pb2_grpc.InventoryServicer):
def GetStock(self, request, context):
# Replace with real DB lookup
return inventory_pb2.StockResponse(sku=request.sku, quantity=42)
def serve():
server = grpc.server(futures.ThreadPoolExecutor(max_workers=4))
inventory_pb2_grpc.add_InventoryServicer_to_server(InventoryServicer(), server)
server.add_insecure_port("[::]:50051")
server.start()
server.wait_for_termination()
if __name__ == "__main__":
serve()
One design pattern worth adopting early is server reflection. Enabling it lets generic tooling inspect your services at runtime without distributing proto files out-of-band, which simplifies onboarding for teams that consume your APIs.
Testing gRPC services follows the same philosophy as REST: prefer real network calls inside a controlled environment. Use in-process servers for unit tests — the gRPC libraries for Python, Go, and TypeScript all support starting a server on a random port — and reserve Docker-composed stacks for integration-level scenarios.
Event-Driven Messaging: Decoupling Through Asynchrony
When a service needs to broadcast that something happened — an order was placed, a payment was confirmed — without caring which downstream services react, event-driven communication is the right tool. Producers publish to a topic; consumers subscribe independently. Neither side needs to know the other exists.
A TypeScript producer publishing to a message broker might look like this (using a hypothetical broker client that mirrors common library APIs):
import { BrokerClient } from "./broker-client";
interface OrderPlacedEvent {
orderId: string;
customerId: string;
totalCents: number;
occurredAt: string;
}
async function publishOrderPlaced(event: OrderPlacedEvent): Promise {
const client = new BrokerClient({ brokers: ["broker:9092"] });
await client.connect();
await client.publish("order.placed", JSON.stringify(event));
await client.disconnect();
}
A Python consumer on the other side processes those events independently:
from broker_client import BrokerConsumer
import json
def handle_order_placed(raw_message: str) -> None:
event = json.loads(raw_message)
print(f"Fulfilling order {event['orderId']} for customer {event['customerId']}")
consumer = BrokerConsumer(brokers=["broker:9092"], topic="order.placed", group="fulfillment")
for message in consumer:
handle_order_placed(message)
The most common pitfall in event-driven design is treating events as commands. An event describes a fact that already happened; it should never carry instructions. If your order.placed payload includes a field called notifyWarehouse: true, you have a command disguised as an event, and you have re-introduced coupling through the message envelope.
Choosing the Right Pattern
No single communication style wins universally. Use synchronous REST when you need an immediate response and the call chain is shallow. Reach for gRPC when contract safety and performance matter more than human-readable payloads. Adopt event-driven messaging when services should evolve independently and eventual consistency is acceptable.
The most resilient architectures combine all three: a public-facing REST API gateway, gRPC for internal service-to-service calls that require low latency, and a message bus for cross-domain side effects. Start with the pattern that solves your current problem cleanly, write tests that exercise real network behavior, and resist the urge to standardize on one approach before you understand your actual load and coupling requirements.