Microservices Communication Patterns

Philip Rehberger Dec 15, 2025 10 min read

Design effective communication between microservices. Compare synchronous and asynchronous patterns, API gateways, and service meshes.

Microservices Communication Patterns

Microservices architecture distributes functionality across independent services. How these services communicate determines system reliability, performance, and complexity. This guide covers communication patterns from synchronous REST calls to asynchronous event-driven architectures.

Communication Styles

Synchronous vs Asynchronous

The fundamental choice in service communication is whether the caller waits for a response. Understanding this distinction helps you make informed architectural decisions.

Synchronous: Caller waits for response

Order Service → [HTTP Request] → Payment Service
              ← [HTTP Response] ←

Asynchronous: Caller doesn't wait

Order Service → [Message Queue] → Payment Service
              (continues immediately)

Synchronous communication is simpler but creates temporal coupling. If the payment service is slow or down, the order service blocks. Asynchronous patterns decouple services but require handling eventual consistency.

One-to-One vs One-to-Many

One-to-One: Single sender, single receiver One-to-Many: Single sender, multiple receivers (pub/sub)

Choose based on whether multiple services need to react to the same event.

Synchronous Patterns

REST APIs

REST is the most common pattern for service communication. Each service exposes HTTP endpoints that others can call. When you need straightforward request-response interactions between services, REST provides a familiar and well-tooled approach.

The following example shows a payment client that calls an external payment service. You'll notice it includes timeout and retry configuration to handle network unreliability gracefully.

// Order Service calling Payment Service
class PaymentClient
{
    public function charge(Order $order): PaymentResult
    {
        $response = Http::timeout(5)
            ->retry(3, 100)
            ->post('http://payment-service/api/charges', [
                'order_id' => $order->id,
                'amount' => $order->total,
                'currency' => $order->currency,
            ]);

        if ($response->failed()) {
            throw new PaymentFailedException($response->body());
        }

        return PaymentResult::fromResponse($response->json());
    }
}

Notice the timeout and retry configuration. Without these, a hung service could cascade failures throughout your system. The 5-second timeout prevents indefinite blocking, while retries handle transient network issues.

Pros:

  • Simple, well-understood
  • Stateless
  • Easy debugging

Cons:

  • Tight coupling
  • Cascading failures
  • Latency accumulation

gRPC

For internal service communication where performance matters, gRPC offers significant advantages. It uses Protocol Buffers for efficient binary serialization and HTTP/2 for multiplexed connections.

First, define your service contract in a proto file. This file becomes the single source of truth for your API contract, and you can generate client and server code from it.

// payment.proto
service PaymentService {
    rpc Charge(ChargeRequest) returns (ChargeResponse);
}

message ChargeRequest {
    string order_id = 1;
    int64 amount_cents = 2;
    string currency = 3;
}

message ChargeResponse {
    string transaction_id = 1;
    string status = 2;
}

The proto file generates client and server code in any supported language, ensuring type safety across service boundaries.

Once you have your generated code, calling the service is straightforward. Here's how you'd use the generated PHP client to charge a payment.

// PHP gRPC client
$client = new PaymentServiceClient('payment-service:50051', [
    'credentials' => ChannelCredentials::createInsecure(),
]);

$request = new ChargeRequest();
$request->setOrderId($order->id);
$request->setAmountCents($order->total_cents);

[$response, $status] = $client->Charge($request)->wait();

Pros:

  • High performance (binary, HTTP/2)
  • Strong typing with code generation
  • Bi-directional streaming

Cons:

  • More complex setup
  • Harder to debug
  • Limited browser support

Service Discovery

In dynamic environments where services scale up and down, hardcoded URLs don't work. Service discovery provides a registry where services announce their availability.

This example demonstrates using Consul as a service registry. When you need to call another service, you query the registry to get current, healthy instances rather than relying on static configuration.

// Using Consul for service discovery
class ServiceRegistry
{
    public function getServiceUrl(string $service): string
    {
        $response = Http::get("http://consul:8500/v1/catalog/service/{$service}");
        $instances = $response->json();

        // Simple round-robin
        $instance = $instances[array_rand($instances)];
        return "http://{$instance['ServiceAddress']}:{$instance['ServicePort']}";
    }
}

// Usage
$paymentUrl = $registry->getServiceUrl('payment-service');

Services register themselves on startup and deregister on shutdown. The registry provides health checking to remove unhealthy instances.

Circuit Breaker

