Same key, same receipt
Idempotency in object APIs
Idempotency means repeating the same command with the same key does not apply the side effect twice. Example: the kiosk retries pay because the network blinked. Two pay() calls would double-charge. If the command carries an , the second call returns the same receipt. are normal; double charge is not.
Retry timeline
What the second packet must do
- T+0pay(ticket, key=K, amount=120) starts. Charge succeeds. Receipt R stored under K.
- T+800msResponse is lost. Kiosk still shows a spinner.
- T+2sKiosk sends the same pay with key=K. Service finds K, returns R. No second charge.
- WrongIf you keyed only on ticket id, a later pay with a different amount could be swallowed. Keys are per attempt intent.
Not every verb is idempotent
means doing the command again does not change the outcome. park() twice is two cars. That is a new command each time. Making park idempotent by plate is usually wrong — two cars can share a family plate story, and a retry of park is usually a new ticket. Use keys on payments and confirms, not on every verb. Commands vs queries is the sibling page: CQS. An idempotent command is still a command.
Java: same key, same receipt
class PaymentService {
private final Map<String, Receipt> completed = new HashMap<>();
private final TicketRepository tickets;
Receipt pay(TicketId id, String idempotencyKey, Money amount) {
Receipt seen = completed.get(idempotencyKey);
if (seen != null) {
if (!seen.amount().equals(amount)) throw new IdempotencyConflict(idempotencyKey);
return seen;
}
Ticket ticket = tickets.findById(id).orElseThrow(() -> new UnknownTicket(id));
Receipt receipt = ticket.pay(amount);
completed.put(idempotencyKey, receipt);
tickets.save(ticket);
return receipt;
}
}Safe to retry
- pay with the same key and amount
- exit on an already CLOSED ticket → same receipt
- cancel a reservation already cancelled
New command each time
- park(Vehicle) — two cars, two tickets
- pay with a new key — a new attempt
- same key, different amount —
Java: exit can be naturally idempotent
class Ticket {
private TicketStatus status;
private Receipt receipt;
Receipt close(Instant exit, PricingStrategy pricing) {
if (status == TicketStatus.CLOSED) return receipt; // same outcome
if (status != TicketStatus.ACTIVE) throw new InvalidTransition(status, TicketStatus.CLOSED);
status = TicketStatus.CLOSED;
receipt = new Receipt(id, pricing.fee(this, exit));
return receipt;
}
}Where the key lives
In a 45-minute round, a HashMap<String, Receipt> is enough if you say it would be a unique index in the database. Do not put that map on ParkingLot next to a JDBC Connection — the payment service or an IdempotencyStore port owns it. HLD coverage of the same idea is idempotency; retries in the network are timeouts and retries. State that must not double-apply pairs with state machines.
Mistakes
- Retry = second charge — pay() that always decrements a balance will punish a double-click. Record the key. Return the first receipt.
- Making park() idempotent by plate — two cars can share a plate story. Use keys on payments and confirms, not on every verb.
- Key without storing the body — you cannot detect a conflict if you only remember “K was seen.” Store enough to compare amount and ticket id.
In-flight vs completed
The first pay may still be running when the retry arrives. A naive HashMap get-then-put races: two charges. Record the key as IN_PROGRESS under a lock or a unique index, then complete it. If the second call sees IN_PROGRESS, wait or return “try again” — do not start a second charge. In a 45-minute round, synchronized on the key plus the map is enough if you name the race.
An untyped process(Object) method with a retry is hopeless: you cannot tell whether the blob is the same command. A typed pay(TicketId, key, amount) is comparable. That is why class API design and idempotency are the same interview: names first, then “what if they send it twice.”
Java: lock the key before charging
Receipt pay(TicketId id, String key, Money amount) {
synchronized (lockFor(key)) {
Record existing = store.get(key);
if (existing != null) {
if (!existing.amount.equals(amount) || !existing.ticketId.equals(id)) {
throw new IdempotencyConflict(key);
}
return existing.receipt;
}
Ticket ticket = tickets.findById(id).orElseThrow(() -> new UnknownTicket(id));
Receipt receipt = ticket.pay(amount);
store.put(key, new Record(id, amount, receipt));
tickets.save(ticket);
return receipt;
}
}What to say about park()
“park is not idempotent by default: two calls, two tickets. If the kiosk retries park after a lost response, I would take a client-generated stay id, not the plate.” That stay id is an idempotency key for create. You only add it when the interviewer describes lost-response creates. Do not volunteer plate-based dedupe; it merges two cars.
Exercises
- Kiosk double-clicks pay. Draw where the key is stored and what the second call returns.
- Same key, different amount. What do you throw, and why is that not a successful retry?
- Should park(Vehicle) accept an idempotency key? When would you say yes?
Worked scene: lost response, same key
Gate software calls pay(T-9, key=abc, 120). The charge hits the processor. The process dies before the HTTP response. The gate sends pay(T-9, key=abc, 120) again. The store finds abc, amounts match, ticket ids match, returns the first Receipt. The processor is not called. If the second body said 200, IdempotencyConflict fires — that is a new intent wearing an old key, not a retry.
Store the key next to the receipt, not on ParkingLot beside a Connection. An IdempotencyStore port keeps the same persistence rule: HashMap in an interview, unique index later. An untyped process(Object) method cannot compare keys because it does not know which field is the key. Typed pay(...) can.
interface IdempotencyStore {
Optional<Receipt> find(String key);
void put(String key, TicketId id, Money amount, Receipt receipt);
}
class PaymentService {
private final IdempotencyStore keys;
private final TicketRepository tickets;
}How to explain this in an interview
pay / confirm / cancel
Idempotency key. Store result. Same key, same receipt. Conflict if the body differs.
exit already CLOSED
Return the stored receipt. Naturally idempotent. Still a command.
park(Vehicle)
Two calls, two tickets. Key only if they describe lost-response creates — then stay id, not plate.
Where the map lives
IdempotencyStore port. Not a Connection on ParkingLot.
In about a minute, say: “pay takes a key. Same key and amount return the first receipt. Different amount is a conflict. park() twice is two cars unless they give me a stay id. Retries are normal.”
Lock the key before charging so two in-flight retries cannot double-bill. Store enough of the body to detect conflicts. The store is a port, like TicketRepository.
A getter that charges is not idempotency — it is a query wearing a command. Name pay. Key it. Return the first receipt.
Plate-based dedupe of park merges two cars. Stay id is the only honest create key, and only when they describe a lost response.
Java: stay id only when create can be retried
class ParkingLot {
Ticket park(Vehicle vehicle) { /* new stay every call */ }
Ticket park(Vehicle vehicle, StayId stayId) {
Ticket existing = byStay.get(stayId);
if (existing != null) return existing;
Ticket ticket = park(vehicle);
byStay.put(stayId, ticket);
return ticket;
}
}Takeaway
Design commands that can be sent again. Keys make pay safe. exit on CLOSED can return the same receipt. park() twice is two cars unless you have a real reason to treat it as one stay. Retries are normal; double charges are the bug.