Lesson 2 - Object-Oriented Programming in Practice

Welcome to Object-Oriented Programming in Practice

Module 1 introduced encapsulation and abstraction through Ledgerly’s Invoice and PaymentGateway classes. This lesson picks up where that one left off. You already know how to hide an object’s internal state and how to expose a stable interface. Now you will learn how classes relate to each other, and how to choose the right relationship for a given piece of Ledgerly’s codebase.

Three ideas drive this lesson: inheritance, polymorphism, and the choice between composition and inheritance. You will extend Ledgerly’s PaymentGateway family with new subclasses, call the same method on different gateway types and get different results, and rebuild Invoice out of small, focused collaborators instead of one large class. Every example runs as real Python, so you can check the output for yourself.

By the end of this lesson, you will be able to:

  • Explain the difference between a class and an object, using Ledgerly’s Customer class
  • Build a class hierarchy with inheritance, including method overriding and super()
  • Use polymorphism to call one method on objects of different classes and get correct, type-specific behavior
  • Compare composition and inheritance, and explain when each one fits a design better
  • Rebuild a class like Invoice around smaller collaborating objects instead of a large hierarchy

Classes and Objects: A Quick Refresher

A class is a blueprint. It describes what attributes and methods every object built from it will have, but it holds no data of its own. An object is one specific instance built from that blueprint, with its own values stored in its attributes.

Ledgerly’s Customer class is a simple example. The blueprint defines a name, an email, and a plan_tier. Every customer object gets its own values for these three attributes.

class Customer:
    """A blueprint for one Ledgerly customer."""

    def __init__(self, name, email, plan_tier):
        self.name = name
        self.email = email
        self.plan_tier = plan_tier

    def describe(self):
        return f"{self.name} ({self.plan_tier} plan)"


amara = Customer("Amara Okafor", "[email protected]", "gold")
dana = Customer("Dana Whitfield", "[email protected]", "silver")

print(amara.describe())
print(dana.describe())
print(amara.plan_tier == dana.plan_tier)
Amara Okafor (gold plan)
Dana Whitfield (silver plan)
False

amara and dana share the same class, so they share the same describe() method. But each object keeps its own attribute values, so calling describe() on each one produces a different result. This distinction between the shared blueprint and the individual object is the foundation for everything else in this lesson.


Inheritance: Building a Family of Payment Gateways

Inheritance lets one class, called a subclass, take on the attributes and methods of another class, called a base class or parent class. The subclass can also add its own attributes and methods, or override the parent’s methods with its own version. Use inheritance when a genuine “is-a” relationship exists: a StripeGateway is a PaymentGateway, not just something that happens to look like one.

Ledgerly needs several payment providers. Every provider shares one behavior, calculating its own processing fee, but each one charges and refunds money through a different API. A PaymentGateway base class holds the shared logic, while each subclass supplies its own version of charge() and refund().

from abc import ABC, abstractmethod


class PaymentGateway(ABC):
    """Base class for every payment provider Ledgerly can charge through."""

    def __init__(self, provider_name, fee_percent):
        self.provider_name = provider_name
        self.fee_percent = fee_percent

    @abstractmethod
    def charge(self, amount_cents, customer_token):
        pass

    @abstractmethod
    def refund(self, charge_id, amount_cents):
        pass

    def calculate_fee(self, amount_cents):
        """Shared by every gateway: no subclass needs to repeat this math."""
        return round(amount_cents * self.fee_percent / 100)


class StripeGateway(PaymentGateway):
    """Charges customers through Stripe."""

    def __init__(self):
        super().__init__(provider_name="Stripe", fee_percent=2.9)

    def charge(self, amount_cents, customer_token):
        fee = self.calculate_fee(amount_cents)
        return {
            "charge_id": f"stripe_ch_{customer_token}",
            "amount": amount_cents,
            "fee": fee,
        }

    def refund(self, charge_id, amount_cents):
        return {"refund_id": f"stripe_re_{charge_id}", "amount": amount_cents}


