A deep-dive into Python inheritance using a real-world payment processing domain.
ABC and @abstractmethodsuper(), method overriding, and polymorphismEvery programmer should learn inheritance because it is the foundation upon which scalable, maintainable, and extensible software is built. It is not merely a language feature — it is a way of thinking about code that mirrors how we naturally categorize the world.
Inheritance is one of the four pillars of Object-Oriented Programming (OOP), alongside encapsulation, abstraction, and polymorphism. It allows a class to inherit attributes and methods from another class, creating a parent-child relationship. This is not just about code reuse — it is about modeling real-world relationships in your codebase.
In the programming world, inheritance is what enables:
nn.Module.This project is specifically designed to teach inheritance through a real-world domain that every developer can relate to: payment processing. Payment methods share common behavior (amount, currency, processing) but differ dramatically in implementation. This makes them a perfect candidate for inheritance.
By building a PaymentMethod hierarchy with CreditCard, BankTransfer,
and Wallet subclasses, you will learn:
ABC and @abstractmethodsuper() to call parent constructors and methods🎯 The Big Picture: By the end of this project, you will not only understand inheritance syntactically but also when and why to use it in real software — and when to choose alternatives.
Payment processing is one of the most common domains where inheritance shines. In production systems like
Stripe, Braintree, and Adyen, you'll find classes like
PaymentIntent, Charge, or Transaction that use inheritance to handle
different payment methods while sharing common logic for idempotency, logging, and webhook delivery.
def process_payment(method, amount, **kwargs): if method == 'credit_card': # 20 lines of card logic return {'status': 'approved'} elif method == 'bank_transfer': # 15 lines of ACH logic return {'status': 'pending'} elif method == 'wallet': # 12 lines of wallet logic return {'status': 'completed'} else: raise ValueError("Unknown method")
This works — until you add a fourth method, or a fifth. Each new method adds another branch, and shared logic (logging, validation, idempotency) gets copy‑pasted or tangled. The inheritance version centralizes the shared parts and isolates the differences.
These different latencies, risk profiles, and compliance needs make payment methods a perfect fit
for inheritance: the shared contract (process()) stays the same, but each implementation handles
its own domain specifics.
Key insight: The base class defines the contract — every subclass must implement process(). The shared state (amount, currency, timestamp) lives in the base, so subclasses only add what's unique.
PaymentMethod Abstract BaseABC and @abstractmethod ensures that no subclass can be instantiated without implementing process().
This code establishes the foundation of the entire hierarchy. The PaymentMethod class is designed to be a blueprint — it defines what every payment method must have and do, without implementing the specific details. This is the essence of abstraction: define the what, leave the how to subclasses.
The currency parameter defaults to 'PHP' (Philippine Peso) to reflect a real-world use case in the Philippines.
from abc import ABC, abstractmethod from datetime import datetime class PaymentMethod(ABC): def __init__(self, amount: float, currency: str = 'PHP'): self.amount = amount self.currency = currency self.timestamp = datetime.now().isoformat() @abstractmethod def process(self) -> dict: '''Process the payment and return a result dict.''' pass def summary(self) -> str: return f"{self.__class__.__name__}: {self.currency} {self.amount:.2f} at {self.timestamp}"
When Python encounters class PaymentMethod(ABC), it creates an abstract base class. The @abstractmethod decorator marks process() as a method that must be overridden by any concrete subclass. The __init__ method sets up shared state, and summary() provides a reusable string representation that all subclasses inherit.
If you try to instantiate PaymentMethod directly, Python raises a TypeError because process() is abstract. This protects the developer from creating an incomplete object. The summary() method, when called on a subclass, will dynamically reflect the subclass name — demonstrating polymorphism in action.
The PaymentMethod class serves as the contract that all payment methods must adhere to. It enforces a consistent interface (process()) and provides shared infrastructure (state, timestamp, summary). This is the architectural anchor of the entire hierarchy.
amount, currency, and timestamp are defined once. The default currency is now PHP (Philippine Peso).summary() uses self.__class__.__name__ — this means it always shows the actual subclass name, even when called on a CreditCard or Wallet instance.dict — flexible but implicit. Later we'll refactor to a PaymentResult dataclass for better type safety.CreditCardCreditCard adds card_number and cvv, calls super().__init__() to reuse the base setup, and overrides process() with credit-card-specific logic.
This demonstrates the core inheritance pattern: extending a base class by adding new attributes and overriding behavior. The CreditCard class shows how to specialize a general concept (payment) into a concrete implementation.
The super().__init__() call is critical — it ensures the base class's initialization runs, so amount, currency, and timestamp are set up correctly. Without this, the subclass would be broken.
class CreditCard(PaymentMethod): def __init__(self, amount: float, card_number: str, cvv: str, currency: str = 'PHP'): super().__init__(amount, currency) self.card_number = card_number self.cvv = cvv def process(self) -> dict: auth_code = random.randint(100000, 999999) return { 'status': 'approved', 'method': 'credit_card', 'auth_code': auth_code, 'message': f'Card ending {self.card_number[-4:]} authorized.' }
When CreditCard is instantiated, it calls super().__init__() first, which runs PaymentMethod.__init__(). This sets up amount, currency, and timestamp. Then it adds its own attributes: card_number and cvv.
The process() method generates a random authorization code and returns a structured dict with a status of 'approved'. This simulates a successful credit card authorization.
The output shows the summary() method — inherited from the base class — printing "CreditCard: PHP 150.00 at [timestamp]". Then process() returns a dict with the authorization details. The output demonstrates that the subclass has correctly inherited and extended the base class.
CreditCard is the first concrete implementation of the PaymentMethod contract. It shows how to add domain-specific attributes and override the abstract method. It also establishes the pattern that all other subclasses will follow.
✅ super().__init__() is the key: It calls PaymentMethod.__init__() to set up amount, currency, and timestamp. Without this, the subclass would have to duplicate that logic — a maintenance trap.
super().__init__()?
Then self.amount, self.currency, and self.timestamp would not exist. Any call to summary() or process() that uses them would raise an AttributeError. Always call super() first when you add new attributes in __init__.
super().__init__() and implements process() with its own logic.
Notice how the interface stays consistent while the implementation varies.
This completes the hierarchy, showing polymorphism in action. Each subclass (BankTransfer, Wallet) shares the same interface (process()) but implements it differently. This demonstrates the power of inheritance: you can treat all payment methods uniformly while they behave differently.
The BankTransfer class simulates a slower, batch-oriented process with a 'pending' status and a reference number. The Wallet class simulates an instant, in-app transaction with a 'completed' status and an updated balance.
class BankTransfer(PaymentMethod): def __init__(self, amount: float, account_number: str, routing: str, currency: str = 'PHP'): super().__init__(amount, currency) self.account_number = account_number self.routing = routing def process(self) -> dict: ref = 'TX' + str(random.randint(1000000, 9999999)) return {'status': 'pending', 'method': 'bank_transfer', 'reference': ref} class Wallet(PaymentMethod): def __init__(self, amount: float, wallet_id: str, currency: str = 'PHP'): super().__init__(amount, currency) self.wallet_id = wallet_id def process(self) -> dict: return {'status': 'completed', 'method': 'wallet', 'balance_after': round(random.uniform(10, 500), 2)}
The program creates a list of three payment objects — one of each subclass. The for pm in payments loop then iterates over them, calling pm.summary() and pm.process() on each.
Because of polymorphism, Python dynamically dispatches the correct process() implementation based on the actual type of each object. The loop doesn't need to know whether it's a credit card, bank transfer, or wallet — it just calls the method and gets a result.
The output shows three distinct payment results, each with a different status and method-specific data. The summary() method (inherited from the base) correctly reflects the subclass name each time. This demonstrates that the hierarchy works as intended — each subclass behaves correctly when treated uniformly.
This is the culmination of the inheritance design. The full hierarchy shows how a single, clean interface (process()) can support multiple implementations. The polymorphic loop is the payoff — it's the reason we invested in building the hierarchy in the first place.
🌀 The polymorphic loop: The for pm in payments loop calls pm.process() without knowing whether pm is a CreditCard, BankTransfer, or Wallet. Python dynamically dispatches to the correct implementation — that's polymorphism.
Each payment method has the same interface but different behavior. Here's how they differ:
| Feature | CreditCard | BankTransfer | Wallet |
|---|---|---|---|
| Status | approved | pending | completed |
| Key extra data | auth_code | reference | balance_after |
| Typical latency | near-real-time | hours–days | instant |
| Requires external auth | Yes (card network) | Yes (bank) | No (in-app) |
| Idempotency key | auth_code | reference | wallet_id + timestamp |
| Risk level | high (fraud) | medium | low |
Design note: Returning a dict is flexible but implicit. In production, you'd likely use a PaymentResult dataclass for type safety and discoverability. We'll refactor to that later.
super().__init__()class BrokenCard(PaymentMethod): def __init__(self, amount, card_number): self.card_number = card_number def process(self): return {'status': 'ok'}
Fix: Always call super().__init__(amount, currency) first in your subclass __init__.
process() with a different signatureclass BadWallet(PaymentMethod): def process(self, extra_param: str) -> dict: return {'status': 'ok'}
Fix: Keep the method signature consistent. If you need extra data, pass it via the constructor or store it as an attribute.
If your payment method shares no behavior and only needs the process() contract, consider using an interface (ABC) without shared state. Composition — holding a PaymentProcessor instance — is often more flexible for cross-cutting concerns like logging or retries.
If a subclass overrides process() to return a different type, or raises unexpected exceptions, the polymorphic loop breaks. Always ensure that a subclass substitutes for its parent without changing the expected behavior contract.
class BadWallet(PaymentMethod): def process(self) -> str: return "Completed"
One of the biggest advantages of a well-designed inheritance hierarchy is that you can write one test that works for every subclass. This demonstrates the power of the shared interface.
import pytest from payments import CreditCard, BankTransfer, Wallet def test_credit_card_returns_approved(): cc = CreditCard(100.00, '4111-1111-1111-1111', '123') result = cc.process() assert result['status'] == 'approved' assert 'auth_code' in result def test_bank_transfer_returns_pending(): bt = BankTransfer(500.00, '987654321', '021000021') result = bt.process() assert result['status'] == 'pending' assert 'reference' in result @pytest.mark.parametrize('payment_class,args', [ (CreditCard, (100.00, '4111-1111-1111-1111', '123')), (BankTransfer, (500.00, '987654321', '021000021')), (Wallet, (25.50, 'wallet_xyz')), ]) def test_all_subclasses_have_summary_and_process(payment_class, args): pm = payment_class(*args) assert hasattr(pm, 'summary') assert hasattr(pm, 'process') result = pm.process() assert isinstance(result, dict) assert 'status' in result
✅ Parametrized tests are a superpower: One test function runs against every subclass. If you add a CryptoPayment later, the same test automatically verifies it — no extra code needed.
Inheritance isn't the only way. The Strategy pattern uses composition — you inject a processor into a context class. This is more flexible when behaviors don't share state or when you need to swap them at runtime.
class PaymentContext: def __init__(self, processor: PaymentProcessor): self.processor = processor def execute(self, amount: float, currency: str = 'PHP') -> dict: return self.processor.process(amount, currency)
| Aspect | Inheritance | Strategy (Composition) |
|---|---|---|
| Shared state | Easy — put it in the base | Harder — context holds it, or pass to process() |
| Runtime swap | Impossible after creation | Trivial — change the processor |
| Code reuse | High via base methods | Lower — shared logic must be delegated |
| Flexibility | Lower — hierarchy is fixed | Higher — compose any processor |
| Testing | Parametrized tests work well | Mock the processor easily |
When to choose which: Use inheritance when payment methods share significant state and behavior. Use Strategy when you need to swap processors at runtime or when the "is-a" relationship feels forced.
PaymentResult Dataclass
Returning a plain dict is flexible, but it's hard to document, discoverable, and type‑safe.
A @dataclass gives you named fields, default values, and better IDE support.
from dataclasses import dataclass @dataclass class PaymentResult: status: str method: str extra: dict message: str = '' def process(self) -> PaymentResult: return PaymentResult( status='approved', method='credit_card', extra={'auth_code': random.randint(100000, 999999)}, message=f'Card ending {self.card_number[-4:]} authorized.' )
✅ Benefits: Type checking, autocomplete, explicit fields, and you can add methods to PaymentResult (e.g., to_json()). The polymorphic loop still works seamlessly.
Try these exercises to deepen your understanding. Click Show solution for hints or full implementations.
CryptoPayment subclassCreate a new subclass that accepts a wallet_address and a network (e.g., "Ethereum", "Solana"). Override process() to return a transaction hash (simulated) and a status of "pending".
hashlib to generate a fake transaction hash.import hashlib, time class CryptoPayment(PaymentMethod): def __init__(self, amount, wallet_address, network, currency='PHP'): super().__init__(amount, currency) self.wallet_address = wallet_address self.network = network def process(self): tx_hash = hashlib.sha256(f"{self.wallet_address}{time.time()}".encode()).hexdigest() return { 'status': 'pending', 'method': 'crypto', 'network': self.network, 'tx_hash': tx_hash }
Define PaymentError, InsufficientFunds, and InvalidCard. Raise them in the appropriate subclasses and update the demo loop to handle them.
InvalidCard if the card number is too short.class PaymentError(Exception): pass class InsufficientFunds(PaymentError): pass class InvalidCard(PaymentError): pass class CreditCard(PaymentMethod): def __init__(self, amount, card_number, cvv, currency='PHP'): super().__init__(amount, currency) if len(card_number) < 16: raise InvalidCard("Card number must be at least 16 digits") self.card_number = card_number self.cvv = cvv
refund() methodAdd an abstract refund() method to the base class. Implement it in each subclass with appropriate logic (e.g., credit cards refund to the card, wallets credit the balance).
refund() should accept an amount and return a result dict.class PaymentMethod(ABC): def refund(self, amount: float) -> dict: return { 'status': 'refunded', 'amount': amount, 'method': self.__class__.__name__ }
PaymentResult dataclassTake the full hierarchy from Step 3 and update it to return PaymentResult instead of dict. Update the polymorphic loop accordingly.
PaymentResult with status, method, extra, and message.from dataclasses import dataclass @dataclass class PaymentResult: status: str method: str extra: dict message: str = '' def process(self) -> PaymentResult: return PaymentResult( status='approved', method='credit_card', extra={'auth_code': random.randint(100000, 999999)}, message=f'Card ending {self.card_number[-4:]} authorized.' )
Stripe, Braintree, and Adyen all expose similar interfaces — a PaymentMethod object with a charge() or process() method. The inheritance pattern you've just built mirrors how these SDKs structure their PaymentIntent or Transaction classes.
Instead of inheritance, you could use the Strategy pattern: pass a PaymentProcessor strategy object to a context class. This is more flexible when payment methods don't share state, and it's easier to swap behaviors at runtime.
When to choose composition over inheritance: When the "is-a" relationship feels forced, or when you need to change behavior dynamically. For payment methods, both approaches are valid — inheritance works well because the relationship is natural and the state is shared.
You could add a _log() method to the base class that subclasses can call inside process(). This keeps logging consistent across all payment methods without duplicating code.
def _log(self, message: str) -> None: print(f"[{self.timestamp}] {message}")
Production systems often use a registry that maps a string (e.g., "credit_card") to a class. This lets you instantiate the right subclass dynamically from an API request.
class PaymentRegistry: _registry = {} @classmethod def register(cls, name: str, payment_class): cls._registry[name] = payment_class @classmethod def get(cls, name: str): return cls._registry[name] PaymentRegistry.register('credit_card', CreditCard)
super() enables clean extension: Subclasses reuse parent logic and add only what's new.@abstractmethod guarantees that every subclass implements process().PaymentResult gives you named fields, autocomplete, and better documentation.__init__, __repr__, and other methods to a class.