This is the fifth article in our series, where we design a simple order solution for a hypothetical company called Simply Order. The company expects high traffic and needs a resilient, scalable, and distributed order system.
In the previous articles:
- Simply Order (Part 1) Distributed Transactions in Microservices: Why 2PC Doesn’t Fit and How Sagas Help
 - Simply Order (Part 2) — Designing and Implementing the Saga Workflow with Temporal
 - Simply Order (Part 3) — Linking It All Together: Connecting Services and Watching Temporal in Action
 - Simply Order (Part 4) — Reliable Events with the Outbox Pattern (Concepts)
 - Simply Order (Part 5) — Hands-On: Building the Outbox Pattern for Reliable Event
 
We built the core services — Order, Payment, and Inventory — and discussed different approaches for handling distributed transactions across multiple services. Then, we designed and implemented the Saga workflow. Next, we introduced the problem of dual-write consistency and how the Outbox Pattern can solve it.
The Problem
In this article, we’re tackling another critical issue in the inventory service.
Imagine you place an order, and somehow the same item gets reserved twice. Could that happen?  
Short answer: YES.
In distributed systems, retries are inevitable. The problem is that your service doesn’t always know whether the first request succeeded or failed. For example, our Saga workflow might retry a reserve order request due to a network delay or a temporary failure — even though the inventory service already reserved the items the first time.
Idempotency Definition
So, what exactly does idempotency mean?
An API is called idempotent when calling it multiple times with the same input produces the same result — or, more precisely, the same server-side effect.
In other words, making the same request several times leaves the system in the same state.  
For example:
- GET: Fetches data — the server state never changes.
 - PUT: Updates data — the first call changes the state, and subsequent identical calls keep the server in the same state.
 
Both are considered idempotent.
However, POST is not idempotent by default. Each call to a POST endpoint typically creates a new entity — so if you retry the same request, you end up with duplicates.
How to Make Our Operation Idempotent
The simplest way to make a non-idempotent API idempotent is to keep track of each request’s unique identifier — typically passed as a header like X-Idempotency-Key.  
When the same key is received again, instead of re-executing the operation, we simply return the cached response stored from the first successful execution.
Implementation
The code for this project can be found in this repository:
https://github.com/hassan314159/simply-orderSince this repository is continuously updated, the code specific to this lesson can be found in the add_inventory_idempotency. branch. Start with:
git checkout add_inventory_idempotency
Order Service Changes
The Order Service, through the Saga workflow, calls the Inventory Service to reserve items.
To properly track these requests, we need to attach an idempotency key header that is unique for each order.
This way, if the same RESERVE operation is executed multiple times, the Inventory Service can detect it and avoid processing it again.  
We found that the existing sagaId, which is unique for each Saga transaction, is a great candidate for our idempotency key.
Here’s how we add it inside:
dev.simplyoder.order.temporal.activities.OrderActivitiesImpl.java
headers.add("X-Idempotency-Key", sagaId + ":reserve");
The suffix :reserve helps distinguish between different operations (like reserve and release) under the same Saga context
Inventory Service Change
Idempotency Entity
To implement idempotency in the Inventory Service, we need a datastore to keep track of reserve order requests and their corresponding responses.
But what do we actually mean by response?
In an HTTP call, the response mainly consists of two parts:
The two main components for https responses are
- HTTP Status – Indicates the outcome of the operation (e.g., 200 for success, 409 for conflict).
 - 
Response Payload – The body returned from the service. In our case, it contains the 
reservationIdgenerated when the inventory was first reserved. 
We also keep track of some important fields such as the
aggregateId, which in our case represents theorderId, along with the actual idempotency key.To make the record easily traceable and unique, we generate a deterministic UUID (a UUID derived from the idempotency key and operation).
This ensures that every combination of key and operation always produces the same UUID — which helps us quickly detect duplicate requests without reprocessing them.
Here’s the entity that represents this data:
dev.simplyoder.inventory.persistence.IdempotencyEntity
@Id
private UUID key;
private String aggregateId;
private Integer httpStatus;
@Column(length = 4000)
private String jsonResponse;
private Instant createdAt;
private Instant updatedAt;
Domain Entities
At this stage, we haven’t fully implemented the persistence layer for the Inventory Service, but we already have two main domain entities that define its core logic:
- 
InventoryItem – Stores information about items in stock, including the item SKU, name, description, and the number of units currently available (
inStockQty). - ReservationHold – Keeps track of item reservations so we can confirm or release them later, without directly modifying the main inventory table.
 