class PayPalGateway(PaymentGateway):
    """Charges customers through PayPal."""

    def __init__(self):
        super().__init__(provider_name="PayPal", fee_percent=3.4)

    def charge(self, amount_cents, customer_token):
        fee = self.calculate_fee(amount_cents)
        return {
            "order_id": f"paypal_ord_{customer_token}",
            "amount": amount_cents,
            "fee": fee,
        }

    def refund(self, charge_id, amount_cents):
        return {"refund_id": f"paypal_re_{charge_id}", "amount": amount_cents}


class StripeSandboxGateway(StripeGateway):
    """A test-mode version of Stripe. Inherits from StripeGateway, not PaymentGateway directly."""

    def charge(self, amount_cents, customer_token):
        result = super().charge(amount_cents, customer_token)
        result["charge_id"] = result["charge_id"].replace("stripe_ch_", "stripe_test_ch_")
        return result


stripe = StripeGateway()
paypal = PayPalGateway()
sandbox = StripeSandboxGateway()

print(stripe.charge(4200, "cust_88"))
print(paypal.charge(4200, "cust_88"))
print(sandbox.charge(4200, "cust_88"))
print(stripe.calculate_fee(10000))
print(isinstance(sandbox, StripeGateway), isinstance(sandbox, PaymentGateway))
{'charge_id': 'stripe_ch_cust_88', 'amount': 4200, 'fee': 122}
{'order_id': 'paypal_ord_cust_88', 'amount': 4200, 'fee': 143}
{'charge_id': 'stripe_test_ch_cust_88', 'amount': 4200, 'fee': 122}
290
True True

Three things are happening here. First, calculate_fee() lives only in PaymentGateway, and every subclass inherits it without rewriting it. Second, charge() and refund() are overridden: each subclass replaces the parent’s abstract version with its own working implementation. Third, StripeSandboxGateway calls super().charge() inside its own charge() method, so it reuses Stripe’s logic and only changes the one detail it needs, the charge ID prefix. This is multi-level inheritance: StripeSandboxGateway inherits from StripeGateway, which inherits from PaymentGateway, two levels deep.

A quick reminder on abstraction

PaymentGateway also uses abstraction, the topic from Module 1: charge() and refund() are marked @abstractmethod, so Python refuses to create a PaymentGateway object directly. Abstraction defines the contract. Inheritance is the mechanism that lets each subclass fulfill that contract in its own way.


Polymorphism: Calling the Same Method on Different Gateways

Polymorphism means calling the same method name on objects of different classes and getting behavior appropriate to each object’s actual type. The calling code does not need to check which subclass it has. It just calls the method and trusts the object to handle it correctly.

collect_payment() below accepts any PaymentGateway, whether it is a StripeGateway or a PayPalGateway. It calls gateway.charge() once, and Python runs whichever version of charge() belongs to that object’s actual class.

from abc import ABC, abstractmethod


class PaymentGateway(ABC):
    def __init__(self, provider_name, fee_percent):
        self.provider_name = provider_name
        self.fee_percent = fee_percent

    @abstractmethod
    def charge(self, amount_cents, customer_token):
        pass

    @abstractmethod
    def refund(self, charge_id, amount_cents):
        pass

    def calculate_fee(self, amount_cents):
        return round(amount_cents * self.fee_percent / 100)


class StripeGateway(PaymentGateway):
    def __init__(self):
        super().__init__(provider_name="Stripe", fee_percent=2.9)

    def charge(self, amount_cents, customer_token):
        fee = self.calculate_fee(amount_cents)
        return {"charge_id": f"stripe_ch_{customer_token}", "amount": amount_cents, "fee": fee}

    def refund(self, charge_id, amount_cents):
        return {"refund_id": f"stripe_re_{charge_id}", "amount": amount_cents}


