Asynchronous Communication in Microservices
In a microservices system, the way services talk to each other shapes how fast, reliable and scalable the whole application is. A simple request-and-wait call works well in many cases, but when services must survive failures and absorb traffic spikes, a better option is asynchronous communication. This tutorial explains the idea in simple terms, compares it with synchronous calls, walks through an e-commerce example and shows a complete implementation with Spring Boot and RabbitMQ.
What is Asynchronous Communication?
Asynchronous communication is a "send and move on" style of interaction. When one microservice (the producer) sends a message to another (the consumer), it does not wait for an answer. The producer hands the message over and immediately continues with its own work. The consumer picks up the message when it is ready, and if a reply is needed, it sends it back later through a separate message.
This is the opposite of synchronous communication, such as a REST call over HTTP, where the caller sends a request and then blocks until the response arrives. If the receiver is slow or offline, the caller is slowed down or fails as well.
Synchronous vs Asynchronous Communication
| Aspect | Synchronous (REST, gRPC) | Asynchronous (Messaging) |
|---|---|---|
| Waiting | Caller blocks until it gets a reply | Sender continues immediately |
| Availability | Both services must be up at the same time | Receiver can be down; messages wait in the broker |
| Coupling | Caller knows the receiver's address and API | Sender only knows the broker and message format |
| Failure impact | Failures can cascade along the call chain | Failures stay isolated in one service |
| Traffic spikes | Receiver must handle peak load directly | Queue buffers the peak; consumers work at their own pace |
| Consistency | Immediate result | Eventual consistency |
| Best for | Queries and actions needing an instant answer | Background work, notifications, long-running workflows |
Key Characteristics of Asynchronous Communication
- Loose Coupling: Services do not depend on each other being available at the exact moment a message is sent.
- Decoupling in Time: The producer and consumer do not need to run at the same time.
- Improved Resilience: A failure in one service does not instantly spread to the others.
- Natural Load Levelling: Queues act as buffers, so a burst of requests is processed smoothly instead of crashing a service.
- Independent Scaling: You can add more consumer instances to drain a busy queue faster without changing the producer.
Asynchronous Messaging Styles
There are three common ways services exchange messages asynchronously:
1. Point-to-Point (Queue)
A message is placed in a queue and delivered to exactly one consumer. If several instances of the same service listen to the queue, they share the work, which is called the competing consumers pattern. This style suits tasks like "generate this invoice" or "resize this image".
2. Publish-Subscribe (Topic)
A message is published once and delivered to every interested subscriber. Each subscribing service gets its own copy. This style suits announcements like "an order was created", which the inventory, notification and analytics services all want to hear.
3. Asynchronous Request-Reply
Sometimes the sender does need an answer, just not right away. The sender adds a reply-to address and a correlation ID to the request. The receiver processes it and sends the result to the reply address with the same correlation ID, so the sender can match the answer to its original request.
Point-to-Point: Producer --> [ Queue ] --> one Consumer
Publish-Subscribe: Publisher --> [ Exchange / Topic ] --+--> Subscriber A
+--> Subscriber B
+--> Subscriber C
Request-Reply: Requester --(request, replyTo, correlationId)--> [ Queue ] --> Responder
Requester <--(reply, correlationId)-- [ Reply Queue ] <------- Responder
How It Works: The Message Broker
Asynchronous communication is usually handled by a message broker, a piece of middleware that sits between services. Producers send messages to the broker, the broker stores them safely and routes them to the right consumers, and consumers acknowledge each message once it has been processed.
| Broker | Model | Good Fit For |
|---|---|---|
| RabbitMQ | Queues and exchanges (AMQP) | Flexible routing, task queues, request-reply |
| Apache Kafka | Distributed, replayable event log | High-volume event streaming and analytics |
| Amazon SQS / SNS | Managed queue / managed pub-sub | Serverless and AWS-based systems |
| Azure Service Bus | Managed queues and topics | Enterprise workloads on Azure |
| Redis Streams / Pub/Sub | In-memory streams and channels | Lightweight, low-latency messaging |
RabbitMQ Building Blocks
Because the example below uses RabbitMQ, here are the four terms you need to know:
- Exchange: Receives messages from producers and decides which queues should get them.
- Queue: Stores messages until a consumer reads and acknowledges them.
- Binding: A rule that connects an exchange to a queue.
- Routing Key: A label on each message (for example
order.created) that the exchange uses to match bindings.
A topic exchange matches routing keys using patterns: * matches exactly one word and # matches zero or more words. For example, a binding of inventory.* receives both inventory.reserved and inventory.failed.
A Practical Example: E-Commerce Order Processing
Imagine a small online shop built from three microservices:
- Order Service: Creates customer orders.
- Inventory Service: Manages product stock.
- Notification Service: Sends emails to customers.
The Synchronous (Fragile) Way
With synchronous calls, when a customer places an order, the Order Service would:
- Call the Inventory Service and wait while it reserves the items.
- Only after a success response, call the Notification Service and wait for the email to be sent.
- Finally, confirm the order to the customer.
The problem: the response time is the sum of every call. If the Inventory Service is down or the email server is slow, the customer keeps waiting, and the order may fail completely.
The Asynchronous (Resilient) Way
Now let us redesign the flow with a message broker such as RabbitMQ.
Customer
| POST /api/orders
v
Order Service ---- saves order (PENDING), replies "202 Accepted"
|
| OrderCreated (routing key: order.created)
v
[ order.exchange ] ---------------+
| |
v v
Inventory Service Notification Service
| (sends "Thanks for your order" email)
| InventoryReserved / InventoryFailed
v
[ order.exchange ]
|
v
Order Service ---- updates order to CONFIRMED or CANCELLED
Step 1: The Order is Placed
The customer clicks "Buy Now". The Order Service saves the order with status PENDING and publishes an OrderCreated event to the broker. The event carries the orderId, customerEmail and the list of items. The Order Service then replies to the customer at once: "Your order has been received and is being processed."
Step 2: Independent Processing
The broker delivers a copy of OrderCreated to each subscribed service:
- Inventory Service: Checks stock, reserves the items and then publishes
InventoryReserved. If the stock is not available, it publishesInventoryFailedinstead. - Notification Service: Receives the same event and, without waiting for the inventory check, emails the customer a "Thank you for your order" message.
Step 3: Completing the Order
The Order Service also listens for inventory results. On InventoryReserved it changes the order status from PENDING to CONFIRMED. On InventoryFailed it sets the status to CANCELLED and can trigger a different notification.
Implementation with Spring Boot and RabbitMQ
The following code builds the flow above using Spring Boot 3 and Spring AMQP. Each class belongs to the service named in its package.
Step 1: Add the Dependency
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
Step 2: Configure the Connection and Retries
spring:
rabbitmq:
host: localhost
port: 5672
username: guest
password: guest
listener:
simple:
acknowledge-mode: auto
default-requeue-rejected: false # failed messages go to the DLQ, not back to the queue
retry:
enabled: true
max-attempts: 3
initial-interval: 1000ms
multiplier: 2.0
Step 3: Declare Exchanges, Queues and Bindings
package com.javabytechie.messaging;
import org.springframework.amqp.core.Binding;
import org.springframework.amqp.core.BindingBuilder;
import org.springframework.amqp.core.DirectExchange;
import org.springframework.amqp.core.Queue;
import org.springframework.amqp.core.QueueBuilder;
import org.springframework.amqp.core.TopicExchange;
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
import org.springframework.amqp.support.converter.MessageConverter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class RabbitConfig {
public static final String ORDER_EXCHANGE = "order.exchange";
public static final String DEAD_LETTER_EXCHANGE = "order.dlx";
public static final String ORDER_CREATED_KEY = "order.created";
public static final String INVENTORY_RESULT_KEYS = "inventory.*";
public static final String INVENTORY_QUEUE = "inventory.order-created";
public static final String NOTIFICATION_QUEUE = "notification.order-created";
public static final String ORDER_RESULT_QUEUE = "order.inventory-result";
@Bean
public TopicExchange orderExchange() {
return new TopicExchange(ORDER_EXCHANGE);
}
@Bean
public DirectExchange deadLetterExchange() {
return new DirectExchange(DEAD_LETTER_EXCHANGE);
}
@Bean
public Queue inventoryQueue() {
return QueueBuilder.durable(INVENTORY_QUEUE)
.deadLetterExchange(DEAD_LETTER_EXCHANGE)
.deadLetterRoutingKey(INVENTORY_QUEUE + ".dlq")
.build();
}
@Bean
public Queue inventoryDeadLetterQueue() {
return QueueBuilder.durable(INVENTORY_QUEUE + ".dlq").build();
}
@Bean
public Queue notificationQueue() {
return QueueBuilder.durable(NOTIFICATION_QUEUE).build();
}
@Bean
public Queue orderResultQueue() {
return QueueBuilder.durable(ORDER_RESULT_QUEUE).build();
}
@Bean
public Binding inventoryBinding(Queue inventoryQueue, TopicExchange orderExchange) {
return BindingBuilder.bind(inventoryQueue).to(orderExchange).with(ORDER_CREATED_KEY);
}
@Bean
public Binding notificationBinding(Queue notificationQueue, TopicExchange orderExchange) {
return BindingBuilder.bind(notificationQueue).to(orderExchange).with(ORDER_CREATED_KEY);
}
@Bean
public Binding orderResultBinding(Queue orderResultQueue, TopicExchange orderExchange) {
return BindingBuilder.bind(orderResultQueue).to(orderExchange).with(INVENTORY_RESULT_KEYS);
}
@Bean
public Binding inventoryDeadLetterBinding(Queue inventoryDeadLetterQueue,
DirectExchange deadLetterExchange) {
return BindingBuilder.bind(inventoryDeadLetterQueue)
.to(deadLetterExchange)
.with(INVENTORY_QUEUE + ".dlq");
}
@Bean
public MessageConverter jsonMessageConverter() {
// Sends and receives messages as JSON instead of Java serialization.
return new Jackson2JsonMessageConverter();
}
}
Because both OrderCreated queues are bound with the same routing key, every new order is copied to the Inventory queue and the Notification queue. That is publish-subscribe in action.
Step 4: Define the Messages
package com.javabytechie.messaging;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
public record OrderCreatedEvent(
String eventId,
String orderId,
String customerEmail,
List<Item> items,
Instant createdAt) {
public record Item(String productId, int quantity) {
}
public static OrderCreatedEvent of(String orderId, String customerEmail, List<Item> items) {
return new OrderCreatedEvent(
UUID.randomUUID().toString(),
orderId,
customerEmail,
items,
Instant.now());
}
}
package com.javabytechie.messaging;
public record InventoryResultEvent(String orderId, boolean reserved, String reason) {
public static InventoryResultEvent reserved(String orderId) {
return new InventoryResultEvent(orderId, true, null);
}
public static InventoryResultEvent failed(String orderId, String reason) {
return new InventoryResultEvent(orderId, false, reason);
}
}
Step 5: Publish the Event (Order Service)
package com.javabytechie.order;
import com.javabytechie.messaging.OrderCreatedEvent;
import java.util.List;
import java.util.Map;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
@PostMapping
public ResponseEntity<Map<String, String>> placeOrder(@RequestBody PlaceOrderRequest request) {
String orderId = orderService.placeOrder(request.customerEmail(), request.items());
// 202 Accepted: the order is received; processing continues in the background.
return ResponseEntity.accepted()
.body(Map.of("orderId", orderId, "status", "PENDING"));
}
public record PlaceOrderRequest(String customerEmail, List<OrderCreatedEvent.Item> items) {
}
}
package com.javabytechie.order;
import com.javabytechie.messaging.OrderCreatedEvent;
import com.javabytechie.messaging.RabbitConfig;
import java.util.List;
import java.util.UUID;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.stereotype.Service;
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final RabbitTemplate rabbitTemplate;
public OrderService(OrderRepository orderRepository, RabbitTemplate rabbitTemplate) {
this.orderRepository = orderRepository;
this.rabbitTemplate = rabbitTemplate;
}
public String placeOrder(String customerEmail, List<OrderCreatedEvent.Item> items) {
String orderId = UUID.randomUUID().toString();
orderRepository.save(new Order(orderId, customerEmail, OrderStatus.PENDING));
OrderCreatedEvent event = OrderCreatedEvent.of(orderId, customerEmail, items);
// Fire and forget: the method returns without waiting for any consumer.
rabbitTemplate.convertAndSend(
RabbitConfig.ORDER_EXCHANGE,
RabbitConfig.ORDER_CREATED_KEY,
event);
return orderId;
}
}
Step 6: Consume the Event (Inventory Service)
package com.javabytechie.inventory;
import com.javabytechie.messaging.InventoryResultEvent;
import com.javabytechie.messaging.OrderCreatedEvent;
import com.javabytechie.messaging.RabbitConfig;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.stereotype.Component;
@Component
public class InventoryListener {
private final InventoryService inventoryService;
private final RabbitTemplate rabbitTemplate;
public InventoryListener(InventoryService inventoryService, RabbitTemplate rabbitTemplate) {
this.inventoryService = inventoryService;
this.rabbitTemplate = rabbitTemplate;
}
@RabbitListener(queues = RabbitConfig.INVENTORY_QUEUE)
public void onOrderCreated(OrderCreatedEvent event) {
boolean reserved = inventoryService.tryReserve(event.orderId(), event.items());
InventoryResultEvent result = reserved
? InventoryResultEvent.reserved(event.orderId())
: InventoryResultEvent.failed(event.orderId(), "Out of stock");
String routingKey = reserved ? "inventory.reserved" : "inventory.failed";
rabbitTemplate.convertAndSend(RabbitConfig.ORDER_EXCHANGE, routingKey, result);
}
}
Step 7: Consume the Same Event (Notification Service)
package com.javabytechie.notification;
import com.javabytechie.messaging.OrderCreatedEvent;
import com.javabytechie.messaging.RabbitConfig;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;
@Component
public class NotificationListener {
private final EmailSender emailSender;
private final ProcessedMessageRepository processedMessages;
public NotificationListener(EmailSender emailSender,
ProcessedMessageRepository processedMessages) {
this.emailSender = emailSender;
this.processedMessages = processedMessages;
}
@RabbitListener(queues = RabbitConfig.NOTIFICATION_QUEUE)
public void onOrderCreated(OrderCreatedEvent event) {
// Idempotency: a redelivered message must not send a second email.
if (processedMessages.existsById(event.eventId())) {
return;
}
emailSender.send(
event.customerEmail(),
"Thank you for your order",
"We have received order " + event.orderId() + " and are processing it.");
processedMessages.save(new ProcessedMessage(event.eventId()));
}
}
Step 8: Complete the Order (Order Service)
package com.javabytechie.order;
import com.javabytechie.messaging.InventoryResultEvent;
import com.javabytechie.messaging.RabbitConfig;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@Component
public class InventoryResultListener {
private final OrderRepository orderRepository;
public InventoryResultListener(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
@Transactional
@RabbitListener(queues = RabbitConfig.ORDER_RESULT_QUEUE)
public void onInventoryResult(InventoryResultEvent result) {
orderRepository.findById(result.orderId()).ifPresent(order -> {
// Setting a final status is naturally idempotent: repeating it changes nothing.
order.setStatus(result.reserved() ? OrderStatus.CONFIRMED : OrderStatus.CANCELLED);
});
}
}
Reliability: Acknowledgements, Retries and Dead Letter Queues
Messages travel over a network, so things will occasionally go wrong. These mechanisms keep your system safe:
- Durable queues and persistent messages: Messages survive a broker restart because they are written to disk.
- Consumer acknowledgements: The broker removes a message only after the consumer confirms it was processed. With
acknowledge-mode: auto, Spring sends the acknowledgement when the listener method finishes without an exception. - Retries with back-off: Temporary errors, such as a database timeout, are retried a few times with increasing delays (configured in Step 2).
- Dead Letter Queue (DLQ): A message that still fails after all retries is moved to a separate queue so it does not block the others. Engineers can inspect and replay it later.
- Idempotent consumers: Brokers usually deliver at least once, so the same message can arrive twice. Design handlers so that processing a message twice has the same effect as processing it once.
| Delivery Guarantee | Meaning | Risk |
|---|---|---|
| At-most-once | Message is delivered zero or one time | Messages can be lost |
| At-least-once | Message is delivered one or more times | Duplicates; needs idempotent consumers |
| Exactly-once | Message effect is applied one time only | Hard to achieve end to end; usually built from at-least-once plus idempotency |
Benefits Realized in the Example
- Resilience: If the Inventory Service is down for a few minutes, the
OrderCreatedmessage simply waits in its queue and is processed when the service returns. The customer has already been answered and emailed. - Performance: The customer gets an instant response because the slow work happens in the background.
- Scalability: During a big sale, thousands of orders can be queued. You can run extra Inventory Service instances to drain the queue faster, and none of them is overwhelmed.
- Flexibility: A new Analytics Service can subscribe to
OrderCreatedby binding its own queue to the exchange, with no change to the Order Service.
Challenges of Asynchronous Communication
- Eventual Consistency: Data across services is updated after a short delay, so the UI must handle states like PENDING.
- Duplicate Messages: At-least-once delivery means handlers must be idempotent.
- Message Ordering: With several consumers on one queue, messages may finish processing in a different order than they were sent.
- Harder Debugging: A single request flows through several services at different times, so you need correlation IDs and distributed tracing.
- Extra Infrastructure: The broker must be deployed, secured, monitored and kept highly available.
- Message Contract Changes: Changing a message structure can break consumers unless it is versioned carefully.
Best Practices
- Use asynchronous messaging for work that does not need an immediate answer.
- Give every message a unique ID and a timestamp.
- Make every consumer idempotent.
- Configure retries with back-off and a dead letter queue for every important queue.
- Use durable queues and persistent messages for business-critical data.
- Send JSON (or Avro/Protobuf) messages instead of Java-serialized objects.
- Add new message fields as optional, and never remove a field consumers still use.
- Pass a correlation ID in message headers and trace it with tools like OpenTelemetry.
- Monitor queue depth and consumer lag, and alert when they keep growing.
- Use the Transactional Outbox pattern when a database update and a message must succeed together.
When to Use Asynchronous Communication (and When Not To)
| Use Asynchronous When | Prefer Synchronous When |
|---|---|
| The caller does not need the result immediately | The user is waiting for the answer (e.g. login, price lookup) |
| Work is slow, such as reports, emails or video processing | The operation is a fast, simple read |
| Several services must react to the same change | Only one service is involved and must reply |
| Traffic arrives in bursts that need smoothing | Strong, immediate consistency is required |
| The receiver may be temporarily unavailable | The extra broker infrastructure is not justified |
Most real systems use both: synchronous calls for queries the user is waiting on, and asynchronous messages for everything that can happen in the background.
Conclusion
Asynchronous communication, powered by message brokers, is a cornerstone of robust microservices. By letting services send messages and move on, it improves resilience, response time, scalability and flexibility. In return it brings eventual consistency, duplicate handling and extra infrastructure, so acknowledgements, retries, dead letter queues and idempotent consumers are essential. The next time you design an interaction between services, ask: "Does the caller really need to wait?" If the answer is no, asynchronous communication is likely the better choice.
Frequently Asked Questions
1. What is asynchronous communication in microservices?
It is a way for services to exchange messages without waiting for an immediate reply. The sender passes a message to a broker and continues its work, while the receiver processes the message whenever it is ready.
2. How is asynchronous communication different from synchronous communication?
In synchronous communication the caller blocks until it receives a response, and both services must be online. In asynchronous communication the caller does not wait, the receiver can be offline for a while, and messages are stored by a broker until they are processed.
3. What is a message broker?
A message broker is middleware that receives messages from producers, stores them safely and delivers them to the right consumers. Popular examples are RabbitMQ, Apache Kafka, Amazon SQS and Azure Service Bus.
4. What is the difference between a queue and a topic?
A queue delivers each message to only one consumer, which is useful for sharing work. A topic (or a fan-out/topic exchange in RabbitMQ) delivers a copy of each message to every subscriber, which is useful for broadcasting events.
5. Is "asynchronous" the same as "event-driven"?
Not exactly. Asynchronous only means the sender does not wait. Event-driven is a specific asynchronous style where services publish facts about things that already happened. You can also send asynchronous commands, such as "generate invoice", which are not events.
6. Should I choose RabbitMQ or Kafka?
- Choose RabbitMQ for flexible routing, task queues, per-message acknowledgements and request-reply.
- Choose Kafka for very high throughput, long-term storage of events and the ability to replay history.
Both are excellent; the right choice depends on your workload.
7. What does "fire-and-forget" mean?
It means the sender publishes a message and does not expect any reply. It only needs the broker to accept the message. Sending a "welcome email" request is a typical fire-and-forget example.
8. How can a service get a response in asynchronous communication?
Use the asynchronous request-reply pattern. The request includes a replyTo queue and a correlationId. The receiver sends its result to that queue with the same correlation ID, and the sender matches the reply to the original request. Another option is for the caller to poll a status endpoint later.
9. What is a correlation ID and why is it useful?
A correlation ID is a unique value attached to a request and copied into every related message and log line. It lets you match replies to requests and follow a single business transaction across many services when debugging.
10. What is an idempotent consumer?
An idempotent consumer produces the same result whether it processes a message once or several times. A simple technique is to store each processed message ID and skip IDs that were already handled:
if (processedMessages.existsById(message.id())) {
return; // duplicate, ignore it
}
process(message);
processedMessages.save(new ProcessedMessage(message.id()));
11. Why do duplicate messages happen?
Most brokers guarantee at-least-once delivery. If a consumer processes a message but crashes before sending its acknowledgement, the broker assumes the message was not handled and delivers it again.
12. What is a Dead Letter Queue (DLQ)?
A DLQ is a separate queue that receives messages which could not be processed after all retries, or which were rejected or expired. It prevents "poison" messages from blocking the main queue and keeps them safe for later inspection and replay.
13. What is a message acknowledgement?
An acknowledgement (ack) is a signal from the consumer telling the broker that a message was processed successfully, so it can be removed. If the consumer fails or disconnects before acknowledging, the broker redelivers the message.
14. How do I keep messages in order?
Order is only guaranteed within a single queue (RabbitMQ) or partition (Kafka) when there is one active consumer for it. To keep related messages in order, route them by a key such as orderId to the same queue or partition, or include a sequence number so consumers can detect out-of-order messages.
15. What is eventual consistency?
It means data in different services may disagree for a short time but will become consistent once all messages are processed. For example, an order may show PENDING for a moment before it becomes CONFIRMED.
16. Is using @Async or CompletableFuture the same as asynchronous microservice communication?
No. @Async and CompletableFuture run work on another thread inside one application. If the application crashes, that work is lost. Asynchronous microservice communication uses a durable broker between services, so messages survive crashes and restarts.
17. Can a non-blocking HTTP call (like WebClient) be called asynchronous communication?
A non-blocking client frees the calling thread while it waits, but the interaction is still synchronous at the architecture level: both services must be up at the same time and the caller still needs the response. True asynchronous communication removes that time dependency by using a broker.
18. What is the Transactional Outbox pattern?
It ensures a database change and its message are never out of sync. The service saves the business data and the outgoing message to an outbox table in one transaction, and a separate process then publishes the outbox records to the broker.
19. How do I test asynchronous microservices?
Unit test message handlers as plain methods, use Testcontainers to start a real RabbitMQ or Kafka broker in integration tests, and use tools like Awaitility to wait for asynchronous results. Contract tests help confirm that producers and consumers agree on the message format.
20. When should I avoid asynchronous communication?
Avoid it when the user is waiting for an immediate answer, when strong and instant consistency is required, or when the system is so small that running a broker adds more complexity than value. In those cases a direct REST or gRPC call is simpler.