Back to Encapsulation Hub

Python · OOP · Banking Software

Programming in OOP:
Production-Ready ATM

The most comprehensive ATM programming tutorial on the web — covering Python OOP, security architecture, design patterns, unit testing, and a live interactive simulator.This is an intensive method of learning to grasp the profound conceps of encapsulation in Python.Think of Object-Oriented Programming (OOP) as a way of organizing code that mimics how we perceive the real world. Instead of writing a long list of instructions for the computer to follow (procedural programming), you create "objects" that interact with one another.

Read the Guide 🎮 Try Live Simulator
SCROLL
READING TIME Approx. 2–3 hours LEVEL Intermediate to Advanced LANGUAGE Python 3.8+ TOPICS OOP · Security · Design Patterns · Testing PUBLISHED February 28, 2025 READING TIME Approx. 2–3 hours LEVEL Intermediate to Advanced LANGUAGE Python 3.8+ TOPICS OOP · Security · Design Patterns · Testing PUBLISHED February 28, 2025

Learning Object-Oriented Programming:
Building a Production-Ready ATM System

"This isn't just another 'hello world' example — we're building a complete, production-inspired banking system that teaches you real-world software development.Object-Oriented Programming (OOP) is a programming paradigm based on the concept of "objects," which can contain data and code. Data in the form of fields (often known as attributes or properties), and code in the form of procedures (often known as methods). ​Instead of thinking about a program as a list of steps (procedural), OOP encourages you to think about it as a collection of interacting entities."
— Valleys & Bytes
🏧
What Makes This Different

Most tutorials show isolated concepts.
Here you see how everything fits together.

📖 5000+ words 2–3 hours 🎮 Live simulator

Whether you're a self-taught developer trying to level up, a CS student bridging the gap between theory and practice, or a working programmer sharpening your OOP instincts — this guide was written specifically for you.

By the time you finish, you won't just have working code — you'll have a mental model for how professionals think about designing software systems. That mental model is what separates a junior developer from a senior one.

What you'll understand
  1. How professional banking software is architected from the ground up
  2. Why OOP principles prevent real production failures
  3. How to write maintainable, testable, and secure code
  4. The thought process and trade-offs behind every design decision
  5. How to handle edge cases and errors like a senior engineer

The Problem: Why Build an ATM?

An ATM system is the perfect learning vehicle because every person on the planet has used one. That familiarity is an advantage — you already understand the expected behavior, which means you can focus on the engineering rather than the domain. ATMs are deceptively complex: they involve authentication, transaction processing, concurrent state management, security hardening, audit trails, hardware abstraction, and strict business rules — exactly the same challenges you'll face in any serious application.

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.

Prerequisites & Who This Is For

This tutorial assumes basic Python syntax: variables, loops, functions, and maybe a little about classes. You don't need to be an OOP expert — that's what we're building. Estimated time: 2–3 hours for reading, plus however long you spend experimenting with the live simulator.

● OOP Fundamentals
Classes & Encapsulation
Classes, objects, encapsulation, and instance state — the bedrock of everything.
● Security
SHA-256 PIN Hashing
Password hashing, brute-force prevention, and automatic account locking mechanisms.
● Design Patterns
Command · Observer · Factory
Gang of Four patterns applied in a real, working banking system context.
● Unit Testing
Comprehensive Test Strategy
Writing tests that catch bugs before they reach production — including edge cases.
● Error Handling
Structured Results Pattern
Returning structured results instead of throwing raw exceptions — the professional way.
● Audit Trails
Transaction History
Building a transaction log that supports debugging, compliance, and fraud detection.

🎮 Live ATM Simulator

Try the ATM yourself! Use card 1234 with PIN 1234. The simulator mirrors the exact Python logic implemented below — test invalid PINs, insufficient funds, daily limits, PIN changes, and more.

🏧
ATM Simulator v2.0

Mirrors the exact Python logic
implemented below.

Test card: 1234  ·  PIN: 1234  ·  Balance: ₱12,500.75

Multi-Account Daily Limits PIN Change
₱10kDaily Limit
3PIN Attempts
₱50kDeposit Cap
SHA-256PIN Hashing
ATM Simulator — Interactive Session
================================== VALLEYS & BYTES ATM v2.0 ================================== Welcome to the ATM Simulator → Press START to begin your session → Test card: 1234 → PIN: 1234 → Balance: ₱12,500.75 This simulator mirrors the exact logic of our Python implementation below.

💻 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 the sections that follow.

atm_system.py
# ── atm_system.py ──────────────────────────────────────────────
import hashlib
import datetime
import json
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.

📝 Line-by-Line: Critical Sections Explained

Authentication Flow

Why the order of checks matters in authenticate()

authenticate() performs a careful sequence of checks. First it verifies the card exists — then checks if the account is already locked. This order matters: you should never reveal whether a locked account's PIN is correct, since that leaks security-sensitive information.

