Skip to content

Vending machine

Version 1 — Simple, cash-only (have this cold)

public class Product
{
    public string ProductId { get; init; }
    public string Name { get; init; }
}

public class Slot
{
    public string SlotId { get; init; }
    public Product Product { get; set; }
    public decimal UnitPrice { get; set; }
    public int Stock { get; set; }

    public bool IsAvailable => Stock > 0;

    public void Dispense()
    {
        if (!IsAvailable) throw new InvalidOperationException("Slot empty");
        Stock -= 1;
    }
}

public enum MachineState { Idle, HasMoney, Dispensing }

public class SelectionResult
{
    public bool IsSuccess { get; init; }
    public string Message { get; init; }
    public decimal ChangeDue { get; init; }

    public static SelectionResult OutOfStock() =>
        new() { IsSuccess = false, Message = "Out of stock" };

    public static SelectionResult InsufficientBalance(decimal shortBy) =>
        new() { IsSuccess = false, Message = $"Insert {shortBy} more" };

    public static SelectionResult Dispensed(decimal change) =>
        new() { IsSuccess = true, Message = "Dispensed", ChangeDue = change };
}

public class VendingMachine
{
    private readonly Dictionary<string, Slot> _slots;
    private MachineState _state = MachineState.Idle;
    private decimal _balance;

    public VendingMachine(IEnumerable<Slot> slots)
    {
        _slots = slots.ToDictionary(s => s.SlotId);
    }

    public void InsertCoin(decimal amount)
    {
        _balance += amount;
        _state = MachineState.HasMoney;
    }

    public SelectionResult SelectSlot(string slotId)
    {
        if (_state != MachineState.HasMoney)
            throw new InvalidOperationException("Insert money first");

        var slot = _slots.GetValueOrDefault(slotId)
            ?? throw new InvalidOperationException("No such slot");

        if (!slot.IsAvailable)
            return SelectionResult.OutOfStock();

        if (_balance < slot.UnitPrice)
            return SelectionResult.InsufficientBalance(slot.UnitPrice - _balance);

        return Dispense(slot);
    }

    private SelectionResult Dispense(Slot slot)
    {
        _state = MachineState.Dispensing;

        slot.Dispense();
        var change = _balance - slot.UnitPrice;
        _balance = 0;

        _state = MachineState.Idle;
        return SelectionResult.Dispensed(change);
    }

    public decimal Cancel()
    {
        var refund = _balance;
        _balance = 0;
        _state = MachineState.Idle;
        return refund;
    }
}

Version 2 — Multi-payment (Strategy + DI Factory)

public enum PaymentType { Cash, Upi }

public class PaymentResult
{
    public bool IsSuccess { get; init; }
    public string FailureReason { get; init; }

    public static PaymentResult Success() => new() { IsSuccess = true };
    public static PaymentResult Failed(string reason) => new() { IsSuccess = false, FailureReason = reason };
}

public interface IPaymentMethod
{
    PaymentType Type { get; }
    void Credit(decimal amount);
    PaymentResult Settle(decimal amount);
    decimal Refund();
}

public class CashPaymentMethod : IPaymentMethod
{
    public PaymentType Type => PaymentType.Cash;
    private decimal _wallet;

    public void Credit(decimal amount) => _wallet += amount;

    public PaymentResult Settle(decimal amount)
    {
        if (_wallet < amount) return PaymentResult.Failed($"Short by {amount - _wallet}");
        _wallet -= amount;
        return PaymentResult.Success();
    }

    public decimal Refund()
    {
        var leftover = _wallet;
        _wallet = 0;
        return leftover;
    }
}

public interface IUpiGateway
{
    (bool Success, string Reason, string TxnRef) ConfirmPayment(decimal amount);
    void RefundFull(string txnRef, decimal amount);
}

public class UpiPaymentMethod : IPaymentMethod
{
    public PaymentType Type => PaymentType.Upi;
    private readonly IUpiGateway _gateway;
    private decimal _wallet;
    private string _txnRef;

