cswork / libraryOpen practice workspace

Articles

Vending Machine Low-Level Design Interview: Payments, Change, and Failed Vends

Prepare for a vending machine low-level design interview with exact payments, bounded change, failed-vend recovery, state transitions, and useful tests.

A vending machine low-level design interview tests more than the happy path: the machine can take money and still fail to finish the sale. Your answer should explain who owns the customer's balance, whether the last item is still available, and what happens if the dispenser or refund mechanism fails. Start with those rules before choosing a design pattern.

The practical approach is to separate payment accounting, inventory reservation, change planning and physical delivery. Validate a purchase before issuing the vend command, then record what the controller actually confirms. A missing acknowledgement needs an unresolved state; it does not justify dispensing again.

For a concrete starting prompt, open Implement a Vending Machine. The worked design below is original practice, with explicitly chosen failure behavior. It is not a claim about one employer's current interview process.

Agree on the machine you are designing

Ask whether the exercise expects cash, cards or both; one active customer or multiple sessions; and a synchronous simulator or a real controller. Clarify whether inserted bills remain separately returnable until the sale completes. That last assumption changes cancellation substantially: returning held bills is different from constructing a refund from the machine's general cash inventory.

Our practice scope is one machine, one customer session, integer amounts in cents, fixed accepted denominations and one item per purchase. An inventory slot has a product identifier, price and on-hand quantity. The machine can reserve one unit, request a vend and receive one of three simulated outcomes: confirmed delivery, confirmed non-delivery or unknown outcome.

A card-payment version adds authorization, capture and refund decisions. Do not quietly mix that flow with cash escrow. Explain which action transfers value and when it becomes irreversible. A method called pay() hides too much if the interviewer needs to know whether it merely reserves funds or completes a charge.

Keep selection separate from purchase commitment. A customer may inspect an unavailable slot without creating a financial transaction. Once a vend is in progress, competing button presses should observe that purchase's status rather than start another sale.

Define money and stock invariants first

For each session, the inserted amount must equal the sum of completed sales, confirmed cash returned, active credit and unresolved refund liability. In the worked model, these categories do not overlap. A pending refund is the customer's money still owed; clearing the displayed balance does not return their payment.

For a slot, available stock equals on-hand stock minus reserved units. Neither quantity may become negative. Reserve before allowing a purchase to issue its physical command. Deduct on-hand stock when delivery is confirmed; release the reservation when non-delivery is confirmed. Keep the reservation while delivery is unknown.

These are design choices for this exercise, rather than a universal hardware accounting standard. If a controller reports that a jammed item has been damaged or removed from sale, reconcile that observation separately. Releasing a reservation does not magically restore a physically lost product.

Use exact money representation. Integer cents are adequate for this single-currency exercise. If using Java decimal amounts, Oracle's BigDecimal documentation defines arbitrary-precision decimal operations and explicit rounding behavior. Choose construction and rounding deliberately; do not introduce binary floating-point values into the purchase calculation.

Give the session a stable identifier so a late controller callback can find the purchase it belongs to. Record the selected product, agreed price, inserted denominations, reserved change and controller command identifier. An operator needs these facts to explain a disputed purchase after the user interface has stopped showing it.

Plan finite change without changing the cash inventory

The customer inserts 1,000 cents for a 650-cent product. The machine owes 350 cents and holds one 200-cent unit, one 100-cent unit and one 50-cent unit available for change. A valid plan is 200 + 100 + 50. Reserve those units before starting the vend.

If the 50-cent unit is unavailable, having 300 cents in smaller denominations does not make the 350-cent obligation payable. Reject this purchase before dispensing and return the original held payment under our escrow assumption. If that return fails, keep a refund liability and stop accepting another customer.

Planning must not decrement denomination counts as it explores alternatives. Build a proposed combination from a snapshot, then reserve the complete combination. A failed search must leave the cash inventory untouched. This makes change calculation a useful pure function with a clear input and result.

Greedy selection also needs justification. In an original artificial denomination set, change of six units is possible with two threes. Taking the largest available unit, five, leaves an impossible remainder of one. A greedy failure therefore does not prove that no solution exists. Even familiar denominations require attention to finite counts.

For a small denomination set, bounded search with memoization is easy to explain. Search how many units of each denomination to take, never exceeding its available count. Return any feasible plan unless the prompt explicitly requires the fewest pieces. Minimizing coin count is a separate objective with additional comparisons.