PIN verification uses hashed comparison — the actual PIN is never stored or transmitted in plaintext. Even if an attacker accessed the database directly, they'd only see cryptographic hashes.

Why Tuple[bool, str] return types?
  1. A failed login is an expected outcome, not an exceptional one
  2. Callers handle both success and failure paths cleanly without try/except
  3. Makes testing easier: assert on both the boolean AND the message
Withdrawal Logic

The heart of the business rules engine

The can_withdraw() method checks conditions in priority order. First: is today a new calendar day? If so, reset the daily counter. Real ATMs do exactly this — your daily limit refreshes at midnight, not 24 hours after your last withdrawal.

Five conditions are checked in order: account lock, positive amount, sufficient balance, denomination validity (multiples of ₱100), and daily limit. The denomination check (amount % 100 !== 0) is especially realistic — physical ATMs can only dispense whole bills, so only amounts divisible by the smallest denomination are valid.

Class Constants

DRY principle applied to configuration

DAILY_WITHDRAWAL_LIMIT = 10000.0 and MAX_FAILED_ATTEMPTS = 3 are declared as class-level constants. When a business requirement changes — say, the daily limit increases to ₱15,000 — you change it in exactly one place, and every method picks up the new value automatically.

Every successful operation appends a structured record to transaction_history: type, amount, resulting balance, and an ISO 8601 timestamp. This isn't optional in banking — regulators require complete audit trails. Design your public methods to be implementation-agnostic, so you can swap the storage backend later without changing callers.

🏗️ Professional Design Patterns in Action

Our ATM system demonstrates several Gang of Four design patterns that appear constantly in professional software:

● Command
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.
BEHAVIORAL PATTERN
● Observer
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.
BEHAVIORAL PATTERN
● Factory
Factory Pattern
Account creation could use a factory to handle different account types (savings, checking, business) each with different rules. The factory centralizes all creation logic.
CREATIONAL PATTERN
● Strategy
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.
BEHAVIORAL PATTERN
"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."
— Valleys & Bytes, on decoupling

Being able to name and apply these patterns in an interview — with a concrete example like this ATM — immediately signals that you think at an architectural level, not just a "make the test pass" level.

🧪 Comprehensive Testing Strategy

Professional code requires professional testing. Here's how we'd test our ATM system — covering the four most critical scenarios:

test_atm.py
import unittest

class TestATM(unittest.TestCase):
    def setUp(self):
        self.atm = ATM()   # fresh ATM before every test

    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)
        # correct PIN must now fail because account is locked
        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"])    # negative amount
        self.assertFalse(self.atm.withdraw(999999)["success"])   # exceeds balance
        self.assertFalse(self.atm.withdraw(150)["success"])      # not multiple of 100
        self.assertTrue(self.atm.withdraw(1000)["success"])      # valid ✓

    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())

    def test_transaction_history(self):
        self.atm.authenticate("1234", "1234")
        self.atm.deposit(1000)
        self.atm.withdraw(500)
        history = self.atm.get_transaction_history()
        self.assertEqual(len(history), 2)
        self.assertEqual(history[0]["type"], "deposit")
        self.assertEqual(history[1]["type"], "withdrawal")

Notice how 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 (two withdrawals submitted at the same millisecond), database integration, and mocked external API calls. Aim for >90% code coverage and run these tests automatically on every commit via CI/CD pipelines like GitHub Actions or Jenkins.

⚡ Taking It Further

📦
Database Integration
In production, accounts live in a database. You'd use connection pooling, transactions, and optimistic locking for concurrent access. The Account class becomes a Data Access Object (DAO).
PostgreSQLSQLAlchemy
Learn more →
🔗
API Design
A real ATM network exposes REST or gRPC APIs. Each ATM is a client; a central server handles transactions. You'd need authentication, rate limiting, and idempotency keys.
FastAPIgRPC
Learn more →
📈
Monitoring & Logging
Production systems need structured JSON logs, Prometheus metrics, and distributed tracing. Every transaction should be trackable for debugging and fraud detection.
PrometheusOpenTelemetry
Learn more →
🔒
Advanced Security
Real ATMs use hardware security modules (HSM), encrypted PIN pads, and tamper detection. The software side needs encryption at rest, TLS for all communications, and regular security audits.
HSMArgon2TLS
Learn more →

📧 Never Miss an In-Depth Tutorial

Join 5,000+ developers who receive our weekly deep dives on Python, OOP, system design, and more.

No spam. Unsubscribe at any time.

💬 Join the Discussion

Leave a Comment

P
@python_dev
2 hours ago
This is exactly what I needed! The explanation about daily withdrawal limits and account locking really clarified things for me.
S
@security_pro
5 hours ago
Great security practices. Would love to see a follow-up on implementing proper salt+bcrypt for PIN storage in production.
B
@beginner_coder
yesterday
The way you broke down the withdrawal validation into separate checks helped me understand how to structure my own code. Thank you!