    public UpiPaymentMethod(IUpiGateway gateway) => _gateway = gateway;

    public void Credit(decimal amount)
    {
        var (success, reason, txnRef) = _gateway.ConfirmPayment(amount);
        if (!success) throw new InvalidOperationException(reason);
        _wallet += amount;
        _txnRef = txnRef;
    }

    public PaymentResult Settle(decimal amount)
    {
        if (_wallet < amount) return PaymentResult.Failed($"Short by {amount - _wallet}");
        _wallet -= amount;
        return PaymentResult.Success();
    }

    public decimal Refund()
    {
        var leftover = _wallet;
        _wallet = 0;
        if (leftover > 0) _gateway.RefundFull(_txnRef, leftover);
        return leftover;
    }
}

public interface IPaymentMethodFactory
{
    IPaymentMethod Create(PaymentType type);
}

public class PaymentMethodFactory : IPaymentMethodFactory
{
    private readonly IServiceProvider _serviceProvider;
    private readonly Dictionary<PaymentType, Type> _typeMap;

    public PaymentMethodFactory(IServiceProvider serviceProvider, IEnumerable<IPaymentMethod> discovery)
    {
        _serviceProvider = serviceProvider;
        _typeMap = discovery.ToDictionary(m => m.Type, m => m.GetType());
    }

    public IPaymentMethod Create(PaymentType type) =>
        (IPaymentMethod)_serviceProvider.GetRequiredService(_typeMap[type]);
}

public enum MachineState { Idle, PaymentSelected, Credited }

public class VendingMachine
{
    private readonly Dictionary<string, Slot> _slots;
    private readonly IPaymentMethodFactory _paymentFactory;
    private IPaymentMethod _paymentMethod;
    private MachineState _state = MachineState.Idle;

    public VendingMachine(IEnumerable<Slot> slots, IPaymentMethodFactory paymentFactory)
    {
        _slots = slots.ToDictionary(s => s.SlotId);
        _paymentFactory = paymentFactory;
    }

    public void SelectPaymentMethod(PaymentType type)
    {
        if (_state != MachineState.Idle)
            throw new InvalidOperationException("Transaction already in progress");

        _paymentMethod = _paymentFactory.Create(type);
        _state = MachineState.PaymentSelected;
    }

    public void Credit(decimal amount)
    {
        if (_state != MachineState.PaymentSelected && _state != MachineState.Credited)
            throw new InvalidOperationException("Select a payment method first");

        _paymentMethod.Credit(amount);
        _state = MachineState.Credited;
    }

    public SelectionResult SelectSlot(string slotId)
    {
        if (_state != MachineState.Credited)
            throw new InvalidOperationException("Insert/credit money first");

        var slot = _slots.GetValueOrDefault(slotId)
            ?? throw new InvalidOperationException("No such slot");

        if (!slot.IsAvailable)
            return SelectionResult.OutOfStock();

        var result = _paymentMethod.Settle(slot.UnitPrice);
        if (!result.IsSuccess)
            return SelectionResult.PaymentFailed(result.FailureReason);

        slot.Dispense();
        var change = _paymentMethod.Refund();

        _paymentMethod = null;
        _state = MachineState.Idle;
        return SelectionResult.Dispensed(change);
    }

    public decimal Cancel()
    {
        var refund = _paymentMethod?.Refund() ?? 0;
        _paymentMethod = null;
        _state = MachineState.Idle;
        return refund;
    }
}

(SelectionResult needs a PaymentFailed factory method added alongside the ones already shown in Version 1 — public static SelectionResult PaymentFailed(string reason) => new() { IsSuccess = false, Message = reason };)

DI registration for Version 2:

services.AddTransient<IPaymentMethod, CashPaymentMethod>();
services.AddTransient<IPaymentMethod, UpiPaymentMethod>();
services.AddSingleton<IPaymentMethodFactory, PaymentMethodFactory>();

Version 1 is what to lead with in the actual interview. Version 2 is your answer to "how would you extend this."