class PayPalGateway(PaymentGateway):
    def __init__(self):
        super().__init__(provider_name="PayPal", fee_percent=3.4)

    def charge(self, amount_cents, customer_token):
        fee = self.calculate_fee(amount_cents)
        return {"order_id": f"paypal_ord_{customer_token}", "amount": amount_cents, "fee": fee}

    def refund(self, charge_id, amount_cents):
        return {"refund_id": f"paypal_re_{charge_id}", "amount": amount_cents}


def collect_payment(gateway: PaymentGateway, invoice_total_cents, customer_token):
    """Works with any PaymentGateway subclass. Same call, different behavior."""
    result = gateway.charge(invoice_total_cents, customer_token)
    print(f"{gateway.provider_name}: {result}")
    return result


gateways = [StripeGateway(), PayPalGateway()]

for gateway in gateways:
    collect_payment(gateway, 5600, "cust_42")

total_fees = sum(gateway.calculate_fee(5600) for gateway in gateways)
print(f"Combined fees across both providers: {total_fees}")
Stripe: {'charge_id': 'stripe_ch_cust_42', 'amount': 5600, 'fee': 162}
PayPal: {'order_id': 'paypal_ord_cust_42', 'amount': 5600, 'fee': 190}
Combined fees across both providers: 352

collect_payment() was written once and never mentions Stripe or PayPal by name. It works today with two gateway types and will keep working if Ledgerly adds a third one next year, as long as the new gateway also inherits from PaymentGateway. This is the practical payoff of polymorphism: the calling code stays short and stable, even as the number of gateway types grows.


Composition vs. Inheritance: Building Invoice from Smaller Parts

Inheritance models an “is-a” relationship. Composition models a “has-a” relationship, where one object holds other objects as attributes and delegates work to them. Both are valid tools, but reaching for inheritance by default can produce a hierarchy that does not fit the real problem.

Picture a shared Billable base class for both one-time invoices and recurring subscriptions.

class Billable:
    def __init__(self, amount_cents):
        self.amount_cents = amount_cents

    def get_charge_amount(self):
        return self.amount_cents


class Invoice(Billable):
    """A one-time invoice. get_charge_amount() just returns the total."""
    pass


class Subscription(Billable):
    """A recurring charge. Needs a completely different signature to work correctly."""

    def __init__(self, amount_cents, billing_cycle_count):
        super().__init__(amount_cents)
        self.billing_cycle_count = billing_cycle_count

    def get_charge_amount(self, cycle_number=1):
        # Overriding with a different parameter list breaks substitutability:
        # code written for Billable cannot safely call this without knowing it is a Subscription.
        return self.amount_cents

Subscription technically works, but its get_charge_amount() needs an extra parameter that Invoice does not have. Any code written to work with a plain Billable cannot safely call get_charge_amount() on a Subscription without already knowing it is a subscription. The shared parent forced two different problems into one hierarchy.

Composition avoids this. Instead of inheriting shared behavior, Invoice holds three separate collaborators: a LineItems object that tracks what is being billed, a TaxCalculator that turns a subtotal into tax owed, and a PaymentGateway that collects the money. None of these three classes are subclasses of Invoice, and Invoice is not a subclass of any of them.

from abc import ABC, abstractmethod


class LineItems:
    """Holds the goods or services being billed, and sums them."""

    def __init__(self):
        self._items = []

    def add(self, description, price_cents, quantity=1):
        self._items.append({"description": description, "price_cents": price_cents, "quantity": quantity})

    def subtotal_cents(self):
        return sum(item["price_cents"] * item["quantity"] for item in self._items)


class TaxCalculator:
    """Knows how to turn a subtotal into tax owed. Nothing else."""

    def __init__(self, tax_rate_percent):
        self.tax_rate_percent = tax_rate_percent

    def calculate_tax_cents(self, subtotal_cents):
        return round(subtotal_cents * self.tax_rate_percent / 100)


class PaymentGateway(ABC):
    def __init__(self, provider_name, fee_percent):
        self.provider_name = provider_name
        self.fee_percent = fee_percent

    @abstractmethod
    def charge(self, amount_cents, customer_token):
        pass

    @abstractmethod
    def refund(self, charge_id, amount_cents):
        pass