When a service fails repeatedly, continuing to call it wastes resources and slows down the caller. A circuit breaker stops calls to failing services, allowing them time to recover.

The circuit breaker pattern is essential for building resilient distributed systems. You can implement it yourself or use a library, but understanding the mechanics helps you configure it properly.

class CircuitBreaker
{
    private int $failures = 0;
    private int $threshold = 5;
    private ?Carbon $openedAt = null;
    private int $timeout = 30;

    public function call(callable $operation): mixed
    {
        if ($this->isOpen()) {
            throw new CircuitOpenException();
        }

        try {
            $result = $operation();
            $this->recordSuccess();
            return $result;
        } catch (Exception $e) {
            $this->recordFailure();
            throw $e;
        }
    }

    private function isOpen(): bool
    {
        if ($this->failures < $this->threshold) {
            return false;
        }

        if ($this->openedAt->addSeconds($this->timeout)->isPast()) {
            // Half-open: allow one attempt
            return false;
        }

        return true;
    }
}

The circuit has three states: closed (normal operation), open (failing fast), and half-open (testing if the service recovered). This prevents cascading failures and gives downstream services breathing room.

Asynchronous Patterns

Message Queues

Message queues decouple services by introducing a buffer between them. The sender publishes a message and continues immediately, while consumers process messages at their own pace.

Use message queues when you don't need an immediate response, or when the receiving service might be temporarily unavailable. This pattern significantly improves system resilience.

// Order Service publishes event
class OrderService
{
    public function create(array $data): Order
    {
        $order = Order::create($data);

        // Publish to queue instead of calling payment service
        OrderCreated::dispatch($order);

        return $order;
    }
}

// Payment Service consumes event
class ProcessOrderPayment implements ShouldQueue
{
    public function handle(OrderCreated $event): void
    {
        $order = $event->order;
        $this->paymentGateway->charge($order);

        // Publish result
        PaymentProcessed::dispatch($order, $result);
    }
}

If the payment service is temporarily unavailable, messages queue up rather than causing failures. The system eventually catches up when the service recovers.

Queue Technologies:

  • RabbitMQ: Feature-rich, routing, persistence
  • Amazon SQS: Managed, scalable, simple
  • Redis: Fast, simple, good for Laravel
  • Apache Kafka: High-throughput, event streaming

Event-Driven Architecture

In event-driven systems, services publish events about what happened, and interested services subscribe to react. This inverts the dependency direction.

The key insight is that the publisher doesn't know or care about subscribers. You can add new functionality by creating new subscribers without touching existing code.

// Events published to message broker
class OrderService
{
    public function create(array $data): Order
    {
        $order = Order::create($data);

        $this->eventBus->publish('orders', new OrderCreated([
            'order_id' => $order->id,
            'user_id' => $order->user_id,
            'total' => $order->total,
            'items' => $order->items->toArray(),
        ]));

        return $order;
    }
}

// Multiple services subscribe to same event
// Payment Service
$eventBus->subscribe('orders', 'OrderCreated', function ($event) {
    $this->chargeCustomer($event->order_id, $event->total);
});

// Inventory Service
$eventBus->subscribe('orders', 'OrderCreated', function ($event) {
    $this->reserveInventory($event->items);
});

// Notification Service
$eventBus->subscribe('orders', 'OrderCreated', function ($event) {
    $this->sendConfirmationEmail($event->user_id, $event->order_id);
});

The order service doesn't know or care which services react to its events. You can add new subscribers without modifying the publisher.

Saga Pattern

Distributed transactions spanning multiple services can't use traditional ACID transactions. The saga pattern breaks a transaction into steps, each with a compensating action to undo its effects if later steps fail.

When you need to coordinate actions across multiple services atomically, the saga pattern provides a way to maintain consistency without distributed locks. This example shows the orchestration approach where a central coordinator manages the workflow.

class OrderSaga
{
    private array $steps = [];
    private array $compensations = [];

    public function execute(Order $order): void
    {
        try {
            // Step 1: Reserve inventory
            $this->reserveInventory($order);
            $this->compensations[] = fn() => $this->releaseInventory($order);

            // Step 2: Process payment
            $this->processPayment($order);
            $this->compensations[] = fn() => $this->refundPayment($order);

            // Step 3: Confirm order
            $this->confirmOrder($order);

        } catch (Exception $e) {
            // Compensate in reverse order
            $this->compensate();
            throw $e;
        }
    }

    private function compensate(): void
    {
        foreach (array_reverse($this->compensations) as $compensation) {
            try {
                $compensation();
            } catch (Exception $e) {
                Log::error('Compensation failed', ['error' => $e->getMessage()]);
            }
        }
    }
}

