ATM Machine System Design Explained | Full Code & Architecture
This chapter provides a comprehensive, low-level design (LLD) blueprint for an Automated Teller Machine (ATM) system. We will explore how to model the complex lifecycle of an ATM using the State Design Pattern, manage cash inventory using a greedy dispensing algorithm, and transition from a simplified interview-focused codebase to a production-grade, decoupled architecture.
1. The Problem Space & Core Requirements
An Automated Teller Machine (ATM) is a specialized kiosk that allows bank customers to perform financial transactions without a human teller. Designing an ATM system is a classic low-level design interview problem that tests your ability to manage complex state transitions, handle hardware-software boundaries, and write clean, extensible code under time constraints.
Core Functional Requirements
- User Authentication: Securely read a debit card, prompt for a Personal Identification Number (PIN), and validate it against the bank's database.
- Transaction Processing: Support essential banking operations, primarily focusing on:
- Cash Withdrawal: Dispense cash in valid denominations if the account has sufficient balance and the ATM has enough inventory.
- Balance Inquiry: Retrieve and display the current account balance.
- Cash Inventory Management: Track the quantity of physical bills available inside the machine across multiple denominations (e.g., 50, 10).
- Card Lifecycle Management: Retain or return the card safely at the end of the session or upon cancellation.
Non-Functional & Real-World Considerations
- Audit Logging: Every single user interaction (e.g., language selection, button presses, screen transitions) must be logged for security and compliance.
- Security: PINs must never be stored or transmitted in plain text; they must be hashed using secure cryptographic algorithms (e.g., bcrypt, SHA-256) with salts.
- Maintenance Mode: The system must support a maintenance state where technicians can refill cash, run diagnostics, or update software, rendering the machine temporarily unavailable to customers.
2. Core Mental Model & State Transitions
An ATM is fundamentally a Finite State Machine (FSM). It transitions from one state to another based on user actions or internal system events.
Real-Life Analogy
Think of an ATM like a secure self-service laundromat. You cannot start the washing machine (perform a transaction) until you have inserted your token (inserted your card) and selected your wash cycle (entered your PIN and selected an operation). If you press "Cancel" at any point, your token is returned, and the machine resets to its default, waiting state.
State Transition Flow
State Transition Matrix
3. Design Patterns & Architectural Decisions
To build a clean, maintainable, and extensible ATM system, we leverage several key software design patterns:
A. The State Design Pattern (Core Pattern)
- Why: An ATM has highly conditional behavior based on its current state. Using naive
if-elseorswitch-caseblocks to check states across every method leads to spaghetti code that is incredibly difficult to maintain or extend. - How: We define an
ATMStateinterface. Each state is represented by a concrete class (e.g.,IdleState,HasCardState) that implements state-specific behavior and defines transitions to the next state.
B. The Factory Design Pattern
- Why: Centralizes the creation of state objects, decoupling the state transition logic from concrete state instantiation.
- How: An
ATMStateFactorymanages and returns instances of the concrete states.
C. The Singleton Design Pattern
- Why: There is only one physical ATM machine instance running on the local hardware.
- How: The state factory or the main controller can be implemented as a Singleton to ensure global access to a single, consistent state.
D. The Observer Design Pattern
- Why: When a transaction completes, multiple decoupled systems need to be notified (e.g., the receipt printer, the bank's notification service to send an SMS/email, and the local audit logger).
- How: The transaction processor acts as a Subject, and the printer, SMS gateway, and audit logger act as Observers.
4. Naive vs. Production-Grade Decoupling
In an interview, you have limited time (typically 45–60 minutes). You must balance writing clean code with completing the core state machine logic.
The Naive/Interview Approach
To save time, we can consolidate operations directly inside the ATMContext class. The context acts as both the state holder and the execution manager. While this violates the Single Responsibility Principle (SRP), it is highly practical for demonstrating the State Pattern quickly.
The Production-Grade Decoupled Approach
In a real production codebase, the ATMContext should only manage state transitions. All other responsibilities are delegated to dedicated controllers and services:
Architectural Comparison
5. Cash Inventory & Greedy Dispensing Algorithm
An ATM must dispense physical cash using the minimum number of bills possible. This is a classic optimization problem solved using a Greedy Algorithm.
The Greedy Strategy
To dispense an amount , we iterate through our available cash denominations in descending order (from largest to smallest). For each denomination , we calculate the maximum number of bills we can dispense based on the remaining amount and our current inventory.
Let:
- be the target withdrawal amount.
- be the value of denomination , where .
- be the count of available bills of denomination in the inventory.
- be the number of bills of denomination to dispense.
For each denomination from to :
If at the end of the loop, the withdrawal is successful, and we deduct the bills from the inventory. If , the ATM cannot dispense the exact amount (either due to insufficient cash or lack of matching denominations), and the transaction is aborted.
Algorithmic Complexity
- Time Complexity: where is the number of unique denominations supported by the ATM. Since is a small constant (typically ), this runs in time.
- Space Complexity: to store the map of dispensed bills.
6. Step-by-Step State Design Pattern Implementation
Let's implement the ATM system in Java. We begin by defining our domain models, enums, and the cash inventory system.
Step 1: Domain Models and Enums
package domain;
public enum CashDenomination {
HUNDRED(100),
FIFTY(50),
TWENTY(20),
TEN(10);
private final int value;
CashDenomination(int value) {
this.value = value;
}
public int getValue() {
return value;
}
}
package domain;
public enum TransactionType {
WITHDRAW_CASH,
CHECK_BALANCE
}
package domain;
public class Card {
private final String cardNumber;
private final String hashedPin;
private final String accountNumber;
public Card(String cardNumber, String hashedPin, String accountNumber) {
this.cardNumber = cardNumber;
this.hashedPin = hashedPin;
this.accountNumber = accountNumber;
}
public String getCardNumber() {
return cardNumber;
}
public boolean verifyPin(String inputPin) {
// In production, use a secure hashing library: BCrypt.checkpw(inputPin, hashedPin)
return this.hashedPin.equals(inputPin);
}
public String getAccountNumber() {
return accountNumber;
}
}
package domain;
public class Account {
private final String accountNumber;
private double balance;
public Account(String accountNumber, double initialBalance) {
this.accountNumber = accountNumber;
this.balance = initialBalance;
}
public String getAccountNumber() {
return accountNumber;
}
public double getBalance() {
return balance;
}
public synchronized void deductBalance(double amount) {
if (amount <= balance) {
this.balance -= amount;
} else {
throw new IllegalArgumentException("Insufficient account balance.");
}
}
}
Step 2: Cash Inventory Management
The inventory class tracks available bills and implements the greedy dispensing algorithm.
package inventory;
import domain.CashDenomination;
import java.util.EnumMap;
import java.util.Map;
public class ATMInventory {
private final Map<CashDenomination, Integer> cashStock = new EnumMap<>(CashDenomination.class);
public ATMInventory() {
// Initialize with default stock
cashStock.put(CashDenomination.HUNDRED, 10); // $1000
cashStock.put(CashDenomination.FIFTY, 20); // $1000
cashStock.put(CashDenomination.TWENTY, 50); // $1000
cashStock.put(CashDenomination.TEN, 100); // $1000
}
public synchronized void addCash(CashDenomination denomination, int count) {
cashStock.put(denomination, cashStock.getOrDefault(denomination, 0) + count);
}
public synchronized double getTotalCashValue() {
double total = 0;
for (Map.Entry<CashDenomination, Integer> entry : cashStock.entrySet()) {
total += entry.getKey().getValue() * entry.getValue();
}
return total;
}
/**
* Greedy algorithm to dispense cash using the largest denominations first.
*/
public synchronized Map<CashDenomination, Integer> dispenseCash(int amount) {
if (amount > getTotalCashValue()) {
throw new IllegalStateException("ATM has insufficient physical cash.");
}
Map<CashDenomination, Integer> dispensedBills = new EnumMap<>(CashDenomination.class);
int remainingAmount = amount;
// Iterate through denominations from largest to smallest
CashDenomination[] denominations = CashDenomination.values();
for (CashDenomination denom : denominations) {
int denomVal = denom.getValue();
int availableBills = cashStock.getOrDefault(denom, 0);
int billsNeeded = remainingAmount / denomVal;
int billsToDispense = Math.min(billsNeeded, availableBills);
if (billsToDispense > 0) {
dispensedBills.put(denom, billsToDispense);
remainingAmount -= billsToDispense * denomVal;
}
}
if (remainingAmount != 0) {
throw new IllegalStateException("ATM cannot dispense the exact requested amount with available denominations.");
}
// Commit the transaction by deducting bills from stock
for (Map.Entry<CashDenomination, Integer> entry : dispensedBills.entrySet()) {
cashStock.put(entry.getKey(), cashStock.get(entry.getKey()) - entry.getValue());
}
return dispensedBills;
}
}
Step 3: State Interface and Concrete States
The ATMState interface defines the contract for all states. Each concrete state class handles its specific transition logic.
package state;
import context.ATMContext;
public interface ATMState {
ATMState nextState(ATMContext context);
}
Concrete State: IdleState
package state;
import context.ATMContext;
public class IdleState implements ATMState {
@Override
public ATMState nextState(ATMContext context) {
if (context.getInsertedCard() != null) {
System.out.println("[State Transition] Card detected. Moving to HasCardState.");
return new HasCardState();
}
return this;
}
}
Concrete State: HasCardState
package state;
import context.ATMContext;
public class HasCardState implements ATMState {
@Override
public ATMState nextState(ATMContext context) {
if (context.isAuthenticated()) {
System.out.println("[State Transition] PIN authenticated. Moving to SelectOperationState.");
return new SelectOperationState();
} else if (context.getInsertedCard() == null) {
System.out.println("[State Transition] Card ejected. Returning to IdleState.");
return new IdleState();
}
return this;
}
}
Concrete State: SelectOperationState
package state;
import context.ATMContext;
public class SelectOperationState implements ATMState {
@Override
public ATMState nextState(ATMContext context) {
if (context.getSelectedTransaction() != null) {
System.out.println("[State Transition] Operation selected. Moving to TransactionState.");
return new TransactionState();
} else if (context.getInsertedCard() == null) {
System.out.println("[State Transition] Session cancelled. Ejecting card and returning to IdleState.");
return new IdleState();
}
return this;
}
}
Concrete State: TransactionState
package state;
import context.ATMContext;
public class TransactionState implements ATMState {
@Override
public ATMState nextState(ATMContext context) {
// After processing a transaction, we return to SelectOperationState to allow more actions.
if (context.getInsertedCard() != null) {
System.out.println("[State Transition] Transaction completed. Returning to SelectOperationState.");
context.clearSelectedTransaction();
return new SelectOperationState();
}
System.out.println("[State Transition] Resetting to IdleState.");
return new IdleState();
}
}
Step 4: State Factory
The ATMStateFactory centralizes state instantiation.
package state;
public class ATMStateFactory {
public static ATMState getState(String stateType) {
switch (stateType.toUpperCase()) {
case "IDLE":
return new IdleState();
case "HAS_CARD":
return new HasCardState();
case "SELECT_OPERATION":
return new SelectOperationState();
case "TRANSACTION":
return new TransactionState();
default:
throw new IllegalArgumentException("Unknown state type: " + stateType);
}
}
}
7. The ATM Context: The Orchestrator
The ATMContext coordinates the current state, handles user inputs, and executes business logic.
package context;
import domain.Account;
import domain.Card;
import domain.CashDenomination;
import domain.TransactionType;
import inventory.ATMInventory;
import state.ATMState;
import state.IdleState;
import state.HasCardState;
import state.SelectOperationState;
import state.TransactionState;
import java.util.HashMap;
import java.util.Map;
public class ATMContext {
private ATMState currentState;
private final ATMInventory inventory;
private final Map<String, Account> bankDatabase;
// Session-specific variables
private Card insertedCard;
private boolean isAuthenticated;
private TransactionType selectedTransaction;
private Account activeAccount;
public ATMContext() {
this.currentState = new IdleState();
this.inventory = new ATMInventory();
this.bankDatabase = new HashMap<>();
this.isAuthenticated = false;
}
// Helper to register accounts in our mock database
public void registerAccount(Account account) {
bankDatabase.put(account.getAccountNumber(), account);
}
public void advanceState() {
this.currentState = this.currentState.nextState(this);
}
// --- User Actions ---
public void insertCard(Card card) {
if (!(currentState instanceof IdleState)) {
throw new IllegalStateException("Cannot insert card in current state: " + currentState.getClass().getSimpleName());
}
this.insertedCard = card;
System.out.println("Card inserted successfully: " + card.getCardNumber());
advanceState(); // Transition to HasCardState
}
public void enterPin(String pin) {
if (!(currentState instanceof HasCardState)) {
throw new IllegalStateException("Cannot enter PIN in current state: " + currentState.getClass().getSimpleName());
}
if (insertedCard.verifyPin(pin)) {
this.isAuthenticated = true;
this.activeAccount = bankDatabase.get(insertedCard.getAccountNumber());
System.out.println("PIN verified successfully.");
advanceState(); // Transition to SelectOperationState
} else {
System.out.println("Invalid PIN entered.");
cancelTransaction();
}
}
public void selectOperation(TransactionType type) {
if (!(currentState instanceof SelectOperationState)) {
throw new IllegalStateException("Cannot select operation in current state: " + currentState.getClass().getSimpleName());
}
this.selectedTransaction = type;
System.out.println("Operation selected: " + type);
advanceState(); // Transition to TransactionState
}
public void executeWithdrawal(int amount) {
if (!(currentState instanceof TransactionState)) {
throw new IllegalStateException("Cannot execute transaction in current state: " + currentState.getClass().getSimpleName());
}
if (selectedTransaction != TransactionType.WITHDRAW_CASH) {
throw new IllegalStateException("Selected transaction is not withdrawal.");
}
System.out.println("Processing withdrawal of $" + amount + "...");
try {
// 1. Validate account balance
if (activeAccount.getBalance() < amount) {
throw new IllegalArgumentException("Insufficient funds in bank account.");
}
// 2. Attempt to dispense cash from physical inventory (Greedy Algorithm)
Map<CashDenomination, Integer> dispensed = inventory.dispenseCash(amount);
// 3. Deduct from bank account
activeAccount.deductBalance(amount);
System.out.println("Withdrawal successful! Dispensed bills:");
dispensed.forEach((denom, count) -> System.out.println(" $" + denom.getValue() + " x " + count));
System.out.println("Remaining account balance: $" + activeAccount.getBalance());
} catch (Exception e) {
System.out.println("Transaction Failed: " + e.getMessage());
} finally {
advanceState(); // Transition back to SelectOperationState
}
}
public void executeCheckBalance() {
if (!(currentState instanceof TransactionState)) {
throw new IllegalStateException("Cannot execute transaction in current state: " + currentState.getClass().getSimpleName());
}
if (selectedTransaction != TransactionType.CHECK_BALANCE) {
throw new IllegalStateException("Selected transaction is not balance inquiry.");
}
System.out.println("Account Balance: $" + activeAccount.getBalance());
advanceState(); // Transition back to SelectOperationState
}
public void cancelTransaction() {
System.out.println("Cancelling transaction and resetting session...");
returnCard();
}
public void returnCard() {
if (insertedCard != null) {
System.out.println("Ejecting card: " + insertedCard.getCardNumber() + ". Please collect your card.");
}
// Reset session state
this.insertedCard = null;
this.isAuthenticated = false;
this.selectedTransaction = null;
this.activeAccount = null;
// Force state transition back to Idle
this.currentState = new IdleState();
System.out.println("ATM is now IDLE.");
}
// --- Getters and Setters ---
public Card getInsertedCard() {
return insertedCard;
}
public boolean isAuthenticated() {
return isAuthenticated;
}
public TransactionType getSelectedTransaction() {
return selectedTransaction;
}
public void clearSelectedTransaction() {
this.selectedTransaction = null;
}
public ATMState getCurrentState() {
return currentState;
}
}
8. Complete Flow Walkthrough & Main Execution
Let's trace a complete customer session: inserting a card, authenticating, performing a cash withdrawal, checking the balance, and exiting the system.
import context.ATMContext;
import domain.Account;
import domain.Card;
import domain.TransactionType;
public class Main {
public static void main(String[] args) {
// 1. Initialize the ATM Context
ATMContext atm = new ATMContext();
// 2. Setup Mock Bank Database
Account userAccount = new Account("ACC-98765", 1500.00); // $1500 balance
atm.registerAccount(userAccount);
// Hashed PIN in DB is "1234"
Card userCard = new Card("CARD-1111-2222", "1234", "ACC-98765");
System.out.println("--- STARTING ATM SESSION ---");
System.out.println("Initial ATM State: " + atm.getCurrentState().getClass().getSimpleName());
// Step 1: Insert Card
atm.insertCard(userCard);
System.out.println("Current ATM State: " + atm.getCurrentState().getClass().getSimpleName());
// Step 2: Enter PIN
atm.enterPin("1234");
System.out.println("Current ATM State: " + atm.getCurrentState().getClass().getSimpleName());
// Step 3: Select Withdrawal Operation
atm.selectOperation(TransactionType.WITHDRAW_CASH);
System.out.println("Current ATM State: " + atm.getCurrentState().getClass().getSimpleName());
// Step 4: Execute Withdrawal of $280
// Expected bills: Two $100s, one $50, one $20, one $10
atm.executeWithdrawal(280);
System.out.println("Current ATM State: " + atm.getCurrentState().getClass().getSimpleName());
// Step 5: Select Balance Inquiry Operation
atm.selectOperation(TransactionType.CHECK_BALANCE);
System.out.println("Current ATM State: " + atm.getCurrentState().getClass().getSimpleName());
// Step 6: Execute Balance Inquiry
atm.executeCheckBalance();
System.out.println("Current ATM State: " + atm.getCurrentState().getClass().getSimpleName());
// Step 7: Finish Session and Return Card
atm.returnCard();
System.out.println("Current ATM State: " + atm.getCurrentState().getClass().getSimpleName());
System.out.println("--- SESSION ENDED ---");
}
}
Expected Output
--- STARTING ATM SESSION ---
Initial ATM State: IdleState
Card inserted successfully: CARD-1111-2222
[State Transition] Card detected. Moving to HasCardState.
Current ATM State: HasCardState
PIN verified successfully.
[State Transition] PIN authenticated. Moving to SelectOperationState.
Current ATM State: SelectOperationState
Operation selected: WITHDRAW_CASH
[State Transition] Operation selected. Moving to TransactionState.
Current ATM State: TransactionState
Processing withdrawal of $280...
Withdrawal successful! Dispensed bills:
$100 x 2
$50 x 1
$20 x 1
$10 x 1
Remaining account balance: $1220.0
[State Transition] Transaction completed. Returning to SelectOperationState.
Current ATM State: SelectOperationState
Operation selected: CHECK_BALANCE
[State Transition] Operation selected. Moving to TransactionState.
Current ATM State: TransactionState
Account Balance: $1220.0
[State Transition] Transaction completed. Returning to SelectOperationState.
Current ATM State: SelectOperationState
Ejecting card: CARD-1111-2222. Please collect your card.
ATM is now IDLE.
Current ATM State: IdleState
--- SESSION ENDED ---
9. Real-World Pitfalls, Concurrency & Edge Cases
When designing an ATM system for a production environment, you must address several critical edge cases and concurrency challenges.
A. Concurrency and Race Conditions
- The Double-Withdrawal Attack: A malicious user might attempt to withdraw money from two different ATMs simultaneously using two cards linked to the same bank account.
- Solution: Use database-level locking (e.g., pessimistic locking:
SELECT * FROM Account WHERE id = :id FOR UPDATE) or distributed locks (e.g., Redis/Redlock) to ensure that only one transaction can modify an account balance at any given time. - Local Inventory Locking: Use Java's
synchronizedkeyword or explicitReentrantLockinstances on theATMInventoryclass to prevent multiple threads from dispensing the same physical bills.
B. Hardware Failures & Transaction Rollbacks
- The Half-Dispense Problem: The ATM successfully deducts the balance from the bank database, but the physical dispenser jams, or the power cuts out mid-transaction.
- Solution: Implement the Saga Pattern or Two-Phase Commit (2PC). The transaction is marked as
PENDINGin the database. Only when the physical sensors confirm that the cash has cleared the shutter door does the ATM send a finalCOMMITsignal to the bank. If a hardware failure occurs, the transaction is automatically rolled back, and a reversal entry is logged.
C. Security & Tampering
- Card Skimming & PIN Sniffing: Attackers can install physical overlays on the card slot or keypad.
- Solution: Modern ATMs use EMV chip readers instead of magnetic stripe readers. The chip generates a unique, one-time cryptographic transaction token, making skimmed data useless.
10. Summary and Key Takeaways
- Finite State Machine (FSM): The ATM's lifecycle is highly state-dependent. The State Design Pattern is the cleanest way to model this behavior, eliminating complex conditional logic.
- State Transition Flow: The standard flow transitions from
IdleStateHasCardStateSelectOperationStateTransactionState, returning toSelectOperationStatefor subsequent operations, and resetting toIdleStateupon card ejection. - Greedy Dispensing Algorithm: Cash is dispensed using a greedy approach, iterating from the largest denomination to the smallest to minimize the physical bill count.
- Decoupling for Scale: While a simplified context class is acceptable in interviews, production systems must decouple responsibilities into dedicated controllers (e.g.,
AccountController,InventoryController,TransactionController). - Concurrency Control: Use pessimistic database locking and synchronized blocks to prevent race conditions during simultaneous withdrawals.
- Defensive Programming: Always implement safety nets (e.g., checking
instanceoffor states) to prevent illegal transitions and ensure the system fails safely.