ATM System —
Mastering Encapsulation
Through Real Banking Software
Build a complete, professional ATM system in Python. Learn encapsulation, security, design patterns, and testing — all while creating a live interactive simulator you can use right in your browser.
Why an ATM System?
The Perfect Vehicle for Encapsulation
Every person on the planet has used an ATM. That familiarity is your superpower — you already understand the expected behavior, which means you can focus entirely on the engineering rather than the domain. But ATMs are deceptively complex: they involve authentication, transaction processing, concurrent state management, security hardening, audit trails, hardware abstraction, and strict business rules. These are exactly the same challenges you'll face in any serious application — from e‑commerce to AI systems.
Banking software has zero tolerance for bugs. A mistake in your ATM code doesn't just break a feature — it could drain someone's account or lock them out of their money. Building in this mindset trains you to write better software in every domain. This tutorial will teach you how to think like a senior engineer, not just how to write code.
What you'll learn: Encapsulation, SHA‑256 security, Command & Observer design patterns, unit testing, and production‑grade error handling — all through one cohesive project.
Prerequisites & Who This Is For
You should know basic Python syntax (variables, loops, functions, and maybe a little about classes). You don't need to be an OOP expert — that's what this tutorial is for. Estimated time: 3–4 hours for reading, plus experimentation with the live simulator.
- You're a self‑taught developer looking to level up from scripts to systems.
- You're a CS student bridging the gap between theory and practice.
- You're a working programmer sharpening your OOP and testing skills.
- You want to see how encapsulation is applied in a real, working project.
🎮 Live ATM Simulator
Try the ATM yourself! Use card 1234 with PIN 1234. The simulator mirrors the exact Python logic we'll dissect below — test invalid PINs, insufficient funds, daily limits, PIN changes, and more.
💻 Complete Production‑Ready Implementation
Below is our ATM system with professional features: multi‑account support, transaction limits, audit logging, and comprehensive error handling. Every design decision is explained in detail in the sections that follow.
import hashlib
import datetime
from typing import Dict, List, Optional, Tuple
class Account:
def __init__(self, account_number: str, pin_hash: str,
holder_name: str, initial_balance: float = 0.0,
account_type: str = "savings"):
self.account_number = account_number
self.pin_hash = pin_hash
self.holder_name = holder_name
self.balance = initial_balance
self.account_type = account_type
self.is_locked = False
self.failed_attempts = 0
self.transaction_history: List[Dict] = []
self.daily_withdrawal_total = 0.0
self.last_transaction_date = datetime.date.today()
def verify_pin(self, plain_pin: str) -> bool:
input_hash = hashlib.sha256(plain_pin.encode()).hexdigest()
return input_hash == self.pin_hash
def can_withdraw(self, amount: float,
daily_limit: float = 10000.0) -> Tuple[bool, str]:
if self.last_transaction_date < datetime.date.today():
self.daily_withdrawal_total = 0.0
self.last_transaction_date = datetime.date.today()
if self.is_locked:
return False, "Account is locked. Please contact support."
if amount <= 0:
return False, "Amount must be positive."
if amount > self.balance:
return False, "Insufficient funds."
if amount % 100 != 0:
return False, "Withdrawals must be in multiples of 100."
if self.daily_withdrawal_total + amount > daily_limit:
return False, f"Daily withdrawal limit of ₱{daily_limit:,.2f} exceeded."
return True, "Approved"
def withdraw(self, amount: float) -> Dict:
can_withdraw, message = self.can_withdraw(amount)
if not can_withdraw:
return {"success": False, "message": message}
self.balance -= amount
self.daily_withdrawal_total += amount
transaction = {
"type": "withdrawal", "amount": amount,
"balance_after": self.balance,
"timestamp": datetime.datetime.now().isoformat()
}
self.transaction_history.append(transaction)
return {"success": True,
"message": f"Withdrawn ₱{amount:,.2f}",
"new_balance": self.balance,
"transaction": transaction}
class ATM:
DAILY_WITHDRAWAL_LIMIT = 10000.0
MAX_FAILED_ATTEMPTS = 3
def __init__(self):
self.accounts: Dict[str, Account] = {}
self.current_account: Optional[Account] = None
self.session_start = None
self._initialize_test_accounts()
def _initialize_test_accounts(self):
demo_hash = hashlib.sha256("1234".encode()).hexdigest()
self.accounts["1234"] = Account("1234", demo_hash,
"Glenn Junsay Pansensoy", 12500.75, "savings")
self.accounts["5678"] = Account("5678", demo_hash,
"Maria Santos", 25000.00, "checking")
def authenticate(self, card_number: str, pin: str) -> Tuple[bool, str]:
if card_number not in self.accounts:
return False, "Card not recognized"
account = self.accounts[card_number]
if account.is_locked:
return False, "Account locked. Please contact customer service."
if account.verify_pin(pin):
account.failed_attempts = 0
self.current_account = account
self.session_start = datetime.datetime.now()
return True, f"Welcome, {account.holder_name}!"
account.failed_attempts += 1
if account.failed_attempts >= self.MAX_FAILED_ATTEMPTS:
account.is_locked = True
return False, "Too many failed attempts. Account locked."
remaining = self.MAX_FAILED_ATTEMPTS - account.failed_attempts
return False, f"Incorrect PIN. {remaining} attempt(s) remaining."
def check_balance(self) -> str:
if not self.current_account:
return "No active session"
return f"Current Balance: ₱{self.current_account.balance:,.2f}"
def deposit(self, amount: float) -> Dict:
if not self.current_account:
return {"success": False, "message": "No active session"}
if amount <= 0:
return {"success": False, "message": "Deposit must be positive"}
if amount > 50000:
return {"success": False, "message": "Over ₱50,000 requires teller"}
self.current_account.balance += amount
tx = {"type": "deposit", "amount": amount,
"balance_after": self.current_account.balance,
"timestamp": datetime.datetime.now().isoformat()}
self.current_account.transaction_history.append(tx)
return {"success": True, "message": f"Deposited ₱{amount:,.2f}",
"new_balance": self.current_account.balance, "transaction": tx}
def withdraw(self, amount: float) -> Dict:
if not self.current_account:
return {"success": False, "message": "No active session"}
return self.current_account.withdraw(amount)
def change_pin(self, old_pin: str, new_pin: str) -> Dict:
if not self.current_account:
return {"success": False, "message": "No active session"}
if not self.current_account.verify_pin(old_pin):
return {"success": False, "message": "Current PIN is incorrect"}
if len(new_pin) != 4 or not new_pin.isdigit():
return {"success": False, "message": "PIN must be exactly 4 digits"}
if new_pin == old_pin:
return {"success": False, "message": "New PIN must differ from old"}
self.current_account.pin_hash = hashlib.sha256(new_pin.encode()).hexdigest()
return {"success": True, "message": "PIN changed successfully."}
def get_transaction_history(self, limit: int = 10) -> List[Dict]:
if not self.current_account:
return []
return self.current_account.transaction_history[-limit:]
def logout(self):
self.current_account = None
self.session_start = None
🔬 Understanding Every Design Decision
Separation of Concerns
We split Account (data + individual operations) from ATM (system‑wide coordination). Each class has exactly one reason to change — a core SRP principle that makes maintenance straightforward.
Security Architecture
Never store plaintext PINs. SHA‑256 hashing here for clarity; real systems use bcrypt or Argon2 with per‑user salts. Failed attempt tracking stops brute force: 3 wrong PINs locks the account permanently.
State Management
Each account tracks its own state: balance, lock status, failed attempts, and daily withdrawal total. This encapsulation means one account's state can never corrupt another's — naturally thread‑safe at the account level.
Business Rules Encapsulation
All withdrawal rules live in can_withdraw(). When compliance says "add a minimum withdrawal of ₱200," there's exactly one place to make that change — no scattered checks to miss.
🏗️ Professional Design Patterns in Action
Our ATM system demonstrates several Gang of Four design patterns that appear constantly in professional software:
Command Pattern
Each ATM operation (deposit, withdraw, balance check) could be encapsulated as a command object — enabling queued transactions, undo operations, and complete audit logging.
Observer Pattern
Transaction history demonstrates Observer‑like behavior — accounts notify interested parties (ATM screen, receipt printer, fraud detector) when state changes, decoupling account from presentation.
Factory Pattern
Account creation could use a factory to handle different account types (savings, checking, business) each with different rules. The factory centralises all creation logic.
Strategy Pattern
Withdrawal rules could be swapped out based on account type. A savings account has different limits from a business account — Strategy makes these interchangeable and independently testable.
💡 Key Insight: A withdrawal command (Command) uses a withdrawal strategy (Strategy) that checks business rules, then notifies observers (Observer) — all without the Account class knowing any of those systems exist. This decoupling is the hallmark of maintainable software.
🧪 Comprehensive Testing Strategy
Professional code requires professional testing. Here's how we'd test our ATM system — covering the four most critical scenarios:
import unittest
class TestATM(unittest.TestCase):
def setUp(self):
self.atm = ATM()
def test_successful_authentication(self):
success, message = self.atm.authenticate("1234", "1234")
self.assertTrue(success)
self.assertIn("Welcome", message)
def test_failed_authentication_locks_account(self):
for i in range(3):
success, _ = self.atm.authenticate("1234", "wrong")
self.assertFalse(success)
success, message = self.atm.authenticate("1234", "1234")
self.assertFalse(success)
self.assertIn("locked", message.lower())
def test_withdrawal_rules(self):
self.atm.authenticate("1234", "1234")
self.assertFalse(self.atm.withdraw(-100)["success"])
self.assertFalse(self.atm.withdraw(999999)["success"])
self.assertFalse(self.atm.withdraw(150)["success"])
self.assertTrue(self.atm.withdraw(1000)["success"])
def test_daily_limit(self):
self.atm.authenticate("1234", "1234")
self.assertTrue(self.atm.withdraw(5000)["success"])
self.assertTrue(self.atm.withdraw(5000)["success"])
result = self.atm.withdraw(1000)
self.assertFalse(result["success"])
self.assertIn("daily withdrawal limit", result["message"].lower())
Why these tests? Each test is self‑contained — setUp() creates a fresh ATM before every test, so a failure in one test can never corrupt another. This isolation is the difference between tests you trust and tests that give you false confidence.
In a real project, you'd also test concurrent access, database integration, and mocked external API calls. Aim for >90% code coverage and run these tests automatically on every commit via CI/CD pipelines.
The Vital Role of Encapsulation in the Age of AI
Encapsulation is not a relic of classical software engineering — it is more relevant than ever in the age of machine learning, large language models, and AI‑driven systems. Here's why.
ML Model Abstraction
Libraries like PyTorch and TensorFlow use deep encapsulation. nn.Module encapsulates weights, forward pass logic, and gradient tracking — you call model(x) without touching any internals.
Data Privacy & GDPR
AI systems processing personal data must protect it rigorously. Encapsulation enforces access control — PII fields remain private, only accessible through validated, logged accessor methods that satisfy compliance requirements.
Model Versioning
When you swap a model from BERT to GPT‑4 internally, encapsulation ensures nothing outside breaks. The interface stays constant — only the encapsulated implementation changes. This is the foundation of agile AI deployment.
Microservice Design
Modern AI microservices are essentially encapsulated objects at scale. Each service hides its implementation, exposes only a clean API, and can be updated independently — classical encapsulation applied at the architectural level.
Prompt Engineering
LLM wrapper classes encapsulate prompt templates, retry logic, token counting, and caching behind a single .ask() method. The caller never needs to know about rate limits or context windows.
Safety Guardrails
AI safety systems use encapsulation to enforce content filters and output validation at the class level — ensuring no output can bypass safety checks regardless of how the model is called internally.
Connecting the dots: The ATM system you just built uses the same encapsulation principles that power production AI systems. The Account class is like a neural network — it hides its internal state (weights, biases) and only exposes controlled methods (forward pass). The ATM class is like the API layer — it coordinates requests without exposing internals. This is how professional software is built, whether for banking or artificial intelligence.
❓ Common Questions About OOP & Encapsulation
Why can't we just use plain functions instead of classes?
+You can, but classes give you encapsulation — the ability to bundle data with the functions that operate on it, and control access to that data. In the ATM system, the Account class ensures that only its own methods can modify the balance. With plain functions, any part of the program could accidentally corrupt the balance. Classes also enable inheritance and polymorphism, which are essential for large‑scale systems.
Is SHA‑256 secure enough for PINs in production?
+SHA‑256 is a cryptographic hash, but it's fast — which is actually a weakness for password hashing. In production, you'd use bcrypt, Argon2, or PBKDF2 with a per‑user salt and a work factor (cost) that slows down brute‑force attacks. However, SHA‑256 is fine for learning and demonstrates the core principle of never storing plaintext secrets.
How does encapsulation relate to SOLID principles?
+Encapsulation is the foundation for the Single Responsibility Principle (SRP) and the Open‑Closed Principle (OCP). By hiding internal details, you can change the implementation of a class without affecting its users (OCP). And by grouping related data and behaviour together, each class has a single, well‑defined responsibility (SRP). The ATM system shows SRP: Account handles account logic, ATM handles session coordination.
Why use tuples to return results instead of exceptions?
+Returning (bool, str) makes it explicit that a failure is an expected outcome, not an exceptional one. In banking, failed PINs and insufficient funds are common — they aren't bugs, they're normal flow. Using tuples forces the caller to handle both success and failure paths, leading to more robust code. Exceptions should be reserved for truly unexpected errors (e.g., network failure, database corruption).
How would you extend this ATM to support multiple currencies?
+You'd add a currency attribute to Account and a CurrencyConverter class. The withdraw method would use the converter to ensure the amount is in the account's currency. This demonstrates the Strategy pattern — you'd inject the converter as a dependency, making the system flexible and testable.
🏋️ Hands‑On Exercises
Reinforce your understanding by extending the ATM system. Each exercise builds on the previous one and explores a different aspect of encapsulation and OOP.
1️⃣ Add a Minimum Balance Rule
Modify the Account class so that a savings account must maintain a minimum balance of ₱500. If a withdrawal would bring the balance below ₱500, reject it with an appropriate message.
- Hint: Add a
minimum_balanceattribute toAccount. - Update
can_withdraw()to checkself.balance - amount >= self.minimum_balance.
2️⃣ Implement a Transfer Feature
Add a transfer() method to the ATM class that moves money from the current account to another account (by account number). The method should:
- Validate that the target account exists.
- Check that the source account has enough funds (respecting daily limits).
- Debit the source and credit the target atomically (both or neither succeed).
withdraw() and deposit() methods, but wrap them in a single transaction. If either fails, rollback the changes.3️⃣ Add a Transaction Fee for Withdrawals
Modify the withdrawal logic so that each withdrawal incurs a fee of ₱15.00 if the account is a checking account, and ₱5.00 if it's a savings account. The fee should be deducted from the balance after the withdrawal.
- Hint: Extend the
withdraw()method to compute the fee based onself.account_type. - Ensure the fee is logged in the transaction history.
4️⃣ Create a Savings Goal Feature
Extend the Account class to include a savings_goal attribute. Add a method check_goal_progress() that returns the percentage of the goal achieved. Also, add a reset_goal() method.
- Goal can be set via a new method
set_goal(amount). - Progress = (current balance / goal) * 100.
Account class, not the ATM class.🎯 Knowledge Check
Reinforce what you've learned. Each question targets a core encapsulation or ATM concept from this guide.
Great effort — review any missed questions above.
Subscribe to Poetic Bytes
Level up your Python knowledge.
In‑depth Python tutorials, AI insights, and software engineering guides — delivered straight to your inbox. No spam, ever.
🔒 Join 8,400+ developers. Your email is never shared or sold.
💬 Comments
Leave a Comment