Hello! Welcome to our final low-level design case study. In the previous lessons, we've designed a parking lot, a library, and an elevator system. You've seen how a structured approach combined with design patterns like State and Strategy helps us tackle complex problems involving entity relationships, state management, and algorithmic logic.
Today, we'll design an Automated Teller Machine (ATM). This classic interview problem will reinforce your understanding of the State pattern and introduce another powerful pattern for handling sequential processing.
Your learning outcome for this lesson is to design an ATM system, modeling authentication, transactions, and hardware interactions. We will once again follow our 5-step framework to build a robust and well-structured design.
- Clarify Requirements & Scope
- Identify Core Entities & Use Cases
- Design Classes & Interactions
- Implement Core Logic
- Verify & Discuss Extensions
Let's get started.
Step 1: Clarify Requirements & Scope
An interview often begins with a simple prompt like "Design an ATM." Our first task is to transform this into a concrete set of requirements. We need to understand what the system must do, what its limitations are, and what assumptions we can make.
For this, we'll consult a detailed requirements document.
ATM Case Study, Part 1: Object-Oriented Design with the ...
The PDF 'ATM Case Study, Part 1' from Pearson provides an exceptionally thorough requirements document. It details the system's purpose, user interactions, and hardware components we need to simulate. It's a great example of the level of detail needed to build a real-world system.
Please read Section 33.2, 'Examining the Requirements Document.' This section covers the complete specification, from user authentication to processing different transaction types (balance inquiry, withdrawal, deposit).
Based on this comprehensive document, let's summarize our core requirements:
-
Functional Requirements:
- User Authentication: The user must be authenticated using an account number and a PIN.
- Transactions: The system must support three main transactions:
- View Balance: Display the available and total balance.
- Withdraw Cash: Dispense cash in predefined amounts, checking against the account balance and the ATM's cash inventory.
- Deposit Funds: Accept a deposit envelope and credit the user's account.
- Hardware Simulation: The software must interact with simulated hardware components: a
Screen,Keypad,CashDispenser, andDepositSlot.
-
Non-Functional Requirements & Assumptions:
- We are designing the software for a single ATM.
- The cash dispenser starts with a fixed number of bills (e.g., 500 x $20 bills, as per the document, though we can make this more flexible).
- The system interacts with a central
BankDatabaseto manage account information. - Security is simplified; we assume the ATM's connection to the database is secure.
Step 2: Identify Core Entities & Use Cases
With clear requirements, we can now identify the primary classes needed to build our system. A systematic way to do this is by analyzing the nouns in the requirements document.
ATM Case Study, Part 1: Object-Oriented Design with the ...
The same Pearson PDF provides a methodical approach for this in the next section. It walks through the process of extracting nouns and noun phrases from the requirements to identify candidate classes.
Please read Section 33.3, 'Identifying the Classes in a Requirements Document.' Pay attention to how the author justifies which nouns become classes (like ATM, Account) and which become attributes (like PIN, balance).
This analysis leads us to the following core classes for our system:
ATM: The main machine, acting as the central controller.- Hardware Components:
Screen,Keypad,CashDispenser,DepositSlot. - Bank Components:
BankDatabase,Account. - Transaction Components:
BalanceInquiry,Withdrawal,Deposit. We'll model each transaction type as a separate class to encapsulate its specific logic.
Step 3: Design Classes & Interactions
This is the heart of our LLD process. We'll define the structure of our classes and, crucially, select the right design patterns to manage their complex interactions.
The State Pattern: Managing the ATM's Workflow
Just like in our elevator design, the ATM is a perfect candidate for the State design pattern. Its behavior is entirely dependent on its current state. For example:
- In an
Idlestate, it can only accept a card. - In a
HasCardstate, it can only accept a PIN. - In an
Authenticatedstate, it allows transaction selection. - Attempting to enter a PIN when no card is inserted should be rejected.
Using a single ATM class with large if-else or switch statements to manage these states would violate the Single Responsibility and Open/Closed principles, making the code hard to maintain. The State pattern solves this by encapsulating the logic for each state into its own class.
Low level design of an ATM machine | Understand State design pattern
To understand why this pattern is so effective here, let's watch a segment from 'Low level design of an ATM machine' by Sanket Singh. He starts with a naive implementation and clearly explains how it leads to poor design, motivating the transition to a state machine model.
Watch from 05:05 to 18:55. Focus on the progression from a monolithic ATM class to the realization that an ATM is fundamentally a state machine, moving between states like Ready, Card Reading, and Cash Dispensing based on user actions.
Now that you see why we need the State pattern, let's look at how to implement it.
Design ATM Machine (LLD) | Cash Withdrawal Flow | State + COR Pattern
The video 'Design ATM Machine (LLD)' by Shubh Patel provides a clear, Java-oriented explanation of the State pattern's implementation, detailing the ATMState interface and its concrete state classes.
Please watch from 06:25 to 12:19. Observe how each state class (IdleState, CardInsertedState, etc.) implements the common interface but provides its own logic for operations like insertCard or enterPin. This cleanly separates the concerns of each state.
The Chain of Responsibility Pattern: Dispensing Cash
One of the most interesting challenges in an ATM design is the cash dispensing logic. If a user requests, say, ₹2700, how does the machine determine the combination of notes (e.g., one ₹2000, one ₹500, one ₹200)?
This is a perfect use case for the Chain of Responsibility (CoR) design pattern. We can create a chain of dispenser objects, one for each currency denomination. The withdrawal request is passed down the chain, and each object in the chain dispenses as many notes of its denomination as it can before passing the remaining amount to the next object.
.png)
Design ATM Machine (LLD) | Cash Withdrawal Flow | State + COR Pattern
Let's return to the 'Design ATM Machine (LLD)' video by Shubh Patel, who demonstrates a brilliant application of the CoR pattern for this exact purpose.
Watch from 13:19 to 16:58. Focus on how a chain of dispensers (2000Dispenser, 500Dispenser, 100Dispenser) is constructed. This makes the system extensible—adding a new note denomination (like a ₹200 note) is as simple as adding a new link to the chain, following the Open/Closed Principle.
Other Patterns: Factory
When transitioning between states (e.g., from HasCardState to AuthenticatedState), we need to create new state objects. To avoid scattering new AuthenticatedState() calls throughout the codebase, we can use a Factory pattern. A single StateFactory class can be responsible for creating all state objects, centralizing this logic and making the system easier to manage.
Step 4: Implement Core Logic
Now, let's trace the flow of our key operations, integrating the design patterns we've chosen.
Authentication and Transaction Flow
The core of our system is the ATM class (the context), which manages the current state and delegates actions.
Design ATM Machine (LLD) | Cash Withdrawal Flow | State + COR Pattern
The 'Design ATM Machine (LLD)' video provides a complete Java code walkthrough that ties everything together. It shows how the ATM class, the State classes, and the CoR dispensers interact to fulfill a user request.
Please watch the code walkthrough from 16:58 to 30:15. This is a dense but highly valuable segment. Pay close attention to: Main Class (16:58-19:57): How the system is initialized and a sample transaction is run. State Transitions (25:20-30:15): How methods like insertCard in IdleState change the ATM's state to CardInsertedState. Dispense Logic: How the DispenseCashState performs checks and then invokes the CoR chain to dispense money.
Modeling Hardware Interactions
A key aspect of good LLD is abstracting external dependencies. Our ATM software shouldn't be concerned with the low-level details of how a physical cash dispenser works. We model hardware as simple classes with well-defined interfaces.
The Pearson PDF you read earlier (resource LINK) provides an excellent guide for this in Section 33.6, "Identifying Class Operations." It identifies operations by analyzing verbs in the requirements:
Screen->displayMessage(String message)Keypad->getInput(): intCashDispenser->dispenseCash(double amount),isSufficientCashAvailable(double amount): booleanDepositSlot->isEnvelopeReceived(): boolean
By designing to these interfaces, we decouple our core logic from the hardware. The actual implementation could be a simulation (for testing) or a real hardware driver, and our business logic wouldn't need to change.
Test your understanding!
An ATM is in the IdleState. A user walks up and presses a number on the keypad, triggering the enterPin(int pin) method on the ATM object. According to the State pattern design, what should happen?
Show answer
The ATM object will delegate the enterPin(int pin) call to its current state object, which is an instance of IdleState.
The enterPin method within the IdleState class should be implemented to do nothing or print an error message like "Please insert your card first." It will not process the PIN and it will not change the ATM's state.
This demonstrates the power of the State pattern: the action's outcome is determined entirely by the current state, preventing invalid operations from occurring.
Step 5: Verify & Discuss Extensions
Finally, let's verify our design and consider how it could evolve.
A. Verifying with a Sequence Diagram
A sequence diagram helps visualize the object interactions over time for a specific scenario, like a successful withdrawal.
This sequence diagram, adapted from the Pearson resource (LINK), models a successful withdrawal. It shows the Withdrawal transaction object coordinating with the Screen, Keypad, BankDatabase, and CashDispenser to complete the user's request, illustrating the clear separation of responsibilities in our design.
B. Discussing Extensions
Interviewer: "Your design is solid for a single ATM. How would you handle concurrency if multiple ATMs were accessing the same BankDatabase and a user's Account simultaneously?"
Your Response:
"That's a critical point for a real-world system. Concurrency control must be handled at the Account level. The methods that modify the balance—specifically withdraw(amount) and deposit(amount)—must be thread-safe.
In Java, the simplest way to achieve this is by declaring these methods as synchronized. For example:public synchronized boolean withdraw(BigDecimal amount)
This ensures that only one thread (representing one ATM transaction) can modify an account's balance at a time, preventing race conditions like two simultaneous withdrawals debiting the account incorrectly. For a more scalable solution in a distributed system, we would rely on the transactional isolation levels provided by a relational database (e.g., SELECT ... FOR UPDATE to lock the account row) rather than just in-memory synchronization."
Conclusion
Fantastic work! You have now designed a comprehensive ATM system, applying multiple design patterns to create a solution that is robust, maintainable, and extensible.
Key Takeaways:
- State Pattern for Workflows: The State pattern is exceptionally well-suited for modeling systems that move through a series of discrete states with different behaviors in each (e.g., ATM, elevator, vending machine).
- Chain of Responsibility for Sequential Processing: The CoR pattern provides an elegant and extensible way to handle requests that need to be processed by a series of handlers, like dispensing cash in various denominations.
- Abstraction of Hardware: By representing hardware components as classes with simple interfaces, we decouple our application's core logic from the underlying physical devices. This makes the system more modular and easier to test.
- Systematic Design: Following a structured, step-by-step process allows us to manage complexity and ensure all requirements are met.
This concludes our module on LLD case studies. You've built up a strong toolkit of patterns and principles. In the next module, we will formalize some of this knowledge by diving deep into the SOLID principles. These five principles are the bedrock of modern object-oriented design and will give you a powerful framework for writing clean, maintainable, and flexible code.