# Original bounded feasibility search; cash is never mutated.
def plan_change(amount, cash):
    items = sorted(cash.items(), reverse=True)
    def search(i, remaining):
        if remaining == 0:
            return {}
        if i == len(items):
            return None
        value, count = items[i]
        for n in range(min(count, remaining // value), -1, -1):
            rest = search(i + 1, remaining - n * value)
            if rest is not None:
                return {**rest, **({value: n} if n else {})}
        return None
    return search(0, amount)

This short version omits memoization and assumes validated positive denominations and nonnegative counts. Its search can grow combinatorially. Name that limitation instead of presenting a teaching implementation as a scalable cash-management service.

Trace a failed vend without losing the refund

Consider the same 1,000-cent payment, 650-cent product and 350-cent change plan. The slot starts with two units. The following original trace assumes the controller explicitly confirms that no item was delivered and the refund mechanism initially fails.

EventInventory and cashCustomer obligation
Payment heldOn hand 2, reserved 0; inserted bill held separatelyActive credit 1,000 cents
Purchase preparedOn hand 2, reserved 1; change 350 reservedActive credit 1,000 cents
Non-delivery confirmedOn hand 2, reserved 0; change reservation releasedRefund liability 1,000 cents; active credit 0
Refund attempt failsNo stock or cash-return commitmentRefund liability remains 1,000 cents
Refund confirmed laterOriginal payment returned; session closesReturned 1,000 cents; liability 0

The sale never earns 650 cents because delivery did not complete. The pending refund prevents the machine from treating the session as finished. Repeating cancel() must not erase the liability or issue two independent refund commands.

Contrast this with a successful delivery followed by failed change payout. The product has already left the machine, so restoring stock would create a fictional item. Record the completed 650-cent sale and keep a 350-cent payout obligation until confirmed. The recovery depends on which physical effect happened, not merely which method raised an exception.

Treat an unknown result as its own state

Suppose the controller receives the command, but the acknowledgement is lost. The application cannot infer that nothing happened. Without controller deduplication, a second command may deliver a second item. Keep the purchase in OutcomeUnknown, retain its reservation and inspect status using the same command identity when the controller supports that operation.

If the hardware offers neither deduplication nor status lookup, acknowledge the limit. The application can prevent a new logical purchase locally, but it cannot prove a physical exactly-once effect from a timeout. An operator or sensor-based reconciliation step may be necessary. State that dependency clearly.

Suggested states are Idle, Collecting, Prepared, Vending, OutcomeUnknown, RefundPending and Completed. These names describe responsibilities; an enum with guarded transitions can be a suitable first implementation. Separate state classes become useful when each state's behavior grows, but they do not replace the transition rules.

A database transaction can make related local writes atomic. PostgreSQL's transaction tutorial describes grouping database operations with commit or rollback. That guarantee does not include an external motor command. Persisting a purchase and commanding a dispenser remain separate steps requiring explicit recovery.

For a card-payment extension, provider behavior matters too. Stripe's idempotent-request documentation describes replaying a request with the same key and checking request parameters. That is a provider-specific contract, not evidence that a vending controller supports the same capability. Keep the two boundaries distinct.

Assign responsibilities around the failure boundary

An Inventory component handles availability and reservation tokens. A CashStore owns denomination counts and complete change reservations. A Purchase record owns customer credit, price, command identity and obligations. A Dispenser adapter translates commands and observations. The coordinating machine enforces valid transitions.

Keep a reservation token tied to one purchase. Otherwise, a late callback can accidentally release another customer's stock or cash. Check the purchase identity and current phase before accepting delivery or refund events. A duplicate success event should return the already recorded result.

For one machine, serializing state changes is a reasonable starting choice. Hold a short lock while validating and reserving; do not hold it indefinitely while waiting on hardware. Mark the purchase in flight, release the lock, then process the callback through another guarded transition.

Price changes and restocking also need boundaries. Freeze the agreed price when preparing the purchase. Let operator updates affect later sessions. Restocking can add physical stock, but it must not clear an unresolved vend or refund automatically. A larger inventory count is not evidence that an earlier command failed.

Demonstrate the rules with failure-focused tests

We ran ten tests against an original in-memory Python model with a fake controller, not a physical machine or payment provider. Its tests cover exact payment, feasible and impossible change, the artificial greedy counterexample, failed-vend refunds, unknown outcomes, duplicate commands and unavailable stock.

The important assertion is the final ledger, not only a return value. After a failed refund, assert that liability remains 1,000 cents, stock is still two and no second vend occurred. After an unknown outcome, assert that one unit remains reserved. After duplicate delivery confirmation, assert that stock decreased once.

Inject outcomes through an adapter instead of relying on random failures. Record controller calls so the test can prove that a rejection issued no vend command. Snapshot denomination counts before an impossible change search and compare them afterward. Those assertions catch side effects that a happy-path test misses.

These local controls verify the teaching model's transitions and arithmetic. They do not establish crash persistence, concurrent-process safety, a hardware protocol or banking settlement. If the interviewer adds restart recovery, describe how a durable purchase journal would reload unresolved commands and obligations before the machine accepts new sessions.

Sources and Further Reading

Research note: Official references and practice destinations checked on October 5, 2026. The machine model and failure traces are original exercises.