Complete interview-grade design: requirements, entities, patterns, concurrency, cross-questions, and the mental model you need to arrive at this design on your own.
Before any class diagram, anchor yourself on 4 mental moves. Every good LLD answer comes from these — not memorisation.
MakeMyTrip sells flights, hotels, buses, trains, cabs, holiday packages. They look different but behave similarly: search → view → hold inventory → pay → confirm → cancel/refund. That similarity is your abstraction — a Bookable or TravelProduct. Every LLD question has one hidden abstraction; find it first, the rest of the design falls out.
The booking lifecycle (search → hold → pay → confirm) rarely changes. What changes: how inventory is fetched (GDS vs own DB), how price is computed, how payment is charged. Stable stuff → templates / skeletons. Variable stuff → strategies / plugins. This single move produces 80% of the design patterns you'll use.
Interviewers don't care about UI. They care: when does money move, when does inventory lock, what happens if one fails. Draw two parallel timelines — inventory state vs payment state — and design around their intersections (holds, compensations, idempotency).
Treat failures as first-class. "Payment timed out after seat was held" is not an edge case; it's the normal case at scale. The moment you accept that, you start reaching for state machines, sagas, idempotency keys, and compensation — without being told to.
Interview tip: Say these 4 moves aloud at the start. It signals maturity and buys you time to think. Then say: "Let me clarify scope first" — and ask the questions below.
Never start coding. Ask these, write the answers on the board — they scope your whole design.
Assume a typical interview scope: flight + hotel booking, card/UPI/wallet payments, single traveller or group.
Keep nouns small and specific. These are the ~15 classes you'll end up drawing.
| Entity | Responsibility | Key fields / relations |
|---|---|---|
| User | Auth, profile, travellers, wallet, loyalty tier | id, email, phone, walletRef, tier |
| TravelProduct (abstract) | Common contract: id, price, availability, fareRules | Flight / Hotel / Bus extend this |
| Flight | Represents a flight segment/leg | airline, from, to, dep/arr, seatMap, fareClasses |
| Hotel | Property with room types | location, amenities, roomInventory[] |
| SearchCriteria | Immutable query object | origin, destination, dates, pax, class, filters |
| SearchResult | Listing of products + faceted filters | products[], appliedFilters |
| Cart | Items + add-ons + price breakdown | items[], addons[], priceBreakup |
| PriceBreakup | Base + tax + fees + discount + payable | lineItems[], total, payable |
| Hold / InventoryLock | Time-bound reservation | productId, pax, expiryAt, holdId |
| Booking | Aggregate root for a confirmed trip | pnr/voucher, travellers[], state, items[], payments[] |
| Payment | One attempt/capture | method, amount, status, gatewayRef, idempotencyKey |
| Coupon / Wallet / LoyaltyPoints | Value adjusters applied before payment | rules engine decides stackability |
| CancellationPolicy / FareRule | Pure function: returns refundable amount | slabs (hrs before departure → % refund) |
| Refund | Reverse money flow, tracks state | bookingId, amount, toMethod, status |
| Notification | Multi-channel message | channel, template, recipient, status |
Each pattern maps to a specific variability axis. Saying "we'll use Strategy" isn't enough — explain what varies.
Varies: how an operation is done. Search flights via GDS vs own DB; price with dynamic-pricing vs static; pay via Stripe vs PayU vs UPI; apply coupon as %-off vs flat-off vs BOGO.
Define PricingStrategy, SearchStrategy, PaymentStrategy, CouponStrategy. Inject via constructor. New algorithm = new class, zero change to core.
Varies: which concrete object to build. ProductFactory.create(type) returns Flight, Hotel, etc. PaymentGatewayFactory returns the right gateway client. Keeps if-else ladders out of business logic.
Problem: a booking has travellers, add-ons (meal/seat/insurance), coupons, payment splits. Constructor with 10 args is unreadable. Booking.builder().traveller(…).addon(…).coupon(…).split(…).build() is clean and enforces invariants in build().
Varies: price composition. FarePrice → + SeatFee → + MealFee → + InsuranceFee → + ConvenienceFee. Each decorator wraps the previous, overriding getAmount(). Add-ons become pluggable without editing base fare code.
Booking request flows through: ValidateRequest → CheckInventory → ApplyCoupon → ReservePayment → ConfirmWithSupplier → GenerateTicket → Notify. Each handler can pass, fail (with compensation), or short-circuit. Makes cross-cutting steps (fraud check, rate limit) trivially insertable.
Booking states: INITIATED → HOLD → PAYMENT_PENDING → PAYMENT_FAILED / CONFIRMED → CANCELLED / REFUND_PENDING / REFUNDED. Encode transitions in a State pattern (or table) so invalid moves (e.g. cancel an already-refunded) fail fast.
On BookingConfirmed, observers fire: email, SMS, WhatsApp, loyalty-point-credit, CRM sync. On PriceDrop, saved-search subscribers get push notifications. Decouples event producers from handlers.
The algorithm outline (hold → pay → confirm → notify) is identical for flight, hotel, bus. Define AbstractBookingService.book() as a template that calls abstract hooks (holdInventory(), confirmWithSupplier()) — concrete services fill the blanks.
One in-memory fare cache, one GDS client pool, one config store. Spring-style DI already gives you this — just be explicit that you want one instance per JVM.
BookingFacade.book(request) hides the chain, state machine, saga, notifications. Callers (mobile, web, partner) see one clean method.
Money and inventory live in separate systems. A saga ensures: if payment captured but supplier confirmation failed → auto-refund + release hold. Each step has a compensation. Critical to mention in any booking LLD.
Skeleton the interviewer actually expects to see. Focus on interfaces & signatures — implementations are one-liners.
core abstractionsinterface TravelProduct { String id(); ProductType type(); // FLIGHT, HOTEL, BUS… Money basePrice(); Availability availability(SearchCriteria c); FareRule fareRule(); } interface InventoryProvider<P extends TravelProduct> { List<P> search(SearchCriteria c); Hold hold(String productId, int pax, Duration ttl); void release(String holdId); SupplierRef confirm(String holdId); // returns PNR/voucher } interface PricingStrategy { PriceBreakup price(Cart c, User u); } interface PaymentStrategy { PaymentResult charge(PaymentRequest r); } interface CouponStrategy { Money discount(Cart c, Coupon k); } interface RefundStrategy { Refund refund(Booking b, Money amt); }booking aggregate & state
class Booking { final String id; final User user; final List<BookingItem> items; // flight + hotel etc. final List<Payment> payments; // split payments private BookingState state; // State pattern void transition(BookingEvent e) { state = state.on(e, this); // enforces legal transitions } } interface BookingState { BookingState on(BookingEvent e, Booking b); } // Concrete: InitiatedState, HeldState, PaidState, ConfirmedState, // CancelledState, RefundedState… each implements legal moves onlycheckout chain
abstract class CheckoutHandler { protected CheckoutHandler next; public final BookingContext handle(BookingContext ctx) { process(ctx); return next == null ? ctx : next.handle(ctx); } protected abstract void process(BookingContext ctx); } // Concrete handlers — each focused, each compensatable ValidateRequest → CheckFraud → HoldInventory → ApplyCoupon → ReservePayment → ConfirmWithSupplier → GenerateTicket → PublishEvents → Notifypricing with decorators
interface Priced { Money amount(); } class BaseFare implements Priced { … } abstract class FareAddon implements Priced { protected final Priced inner; public Money amount() { return inner.amount().plus(feeAmount()); } protected abstract Money feeAmount(); } // SeatSelection, MealUpgrade, InsurancePlan, ConvenienceFee…facade
class BookingFacade { BookingResponse book(BookingRequest r) { var ctx = new BookingContext(r); checkoutChain.handle(ctx); // may throw, triggers saga return BookingResponse.from(ctx.booking); } }
Draw this on the whiteboard. Every transition has a trigger event and, ideally, a compensation.
[INITIATED]
│ selectProduct
▼
[CART_READY] ── holdFailed ──► [ABANDONED]
│ holdOk
▼
[HELD] ─── holdExpired ───► [EXPIRED] (auto-release)
│ paymentSubmitted
▼
[PAYMENT_PENDING] ── paymentFailed ──► [PAYMENT_FAILED] ──► release hold
│ paymentCaptured
▼
[PAID] ── supplierConfirmFailed ──► [SUPPLIER_FAILED] ──► refund (saga)
│ supplierConfirmed
▼
[CONFIRMED] ── userCancel ──► [CANCELLATION_REQUESTED]
│ applyFareRules
▼
[REFUND_PENDING] ──► [REFUNDED] / [PARTIAL_REFUNDED]
Invariant to verbalise: money only moves with inventory, and inventory only commits with money. Any divergence → saga compensates.
This is where LLD interviews separate mid from senior candidates. Be specific.
version column). Update with WHERE version = ? — if 0 rows updated, retry.SELECT … FOR UPDATE) when a single row represents hot inventory (flash sale).DECR / Lua script for inventory counters in high-QPS search→hold.(flightId, seatNumber, date) as final safety net.Idempotency-Key header on every POST to /book and /pay.(key → response) in Redis with TTL; replays return cached response.payment.idempotencyKey) prevents double-charge on retry.expiresAt. A background job sweeps expired holds and releases inventory.expiresAt > now under lock before capturing.PENDING and reconcile async — never assume failure.Payable = Base + Tax + Addons − Discount. A booking can be paid via multiple Payment rows (wallet ₹500 + UPI ₹2300). Invariant: sum(payments.amount) == booking.payable. Each Payment has its own status and gateway reference; the booking only transitions to PAID when all payments are captured.
Apply in a defined order to avoid gaming: coupon → loyalty → wallet → gateway. A DiscountEngine (chain of CouponStrategy, LoyaltyStrategy, WalletStrategy) produces a final PriceBreakup. Stackability rules live in config, not code — new coupon type = new row.
Refund original payment method first (RBI requirement). If a payment was split across wallet + card, refund each proportionally. Refund state: REQUESTED → INITIATED → SUCCESS / FAILED; failed refunds retry with jitter. Full trace via refundId → paymentId → bookingId.
These are the follow-ups I've personally been asked or asked as interviewer. Answer briefly, lead with the invariant.
SUPPLIER_FAILED, trigger refund to original method, release hold, notify user. Never auto-retry supplier blindly — do 1–2 idempotent retries, then compensate. Log everything for reconciliation.UPDATE … WHERE version = ?) or Redis Lua atomic decrement. Loser gets a "sold out, please pick another" response before payment page — never after money moves.Idempotency-Key when entering the pay screen. Server de-dupes by that key in Redis (24h TTL) and forwards the same key to the gateway. Any retry returns the first attempt's result.TravelProduct, plug a CruiseInventoryProvider, extend AbstractBookingService. The checkout chain, state machine, payment, notifications reuse as-is. This is why we chose Template Method + Strategy up front.PricingStrategy plugs in — a DynamicPricingStrategy calls an ML service with context (occupancy, time-to-departure, user segment). Cache the output with short TTL keyed on (productId, segment); fall back to base fare on ML timeout.CancellationPolicy.compute(booking, now): pure function, slab-based (e.g. ">48h → 90%", "24–48h → 50%", "<24h → 0%"). Airlines send these slabs via fare rules; we store them with the booking so refund math is reproducible even if rules change later.event to an append-only event log (Kafka + persistence). Each event has entityId, actor, before, after, reason. Reconciliation jobs replay to detect divergence.maxUsesPerUser, globalMaxUses, validFrom/To. Apply decrement atomically (Redis INCR against limit). Bind to user & booking on success. Abuse detection: count applications per device/IP over window.PaymentGateway, InventoryProvider). Use in-memory fakes for unit tests, contract tests against sandbox, end-to-end on staging with provider sandboxes. Property tests for state machine transitions.Asia/Kolkata) + destination zone. Display in local zone. Fare rules (e.g. ">24h before departure") are computed against departure local time, not UTC.PARTIAL_CONFIRMED and action is explicit, not silent.SearchStrategy has a CachedSearchStrategy decorator; the domain classes don't change.Run through this mentally in the last 5 minutes — catching a miss here adds one full point to the final rating.
TravelProduct) earlyFinal line to say: "I've kept the domain model small and pushed variability into strategies. That way, adding a new travel product or payment method is additive — no change to the core booking lifecycle. Happy to deep-dive into any piece."