Each step registers its compensation before executing. If step 2 fails, step 1's compensation runs. Compensations run in reverse order, and failures are logged but don't stop other compensations.

Event Sourcing

Instead of storing current state, event sourcing stores the sequence of events that led to that state. You can rebuild state by replaying events.

This pattern is particularly powerful when you need a complete audit trail or when you want to analyze how data changed over time. It's commonly used in financial systems and complex domain models.

class OrderAggregate
{
    private array $events = [];
    private string $status = 'pending';

    public function create(array $items): void
    {
        $this->recordEvent(new OrderCreated($items));
    }

    public function confirm(): void
    {
        if ($this->status !== 'pending') {
            throw new InvalidOrderStateException();
        }
        $this->recordEvent(new OrderConfirmed());
    }

    private function recordEvent(DomainEvent $event): void
    {
        $this->events[] = $event;
        $this->apply($event);
    }

    private function apply(DomainEvent $event): void
    {
        match(get_class($event)) {
            OrderCreated::class => $this->status = 'pending',
            OrderConfirmed::class => $this->status = 'confirmed',
            OrderCancelled::class => $this->status = 'cancelled',
        };
    }
}

Events are immutable facts about what happened. The apply method updates the aggregate's state based on each event. This provides a complete audit trail and enables temporal queries.

API Gateway

An API gateway provides a single entry point for external clients, hiding the internal service topology. It acts as a facade that simplifies client interactions.

Client → API Gateway → Service A
                    → Service B
                    → Service C

Responsibilities:

  • Request routing
  • Authentication
  • Rate limiting
  • Response aggregation
  • Protocol translation

The gateway can aggregate responses from multiple services into a single response, reducing client-side complexity. This is especially valuable for mobile clients where reducing round trips improves user experience.

// Laravel as API Gateway
class OrderController extends Controller
{
    public function show(int $id)
    {
        // Aggregate data from multiple services
        $order = Http::get("http://order-service/orders/{$id}")->json();
        $user = Http::get("http://user-service/users/{$order['user_id']}")->json();
        $payments = Http::get("http://payment-service/orders/{$id}/payments")->json();

        return response()->json([
            'order' => $order,
            'user' => [
                'name' => $user['name'],
                'email' => $user['email'],
            ],
            'payments' => $payments,
        ]);
    }
}

This aggregation pattern is sometimes called Backend for Frontend (BFF). Different clients (web, mobile, IoT) might have their own gateways with different aggregation logic.

Service Mesh

A service mesh moves networking concerns out of application code into infrastructure. Sidecar proxies handle retries, timeouts, encryption, and observability.

If you're running on Kubernetes, a service mesh like Istio or Linkerd can handle cross-cutting concerns that would otherwise clutter your application code. Here's how you'd configure traffic policies declaratively.

# Istio VirtualService
apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
  name: payment-service
spec:
  hosts:
    - payment-service
  http:
    - timeout: 5s
      retries:
        attempts: 3
        perTryTimeout: 2s
      route:
        - destination:
            host: payment-service
            port:
              number: 80

With this configuration, Istio automatically applies timeouts and retries to all traffic to the payment service. Your application code doesn't need to implement these patterns.

Features:

  • mTLS encryption
  • Traffic management
  • Observability
  • Retries and timeouts
  • Circuit breaking

Choosing Patterns

Scenario Recommended Pattern
Simple CRUD operations REST API
High-performance internal gRPC
Loose coupling needed Message Queue
Multiple consumers Event-Driven / Pub-Sub
Distributed transactions Saga Pattern
External API clients API Gateway
Complex networking needs Service Mesh

Best Practices

  1. Design for failure: Assume any call can fail
  2. Set timeouts: Don't wait forever
  3. Implement retries: With exponential backoff
  4. Use circuit breakers: Prevent cascading failures
  5. Make operations idempotent: Safe to retry
  6. Version your APIs: Support gradual migration
  7. Monitor everything: Tracing, metrics, logs
  8. Document contracts: Clear API specifications

Conclusion

Microservices communication patterns range from simple HTTP calls to sophisticated event-driven architectures. Start with synchronous REST for simplicity, add message queues for decoupling, and implement circuit breakers for resilience. The right pattern depends on your consistency requirements, latency tolerance, and operational complexity appetite.

Share this article

Related Articles

Need help with your project?

Let's discuss how we can help you build reliable software.