When you break a big application into microservices, you solve a lot of problems, but you create a new one: how do these independent services talk to each other to get anything done? This isn’t just about sending data back and forth; it’s about ensuring business operations complete successfully, even when things go wrong.
Quick Review: The Coordination Playbook
-
Synchronous (Request-Response) 📞: One service makes a direct call, typically a REST API request over HTTP, to another service and waits for an immediate response. This creates temporal coupling, meaning the caller’s performance is directly tied to the availability and speed of the service it’s calling, making it potentially brittle.
-
Asynchronous (Event-Based) 🍾: Services communicate indirectly by publishing event messages to a central message broker (like RabbitMQ or Kafka) without waiting for a reply. This removes temporal coupling, allowing services to function independently and making the overall system more resilient and scalable.
-
Saga Pattern: Manages complex, multi-step business operations by sequencing local transactions within each service. If any step fails, the Saga executes compensating transactions to semantically undo the work of preceding steps, ensuring data consistency without using locking distributed transactions.
-
Choreography: Services publish and subscribe to events to trigger each other’s actions. This approach is highly decoupled, but the overall workflow logic is implicit and can be difficult to track.
-
Orchestration: A central controller service sends explicit commands to direct the participant services. This makes the workflow logic centralized and easy to manage but introduces a dependency on the orchestrator.
-
-
Two-Phase Commit (2PC) 🏛️: A protocol that ensures atomic commitment across all services in a transaction. It uses a coordinator that first asks all participants to “prepare” (vote) and then, based on the votes, issues a final “commit” or “abort” command. Its major drawback is that it’s a blocking protocol; if the coordinator fails, resources can remain locked.
-
Three-Phase Commit (3PC) 🛡️: An evolution of 2PC that adds a “pre-commit” phase. This extra step reduces the risk of blocking by allowing participants to proceed if the coordinator fails after the pre-commit decision has been made. However, its added complexity and network overhead mean it is rarely used in practice
MicroServices Coordination – 2 >>
MicroServices Coordination – 3 >>
Distributed Communication & Transaction Patterns: A Comparison
Feature | Synchronous (Request-Response) | Asynchronous (Event-Based) | Saga Pattern | Two-Phase Commit (2PC) | Three-Phase Commit (3PC) |
Communication Style | Direct, blocking calls (e.g., HTTP). | Indirect, non-blocking via a message broker. | Asynchronous, via events or commands. | Synchronous, blocking coordinator-participant calls. | Synchronous, blocking with an extra “pre-commit” phase. |
Coupling | Tightly coupled (temporal coupling). | Loosely coupled. | Loosely coupled. | Tightly coupled during the transaction. | Tightly coupled during the transaction. |
Workflow Logic | Contained within the caller. | Decentralized and implicit. | Can be decentralized (Choreography) or centralized (Orchestration). | Centralized in a coordinator. | Centralized in a coordinator. |
Key Characteristic | Caller waits for an immediate response. | Services function independently. | Manages multi-step operations with compensating transactions. | Ensures atomic commitment across services. | An evolution of 2PC designed to reduce blocking. |
Primary Drawback | Brittle; caller performance is tied to the callee. | Workflow can be hard to track. | Can be complex to implement and debug. | It’s a blocking protocol; resources can remain locked if the coordinator fails. | Rarely used due to high complexity and network overhead. |
Synchronous Communication: The Direct Conversation
This is the most straightforward way for services to interact. One service needs something, so it makes a direct API call—usually a REST request over HTTP—to another service and waits until it gets a response. Think of it as making a phone call; you wait for the other person to pick up and answer your question before you can move on.
It’s simple to implement and understand, but it has a major downside: tight coupling. If the service you’re calling is down or slow, your service is stuck waiting. A failure in one service can easily cascade and bring down others.
C# Example: Calling a CustomerService
from an OrderService
// In your CustomerService project (the one being called) [ApiController] [Route("api/customers")] public class CustomersController : ControllerBase { [HttpGet("{id}/is-valid")] public IActionResult IsCustomerValid(int id) { // Pretend we're checking a database bool customerExists = true; if (!customerExists) { return NotFound(); } return Ok(new { IsValid = true }); } } // In your OrderService project (the one making the call) public class OrderProcessor { private readonly IHttpClientFactory _httpClientFactory; // Use IHttpClientFactory for efficient management of HttpClient instances public OrderProcessor(IHttpClientFactory httpClientFactory) { _httpClientFactory = httpClientFactory; } public async Task CreateOrder(int customerId) { var client = _httpClientFactory.CreateClient("CustomerService"); try { // Make the call and wait for the response var response = await client.GetFromJsonAsync<CustomerValidationResponse>($"api/customers/{customerId}/is-valid"); if (response?.IsValid == true) { Console.WriteLine("Customer is valid. Creating order..."); // Proceed with order creation logic } else { Console.WriteLine("Invalid customer. Aborting order."); } } catch (HttpRequestException ex) { // This is the danger zone: the customer service might be down Console.WriteLine($"Error calling customer service: {ex.Message}"); } } } public class CustomerValidationResponse { public bool IsValid { get; set; } }
Asynchronous Communication: The Message in a Bottle
Instead of a direct call, services communicate by sending messages through an intermediary called a message broker (like RabbitMQ, Apache Kafka, or Azure Service Bus). The sending service fires off its message and immediately moves on to other tasks. It doesn’t know or care which service picks up the message, or when.
This decouples your services beautifully. If the NotificationService
is down, the OrderService
can still place orders. The “Order Placed” message will just sit in the queue until the notification service comes back online.
C# Example: Publishing an Event with RabbitMQ
When an order is placed, the OrderService
publishes an OrderPlacedEvent
. The NotificationService
listens for this event to send an email.
// This is a shared DTO (Data Transfer Object) public class OrderPlacedEvent { public int OrderId { get; set; } public string CustomerEmail { get; set; } } // In OrderService: Publishing the event public class OrderService { private readonly IMessageBus _bus; // An abstraction over your message broker public void CompleteOrder(int orderId, string email) { // 1. Save order to the database... // 2. Fire and forget the event var orderEvent = new OrderPlacedEvent { OrderId = orderId, CustomerEmail = email }; _bus.Publish(orderEvent); Console.WriteLine($"Order {orderId} completed and event published."); } } // In NotificationService: Subscribing to the event public class NotificationHandler { public NotificationHandler(IMessageBus bus) { // Start listening for events of this type bus.Subscribe<OrderPlacedEvent>(HandleOrderPlaced); } private void HandleOrderPlaced(OrderPlacedEvent orderEvent) { Console.WriteLine($"Sending email to {orderEvent.CustomerEmail} for order {orderEvent.OrderId}."); // Actual email sending logic goes here } }