Here are the two entities:
InventoryItem
dev.simplyoder.inventory.persistence.InventoryItemEntity
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, length = 64, nullable = false)
privte String sku;
@Column(nullable = false, length = 200)
private String name;
@Column(columnDefinition = "text")
private String description;
@Column(nullable = false)
private int inStockQty;
@Column(nullable = false)
private int reservedQty;
@Column(nullable = false)
private Instant updatedAt = Instant.now();
ReservationHold
dev.simplyoder.inventory.persistence.ReservationHoldEntity
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private UUID reservationId;
@Column(name="order_id", nullable=false)
private String orderId;
@Column(name="idempotency_key")
private String idempotencyKey;
@Column(nullable = false, length = 64)
private String sku;
@Column(nullable = false)
private int qty;
@Enumerated(EnumType.STRING)
@Column(nullable=false)
private State state; // PENDING | CONFIRMED | CANCELLED
Service Layer
dev.simplyoder.inventory.service.InventoryService
public ReservationsResponse reserve(ReservationsRequest req, String idempotencyKey) {
// create a key based on provided idempotency key from the caller and operation
final UUID rid = ReservationIds.fromKey(idempotencyKey, OP_RESERVE);
Optional<IdempotencyEntity> idempotencyRow = idempotencyRepository.findById(rid);
//1 handle idempotency  
if(idempotencyRow.isPresent()){
    return tryDeserialize(idempotencyRow.get())
            .orElseGet(() -> new ReservationsResponse(rid));
}
//2 Safe check: if domain already created reservation (previous crash)
if (!reservationHoldRepository.findByReservationId(rid).isEmpty()) {
    ReservationsResponse resp = new ReservationsResponse(rid);
    saveIdempotency(rid, req.orderId().toString(), HttpStatus.OK.value(), serializeResponse(resp));
    return resp;
}
try {
    // 3 Perform doman operation in a saperate transaction
    ReservationsResponse resp = reserveDomainTransaction(req, rid, idempotencyKey); // @Transactional
    saveIdempotency(rid, req.orderId().toString(), HttpStatus.OK.value(), serializeResponse(resp)); // REQUIRES_NEW
    return resp;
} catch (InsufficientStockException ex) {
    saveIdempotency(rid, req.orderId().toString(), HttpStatus.CONFLICT.value(), serializeResponse(Collections.EMPTY_MAP));     // REQUIRES_NEW
    throw ex;
}
}
The logic is simple:
- Check Idempotency – Verify if a record already exists for the given idempotency key.
 - Secondary Safeguard – Handle the rare case where an idempotency record was created, but the actual item reservation never happened (for example, due to a crash right after saving the key).
 - 
Perform Reservation Logic
- Check the available stock and deduct the requested amount.
 - Create a 
Reservationrecord so that the items can later be confirmed or released. 
 
Note: The idempotency handling and the business operation are executed in two separate transactions, since they are logically independent.
The domain transaction logic is as follows:
@Transactional
public ReservationsResponse reserveDomainTransaction(ReservationsRequest req, UUID rid, String idempotencyKey){
    req.items().forEach(item -> {
        var itemEntity = inventoryItemRepository.findBySku(item.sku()).orElseThrow();
        int available = itemEntity.getInStockQty() - itemEntity.getReservedQty();
        if (item.qty() > available) throw new InsufficientStockException("Insufficient stock for " + item.sku());
        itemEntity.setReservedQty(itemEntity.getReservedQty() + item.qty());
        inventoryItemRepository.save(itemEntity);
    });
    // persist the holds for later release-by-id
    var rows = new ArrayList<ReservationHoldEntity>();
    for (var it : req.items()) {
        var rh = ReservationHoldEntity.reserve(rid, req.orderId().toString(), idempotencyKey, it.sku(), it.qty());
        rows.add(rh);
    }
    reservationHoldRepository.saveAll(rows);
    return new ReservationsResponse(rid);
}
Homework
For now, we’ve secured our reserve endpoint with idempotency.
However, the release operation is still not idempotent.  
The same approach can be applied here — simply reuse the existing IdempotencyEntity and implement the same logic for the release endpoint to ensure it behaves consistently.
Wrap Up
We’ve now made our Inventory Service more resilient by introducing idempotency.
It’s a small change in code but a big improvement in reliability.  
In distributed systems, retries aren’t optional — they’re part of survival.
By making our endpoints idempotent, we ensure that duplicated network calls or Saga retries don’t cause inconsistent states or double reservations.  
Idempotency and the Outbox Pattern (from the previous part) go hand in hand — one protects against duplicated operations, the other against lost events.
Together, they make your system predictable and safe to retry, which is exactly what you want in a distributed environment.
              
    
Top comments (0)