class StripeGateway(PaymentGateway):
    def __init__(self):
        super().__init__(provider_name="Stripe", fee_percent=2.9)

    def charge(self, amount_cents, customer_token):
        return {"charge_id": f"stripe_ch_{customer_token}", "amount": amount_cents}

    def refund(self, charge_id, amount_cents):
        return {"refund_id": f"stripe_re_{charge_id}", "amount": amount_cents}


class Invoice:
    """Invoice HAS line items, HAS a tax calculator, and HAS a payment gateway.
    It does not inherit from any of them."""

    def __init__(self, invoice_id, tax_calculator: TaxCalculator, gateway: PaymentGateway):
        self.invoice_id = invoice_id
        self.line_items = LineItems()
        self.tax_calculator = tax_calculator
        self.gateway = gateway

    def add_item(self, description, price_cents, quantity=1):
        self.line_items.add(description, price_cents, quantity)

    def total_cents(self):
        subtotal = self.line_items.subtotal_cents()
        tax = self.tax_calculator.calculate_tax_cents(subtotal)
        return subtotal + tax

    def collect_payment(self, customer_token):
        return self.gateway.charge(self.total_cents(), customer_token)


invoice = Invoice("INV-2001", TaxCalculator(tax_rate_percent=8), StripeGateway())
invoice.add_item("Monthly hosting", 12000)
invoice.add_item("Support hours", 5000, quantity=2)

print(invoice.total_cents())
print(invoice.collect_payment("cust_15"))
23760
{'charge_id': 'stripe_ch_cust_15', 'amount': 23760}

Invoice.total_cents() delegates to self.line_items and self.tax_calculator instead of doing the math itself. collect_payment() delegates to self.gateway. If Ledgerly needs a subscription later, a Subscription class can reuse LineItems, TaxCalculator, and any PaymentGateway the same way, without forcing Invoice and Subscription into one awkward shared parent. Swapping StripeGateway for PayPalGateway also requires no change to Invoice at all, because Invoice only ever calls the methods defined on PaymentGateway.

A two-panel diagram comparing inheritance and composition using Ledgerly's classes. Left panel, titled 'Inheritance: is-a', shows PaymentGateway as an abstract base class at the top, with arrows down to two subclasses, StripeGateway and PayPalGateway, and a further arrow down from StripeGateway to StripeSandboxGateway, showing two levels of inheritance. Caption text explains every subclass IS a PaymentGateway, inherits charge and refund, and overrides them for its own provider. Right panel, titled 'Composition: has-a', shows Invoice as a central box with diamond-tipped lines pointing out to three separate boxes: LineItems with subtotal_cents, TaxCalculator with calculate_tax_cents, and PaymentGateway with charge. Caption text explains Invoice HAS a LineItems, HAS a TaxCalculator, and HAS a PaymentGateway, and none of them are Invoice subclasses, so any one collaborator can be swapped without touching the others. A bottom strip reads: use inheritance for a genuine is-a relationship, use composition to assemble one class from independent collaborators.
Ledgerly uses inheritance for its family of payment gateways, where every subclass genuinely is a PaymentGateway, and composition for Invoice, which is built from independent collaborators rather than a shared parent class.

Practice Exercises

Exercise 1: Add a PayPal sandbox gateway

Following the pattern of StripeSandboxGateway, sketch a PayPalSandboxGateway class that inherits from PayPalGateway. It should behave exactly like PayPalGateway, except every order_id it returns gets a sandbox_ prefix. Which method do you need to override, and what does your override call on super()?

Hint

Override charge(). Inside it, call result = super().charge(amount_cents, customer_token) to get PayPal’s normal result, then modify result["order_id"] before returning it, the same way StripeSandboxGateway.charge() modifies charge_id. You do not need to override refund(), __init__(), or calculate_fee(), because PayPalSandboxGateway inherits all of them unchanged.

Exercise 2: Explain the polymorphism in total_fees

