Design a cart and checkout in Java 21
Money in paise, stock reserved before payment, and a checkout that cannot charge twice.
What you will be able to do
- Model money and line items so rounding cannot lose paise
- Reserve stock atomically and release it on failure
- Make checkout idempotent under client retries
- Decide where discounts apply relative to tax and totals
Requirements, in the order I would ask them
- Functional: add and remove items, change quantity, apply a coupon, show a priced total, place an order with a payment method, cancel before shipment.
- Non-functional: no overselling, no double charge on retry, totals recomputed server-side (never trusted from the client), all in memory for the round.
- Out of scope, stated out loud: persistence, multi-currency conversion, tax jurisdiction rules, fulfilment. Naming what you are not building is part of the answer.
Entities and what each one owns
| Entity | Owns | Must never allow |
|---|---|---|
| Money | amount in minor units, currency, arithmetic | mixing currencies, floating point |
| Sku | product id plus variant | an empty or ambiguous variant |
| LineItem | quantity, locked unit price, line total | a non-positive quantity |
| Cart | the set of lines and the coupon | two lines for the same SKU, duplicate coupon |
| PricedCart | subtotal, discount, tax, total | a total that does not equal its parts |
| Reservation | held quantity with an expiry | a release after confirm, a confirm after expiry |
| Order | the frozen lines, payment reference, status | a status that skips a transition |
Money, before anything else
Indian retail prices are quoted in rupees but settled in paise, and discounts are frequently a percentage. Do the arithmetic in integers and round once, at the point where a value becomes money the customer sees. Math.multiplyExact turns an overflow into an exception instead of a silently negative total.
record Money(long paise, Currency currency) implements Comparable<Money> {
Money plus(Money o) { sameCurrency(o); return new Money(Math.addExact(paise, o.paise), currency); }
Money minus(Money o) { sameCurrency(o); return new Money(Math.subtractExact(paise, o.paise), currency); }
Money times(int qty) { return new Money(Math.multiplyExact(paise, qty), currency); }
Money percentOff(int basisPoints) { // 1250 = 12.50%
if (basisPoints < 0 || basisPoints > 10_000) throw new IllegalArgumentException("bad basis points");
return new Money(Math.round(paise * basisPoints / 10_000.0), currency);
}
private void sameCurrency(Money other) {
if (!currency.equals(other.currency)) throw new IllegalArgumentException("currency mismatch");
}
}Reserving stock without overselling
The invariant is that available plus reserved never exceeds on hand, and that it stays true under concurrent checkouts. In memory, that means one object owns the counter and every change goes through a method that checks it. In a database, it means a conditional update whose rows-affected count tells you whether you won: update stock set reserved = reserved + ? where sku = ? and on_hand - reserved >= ?.
final class StockItem {
private final Sku sku;
private int onHand;
private int reserved;
synchronized Reservation reserve(int qty, Instant now) {
if (qty <= 0) throw new IllegalArgumentException("quantity");
if (onHand - reserved < qty) throw new OutOfStock(sku, qty);
reserved += qty;
return new Reservation(sku, qty, now.plus(Duration.ofMinutes(15)));
}
synchronized void confirm(Reservation r) { reserved -= r.qty(); onHand -= r.qty(); }
synchronized void release(Reservation r) { reserved -= r.qty(); }
}The reservation carries an expiry because a checkout that times out must not hold stock forever. A sweeper releases expired reservations; the order path confirms before the expiry, and confirmation is idempotent so a duplicate confirm does not decrement twice.
Idempotent checkout
The client will retry. Mobile networks drop responses after the server has already charged the card. Every checkout therefore carries a client-generated idempotency key, and the service keeps the result of the first attempt keyed by it. A retry reads the stored result instead of doing the work again. This is not a cache; it is a record of a decision already made.
final class Checkout {
private final Map<IdempotencyKey, Order> completed = new ConcurrentHashMap<>();
private final Pricing pricing;
private final Inventory inventory;
private final Payments payments;
Order place(Cart cart, PaymentMethod method, IdempotencyKey key) {
var existing = completed.get(key);
if (existing != null) return existing;
var priced = pricing.price(cart);
var reservation = inventory.reserveAll(priced.lines());
try {
var payment = payments.authorise(method, priced.total(), key);
var order = Order.confirmed(priced, reservation, payment);
return completed.computeIfAbsent(key, k -> order);
} catch (RuntimeException e) {
reservation.releaseAll();
throw e;
}
}
}Pricing order is a business rule, not an implementation detail
The four tests that prove the design
- Totals: three items, a 10 percent coupon, tax at 18 percent — assert the exact paise, not a tolerance.
- Overselling: two concurrent reservations for the last unit — exactly one succeeds.
- Retry: the same idempotency key twice returns one order and one payment authorisation.
- Undo: payment authorisation fails — stock is released and the cart is unchanged.
Where the next feature plugs in
If the interviewer asks for a second payment provider, nothing in the cart changes: it is a new implementation of the payment port, and the checkout already passes the idempotency key through. If they ask for partial cancellation, the reservation grows a split and the order grows a status per line. Both answers are short because the seams were named before the code was written.
Worth remembering
- Money is integer minor units plus a currency. Any design that stores a price as a double has already failed the round.
- A cart is a draft and may be invalid; an order is a promise and may not. The transition between them is the whole problem.
- Reserve, then charge, then confirm. Never charge before you know you can fulfil, and always be able to undo the reservation.
- Idempotency is a table keyed by the client's request id, holding the result of the first attempt. Retries read it instead of repeating the work.