Module 13 · Lesson 03

Design a cart and checkout in Java 21

Money in paise, stock reserved before payment, and a checkout that cannot charge twice.

Advanced 14 min read Updated 2026-11-03

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.
The three questions that change the model
Is the price locked when the item is added or when checkout starts? Can one order be partially cancelled? Does a coupon survive if an item is removed? Any three answers produce a defensible design; guessing produces an indefensible one.

Entities and what each one owns

EntityOwnsMust never allow
Moneyamount in minor units, currency, arithmeticmixing currencies, floating point
Skuproduct id plus variantan empty or ambiguous variant
LineItemquantity, locked unit price, line totala non-positive quantity
Cartthe set of lines and the coupontwo lines for the same SKU, duplicate coupon
PricedCartsubtotal, discount, tax, totala total that does not equal its parts
Reservationheld quantity with an expirya release after confirm, a confirm after expiry
Orderthe frozen lines, payment reference, statusa 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");
    }
}
Why basis points
A percentage as an int (12) cannot express 12.5, and a double reintroduces the rounding problem you just removed. Basis points are the smallest unit of a percentage, exactly like paise are of a rupee.

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;
        }
    }
}
computeIfAbsent is not a substitute for a transaction
In one process the map is enough to demonstrate the idea. In production the key lives in a unique index, and the write that creates the order is the same transaction that records the key. Say that out loud in the round; it is the difference between a demo and a design.

Pricing order is a business rule, not an implementation detail

Line totals from locked unit prices
Adding to cart locks the price. Re-pricing at checkout is a separate feature (and a separate conversation about customer trust).
Item-level discounts
Applied per line so you can show the customer what changed. Keep the order of rules stable and test it.
Cart-level discounts and coupons
Applied to the subtotal after item discounts. Never allow the total to go negative: clamp at zero and record the unused discount.
Tax on the discounted subtotal
Tax follows the taxable amount, which is why discounts must land before tax. Getting this backwards is a finance bug, not a rounding bug.
Shipping and grand total
Shipping can itself be taxed in some jurisdictions. Keep it a separate line so the invoice can explain itself.

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.
Instead
Trusting the total the client sends.
Do this
Price server-side from locked unit prices and ignore the client's arithmetic.
Instead
Charging the card, then discovering the stock is gone.
Do this
Reserve, authorise, confirm; release the reservation on any failure.
Instead
Catching the payment failure and returning null.
Do this
Release the reservation and rethrow, so the customer learns the purchase failed.
Instead
An Order object with twelve booleans for its lifecycle.
Do this
One status field with named transitions, guarded in one place.
Instead
Applying the coupon after tax.
Do this
Apply item then cart discounts to the subtotal, tax the discounted amount, clamp at zero.

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.

Checkpoint

Answer all 3 questions to finish the lesson.
1. A client retries checkout after a timeout and the card was already charged. What prevents a second charge?
The key is generated by the client per attempt and stored with the outcome, so the retry returns the original result. Cart id is not unique per attempt, and a transaction cannot roll back an external charge.
2. Where do cart-level discounts belong in the pricing sequence?
Tax applies to the taxable amount, which is what the customer actually pays. Clamping at zero prevents negative totals when a coupon exceeds the subtotal.
3. Stock is reserved, then the payment authorisation fails. The correct behaviour is:
Holding stock for a failed payment denies it to other customers for the whole expiry window. Pending orders multiply the state you must reconcile.