In the world of microservices, independent deployments are often seen as a core benefit. Most teams interpret this as “we can deploy Service A without coordinating with Service B.” That’s true, but it only scratches the surface.
To truly embrace independence, we need to design our interfaces and responsibilities in a way that services can change and evolve without requiring changes in one another.
Let’s look at a practical example in synchronous world.
Synchronous world
Consider how tight coupling can arise in a typical synchronous system within the Shipment domain.
Imagine a typical system with two services:
OrderService
– handles orders and initiates delivery.ShipmentService
– sends goods to the customer.
Sounds simple enough.
After a successful order, the natural next step is to ship the goods. So we often write something like this:
// In OrderService
//Contains order's domain specific fields
class OrderDetails {
List<PurchaseLine> purchaseLines;
UUID customerId;
Address shippingAddress;
LocalDate requestedDeliveryDate;
PaymentInfo paymentInfo;
DiscountCode appliedDiscount;
Invoice invoice;
}
class OrderService {
ShipmentInvoker invoker;
public void sendTheGoods(OrderDetails orders) {
// calculate all necessary fields
invoker.sendToShipment(orders);
}
}
// In ShippmentService
@RestController
class ShipmentService {
@PostMapping("/shipment-service/v1/orders")
public void processSendalToCustomer(OrderDetails orders) {
for(Order order : orders) {
var packageDimention = choosePackageSize(order);
var shipmentprovider = chooseShipmentProvider(order);
// (...)
shipmentprovider.send(order, settings);
}
}
}
At first glance, this looks clean. OrderService
sends the goods, and ShipmentService
takes care of shipping.
Now the business wants a ReturnService – to handle cases where customers send items back, and we exchange them with new ones. So we extend the logic:
//Contains return's domain specific fields
class ReturnRequest {
private UUID returnRequestId;
private UUID customerId;
private UUID originalOrderId;
private List<ReturnedItem> returnedItems;
private String returnReasonCode;
private List<String> customerComments;
private boolean isExpeditedReturn;
private boolean isFragileHandlingRequired;
}
class ReturnService {
ShipmentService shipmentService;
public void returnItem(ReturnItems returnItems) {
ReturnRequest request = new ReturnRequest();
request.setCustomerId(returnItems.getCustomerId());
request.setOriginalOrderId(returnItems.getOriginalOrderId());
request.setItemsToExchange(returnItems.getItemsToExchange());
request.setReasonCode(returnItems.getReasonCode());
request.setCustomerComments(returnItems.getCustomerComments());
request.setExpedited(returnItems.isExpedited());
request.setPhotos(returnItems.getPhotos());
request.setPickupAddress(getCustomerAddress(returnItems.getCustomerId()));
request.setRequestedPickupDate(LocalDate.now().plusDays(1));
request.setFragileHandlingRequired(true);
request.setPreferredCarrier("DHL");
request.setPrintReturnLabel(true);
// ShipmentService now directly depends on Return domain
shipmentService.processReturnItemsSendalToCustomer(request);
}
}
@RestController
public class ShipmentService {
Fedex fedexService;
@PostMapping("/shipment-service/v1/orders")
public void processOrdersSendalToCustomer(OrderDetails orderDetails) {
for (PurchaseLine line : orderDetails.getPurchaseLines()) {
var pkg = packageFor(line.getItem());
fedexService.send(pkg, orderDetails.getShippingAddress());
}
}
@PostMapping("/shipment-service/v1/returns")
public void processReturnItemsSendalToCustomer(ReturnItemsSendalRequest returnItems) {
for (ReturnedItem item : returnItems.getItemsToExchange()) {
var pkg = packageFor(item.getItem());
fedexService.send(pkg, fetchReturnDepot(returnItems.getCustomerId()));
}
}
}
We just created tight coupling to service-specific domains. Even worse, the Shipment service now knows the language from other domains (both Returns and Orders)
ShipmentService
now needs to know about the Order and Return logic.- It handles why an item is being shipped, but it should only care about how to ship it.
- Any change in the
OrderService
orReturnService
affects theShipmentService
.
This becomes harder to maintain, extend, or test. It’s like pulling a thread that unravels the whole sweater.
The Better Way: Generic Interfaces (simplified)
Instead of tying ShipmentService
to specific use cases, let’s treat shipping as a capability — not a behavior tied to a business rule.
// Define generic request, which contains shipment domain specific fields
class RequestSendal {
UUID customerId;
List<ShippableItem> items;
Address destination;
Date sendAfter;
String shipmentType;
Map<String, Object> metadata;
(...)
}
// Contains shipment domain specific fields
class ShipmentResult {
UUID shipmentRequestId;
String carrierTrackingNumber;
String carrierName;
String shippingLabelUrl;
LocalDateTime pickupScheduledAt;
ShipmentStatus status;
(...)
}
class ShipmentService {
SendalProvider provider;
public ShipmentResult processSendalToCustomer(RequestSendal request) {
return provider.processSendal(request);
}
}
How OrderService Looks Now
public class OrderService {
ShipmentService shipmentService;
public void sendOrder(List<Item> items, UUID customerId) {
RequestSendal request = new RequestSendal();
request.setItems(items);
request.setCustomerId(customerId);
request.setTimeToSend(new Date());
request.setReason("order");
shipmentService.processSendalToCustomer(request);
}
}
And ReturnService?
public class ReturnService {
ShipmentService shipmentService;
public void exchangeItem(ReturnRequest returnRequest) {
RequestSendal request = new RequestSendal();
request.setItems(returnRequest.getItems());
request.setCustomerId(returnRequest.getCustomerId());
request.setTimeToSend(new Date());
request.setReason("return");
shipmentService.processSendalToCustomer(request);
}
}
Now:
- ShipmentService doesn’t know anything about Order/Return logic.
- The ShipmentService fully controls the message request content.
- No changes are required in ShipmentService when ReturnService or OrderService logic evolves.
Asynchronous word
Tight coupling can still emerge even in asynchronous systems, despite their very purpose being to decouple services. At first glance, this may seem counterintuitive. After all, the presence of a message queue allows a consumer service to be offline, with the queue reliably storing messages until the consumer is ready. However, this architectural benefit doesn’t always prevent coupling in practice. To illustrate this, let’s revisit the Shipment example.
// Contains order's domain specific fields
class OrderDetailsEvent {
UUID eventId;
Address shippingAddress;
PaymentInfo paymentInfo;
Invoice invoice;
ReturnDocument;
(...)
}
public class OrderService {
KafkaTemplate template;
public void sendTheGoods(OrderDetails orders) {
// calculate all necessary fields
template.sendToKafka(orders);
}
}
Now, we need to define consumer that would listen for such event on the Shipment side:
class ShipmentService {
@KafkaListener(topics = "orders-topic", groupId = "shipment-service", containerFactory = "orderKafkaListenerFactory")
public void processOrder(OrderDetails orderDetails) {
System.out.println("Received order: " + orderDetails.getEventId());
// Process shipping logic here
}
}
Let’s discuss the same situation when there are new requirements for the returns:
// Contains return's domain specific fields
public class ReturnItemsEvent {
UUID eventId
UUID customerId;
UUID originalOrderId;
List<ReturnedItem> itemsToExchange;
String reasonCode;
List<String> customerComments;
boolean expedited;
List<PhotoAttachment> photos;
Address pickupAddress;
LocalDate requestedPickupDate;
boolean fragileHandlingRequired;
String preferredCarrier;
boolean printReturnLabel;
(...)
}
Now the Shipment Service needs to be updated once again to support a new use case—another functionality requested by the business.
class ShipmentService {
@KafkaListener(topics = "orders-topic", groupId = "shipment-service", containerFactory = "orderKafkaListenerFactory")
public void processOrder(OrderDetailsEvent orderDetails) {
System.out.println("Received order: " + orderDetails.getEventId());
// Process shipping logic here
}
@KafkaListener(topics = "returns-topic", groupId = "shipment-service", containerFactory = "returnKafkaListenerFactory")
public void processOrder(ReturnItemsEvent returnItems) {
System.out.println("Received order: " + returnItems.getEventId());
// Process shipping logic here
}
}
Yet again, we just created tight coupling to service-specific domains. The Shipment service again now knows the language from other domains (both Returns and Orders)
ShipmentService
now needs to know about the Order and Return logic.- It handles why an item is being shipped, but it should only care about how to ship it.
- Any change in the
OrderService
orReturnService
affects theShipmentService
.
This becomes harder to maintain, extend, or test. It’s like pulling a thread that unravels the whole sweater.
The Better Way: Generic Message queue (simplified)
Again, the way to get along with such problem is to create a generic queue that all interested parties can put message to, e.g.
class ShipmentCommand {
UUID eventId
String correlationId;
// shipment domain specific fields
UUID customerId;
List<ShippableItem> items;
Address destination;
Date sendAfter;
String shipmentType;
Map<String, Object> metadata;
}
One key challenge with using a generic queue in distributed asynchronous systems is the lack of a clear recipient for the response. Because the queue is shared, any service can publish messages to it, unlike synchronous calls, where the caller expects a direct response within a short timeframe, typically a few seconds. To address this ambiguity, an additional mechanism was introduced: the inclusion of the correlationId field. This identifier allows systems to track who initiated the request and where the response should be sent.
class ShipmentCreatedEvent {
UUID eventId
String correlationId;
// shipment domain specific fields
UUID shipmentRequestId;
String carrierTrackingNumber;
String carrierName;
String shippingLabelUrl;
LocalDateTime pickupScheduledAt;
ShipmentStatus status;
}
You can think of this type of identifier as similar to those used by AWS. Every resource created in AWS is assigned a globally unique identifier—such as:
arn:aws:lambda:us-west-2:111111111111:function:lambda-function
What many people don’t realize is that AWS follows the URN (Uniform Resource Name) convention in its ARN
(Amazon Resource Name) format.
Uniform Resource Names (URNs) provide a consistent, globally unique, and persistent way to identify resources. Unlike URLs, which specify where something is located, URNs describe what something is—its identity. This distinction allows URNs to embed meaningful metadata within the identifier itself, supporting use cases like routing, tracking, and debugging.
In our shipment system, we apply a similar concept using the following URN structure:
urn:shipments:{version}:{source}:{tenantID}:{uniqueID}
Let’s break it down:
- shipments: This is the namespace, signaling that the URN is related to the shipment domain.
- version: Indicates the format version, making it easier to evolve the structure while preserving compatibility.
- source: Shows which system or module initiated the payment request.
- tenantid: Enables you to enter the tenant for which the message was send.
- uniqueid: Guarantees global uniqueness – UUID type
This structured approach makes identifiers self-descriptive and enhances system-wide capabilities such as intelligent routing, logging, and troubleshooting.
urn:shipments:1:orders:tenant1:9c81b759-a6f5-4243-bd1f-bc6cc0322134
urn:shipments:1:returns:tenant412:d82f26cc-7f89-46dd-b98d-113c930b4604
Each message contains this URN to uniquely identify its origin. But how do we ensure the response reaches the right caller?
In AWS, one way to achieve this is through Amazon EventBridge. You can set up a custom Event Bus along with routing rules that distribute messages to the correct consumer services, based on fields like receiverURN
or other metadata embedded in the event.

The idea to use URN in asynchronous communication was presented in Oskar Dudycz blog, see for more details and use cases with RabbitMQ.
Stay tuned for more articles like this!