Look again at this line from the polymorphism section: total_fees = sum(gateway.calculate_fee(5600) for gateway in gateways). The gateways list holds a StripeGateway and a PayPalGateway. Explain why this one line of code produces the correct fee for each gateway without an if statement checking the gateway’s type.

Hint

calculate_fee() is defined once, on PaymentGateway, and both subclasses inherit it unchanged. Each object still carries its own fee_percent, set in its own __init__() through super().__init__(). Calling gateway.calculate_fee(5600) always runs the same inherited method, but the method reads self.fee_percent, which differs per object. No type check is needed because the correct data, not the correct code path, is what varies.

Exercise 3: Fix a forced inheritance hierarchy

A teammate wants to add a RecurringInvoice class that inherits from Invoice to add a billing_cycle_count attribute and a charge_next_cycle() method. Using what you learned about Billable, Invoice, and Subscription, explain one risk of this plan and describe a composition-based alternative.

Hint

The risk: Invoice was designed for a single, one-time charge, so a RecurringInvoice subclass would inherit methods like collect_payment() that assume one charge, then need to override or ignore them to fit a recurring schedule, the same substitutability problem Subscription.get_charge_amount() had. A composition-based alternative: build a separate RecurringBillingSchedule class that holds an Invoice (or the same LineItems, TaxCalculator, and PaymentGateway) as an attribute, and calls collect_payment() on it once per billing cycle. Invoice stays simple, and the recurring logic lives in one focused place.


Summary

Inheritance, polymorphism, and the choice between composition and inheritance give you the tools to relate classes to each other, not just to design one class at a time. Ledgerly’s PaymentGateway hierarchy shows inheritance done well: StripeGateway, PayPalGateway, and StripeSandboxGateway each genuinely are a PaymentGateway, and each overrides only what makes it different. Polymorphism lets collect_payment() and total_fees call the same method on every gateway type without checking which one it has. The composed Invoice class shows the other side of the choice: when the relationship is “has-a” rather than “is-a,” building an object out of small, focused collaborators like LineItems, TaxCalculator, and PaymentGateway keeps each piece simple and easy to swap.

Key Concepts

  • Inheritance — a subclass takes on the attributes and methods of a base class, and can override any of them.
  • Method overriding — a subclass replaces a parent’s method with its own implementation, often calling super() to reuse part of it.
  • Multi-level inheritance — a subclass inherits from another subclass, forming a chain more than one level deep.
  • Polymorphism — calling the same method name on objects of different classes and getting behavior appropriate to each object’s real type.
  • Composition — one object holds other objects as attributes and delegates work to them, modeling a “has-a” relationship instead of “is-a.”
  • Substitutability — a subclass should work anywhere its parent is expected, without the caller needing extra type checks.

Why This Matters

Every framework, library, and codebase you touch after this course will use inheritance, polymorphism, or composition somewhere, often all three. Recognizing which relationship actually exists between two pieces of Ledgerly’s code, instead of defaulting to inheritance because it is familiar, is what keeps a growing codebase flexible. A PaymentGateway hierarchy that models real “is-a” relationships stays easy to extend with a new provider. A composed Invoice stays easy to change one collaborator at a time. The next lesson builds directly on both ideas, showing how creational and structural design patterns give these relationships well-tested, reusable shapes.

Next Steps

Lesson 3: Design Patterns I - Creational and Structural

Learn creational and structural design patterns that build on inheritance, polymorphism, and composition.

Back to Module Overview

Return to the Design, Architecture & OOP module overview


Continue Building Your Skills

You can now build a class hierarchy with inheritance, write code that works polymorphically across subclasses, and choose composition when a hierarchy would force unrelated behavior together. The next lesson uses these same tools, inheritance, polymorphism, and composition, as the building blocks for design patterns, starting with the creational and structural patterns that give common object relationships a proven, reusable shape.

Sponsor

Keep DATATWEETS free. Help fund practical data, AI, and engineering lessons for learners worldwide.

Buy Me a Coffee at ko-fi.com