Lesson 2 - The SOLID Principles

Welcome to The SOLID Principles

In Lesson 1 (Core Principles of Software Engineering), you learned modularity, abstraction, encapsulation, DRY, KISS, and YAGNI. Those ideas tell you what good code looks like in general. SOLID gives you five specific rules for one common situation: designing classes that work together in an object-oriented system. This lesson applies all five rules to Ledgerly, a small invoicing and subscription-billing app that a three-person team is building for freelancers and small businesses.

Ledgerly’s domain has a handful of core ideas: an Invoice records what a customer owes, a Customer is who owes it, a Subscription is a recurring plan a customer pays for, a PaymentGateway charges a card through a provider like Stripe or PayPal, and a NotificationService sends emails or texts. You will see each SOLID principle broken in this domain, and then fixed, with the actual output printed by running the code.

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

  • State each of the five SOLID principles in one sentence
  • Recognize a SOLID violation in a class or function you are reading
  • Rewrite a violating example into a version that follows the principle
  • Explain how the five principles reinforce each other in a real design

Single Responsibility Principle (SRP)

The rule: a class should have only one reason to change.

“Reason to change” means one specific kind of requirement, not one method. If a class handles three unrelated jobs, then three unrelated kinds of change can force you to edit it, and each edit risks breaking the other two jobs.

Here is an InvoiceService that violates SRP. It calculates totals, formats a printable invoice, saves data, and sends email, all in one class.

class InvoiceService:
    def __init__(self, invoices):
        self.invoices = invoices

    def calculate_total(self, invoice):
        subtotal = sum(item["price"] * item["qty"] for item in invoice["items"])
        tax = subtotal * 0.08
        return subtotal + tax

    def render_pdf(self, invoice):
        total = self.calculate_total(invoice)
        return f"INVOICE #{invoice['id']} - Total: ${total:.2f}"

    def save_to_database(self, invoice):
        self.invoices.append(invoice)
        return f"Saved invoice {invoice['id']} to storage"

    def send_reminder_email(self, invoice, customer_email):
        return f"Emailed {customer_email}: reminder for invoice {invoice['id']}"


service = InvoiceService(invoices=[])
invoice = {"id": "INV-1042", "items": [{"price": 45.0, "qty": 2}, {"price": 15.0, "qty": 1}]}

print(service.render_pdf(invoice))
print(service.save_to_database(invoice))
print(service.send_reminder_email(invoice, "[email protected]"))
INVOICE #INV-1042 - Total: $113.40
Saved invoice INV-1042 to storage
Emailed [email protected]: reminder for invoice INV-1042

This works today. The problem shows up later. If the tax rule changes, you edit InvoiceService. If the storage layer moves from a list to a real database, you edit InvoiceService. If the email wording changes, you edit InvoiceService again. Four unrelated teams could all need to touch the same class in the same week.

The fix splits each job into its own class. Each class now has exactly one reason to change.

class InvoicePricer:
    """Single responsibility: calculate totals and tax."""
    def calculate_total(self, invoice):
        subtotal = sum(item["price"] * item["qty"] for item in invoice["items"])
        tax = subtotal * 0.08
        return subtotal + tax


class InvoicePdfRenderer:
    """Single responsibility: format an invoice as printable text."""
    def __init__(self, pricer):
        self.pricer = pricer

    def render(self, invoice):
        total = self.pricer.calculate_total(invoice)
        return f"INVOICE #{invoice['id']} - Total: ${total:.2f}"


class InvoiceRepository:
    """Single responsibility: persist invoices."""
    def __init__(self):
        self.invoices = []

    def save(self, invoice):
        self.invoices.append(invoice)
        return f"Saved invoice {invoice['id']} to storage"


class NotificationService:
    """Single responsibility: send messages to customers."""
    def send_reminder(self, invoice, customer_email):
        return f"Emailed {customer_email}: reminder for invoice {invoice['id']}"


invoice = {"id": "INV-1042", "items": [{"price": 45.0, "qty": 2}, {"price": 15.0, "qty": 1}]}

pricer = InvoicePricer()
renderer = InvoicePdfRenderer(pricer)
repository = InvoiceRepository()
notifier = NotificationService()

print(renderer.render(invoice))
print(repository.save(invoice))
print(notifier.send_reminder(invoice, "[email protected]"))
INVOICE #INV-1042 - Total: $113.40
Saved invoice INV-1042 to storage
Emailed [email protected]: reminder for invoice INV-1042

The output is identical, but the design is different. A tax rule change now touches only InvoicePricer. A database migration touches only InvoiceRepository. Each class is small enough to name precisely and test in isolation.


Open/Closed Principle (OCP)

The rule: a class should be open for extension but closed for modification. You add new behavior by adding new code, not by editing code that already works.

Ledgerly’s PaymentGateway starts out supporting Stripe and PayPal with a conditional branch for each provider.

class PaymentGateway:
    def charge(self, provider, amount):
        if provider == "stripe":
            return f"Charged ${amount:.2f} via Stripe"
        elif provider == "paypal":
            return f"Charged ${amount:.2f} via PayPal"
        else:
            raise ValueError(f"Unknown provider: {provider}")


gateway = PaymentGateway()
print(gateway.charge("stripe", 49.99))
print(gateway.charge("paypal", 49.99))
Charged $49.99 via Stripe
Charged $49.99 via PayPal

The team later wants to support Wise, a third provider. That means opening PaymentGateway and adding another elif branch. Every new provider means editing a class that already works, and every edit risks breaking Stripe or PayPal support that was fine before.

The fix defines an abstract PaymentGateway interface and gives each provider its own class. Adding Wise means writing a new class, not editing an old one.

from abc import ABC, abstractmethod


class PaymentGateway(ABC):
    """Abstraction every payment provider must satisfy."""
    @abstractmethod
    def charge(self, amount):
        pass


class StripeGateway(PaymentGateway):
    def charge(self, amount):
        return f"Charged ${amount:.2f} via Stripe"


class PayPalGateway(PaymentGateway):
    def charge(self, amount):
        return f"Charged ${amount:.2f} via PayPal"


class WiseGateway(PaymentGateway):
    """Added later - no existing class needed to change."""
    def charge(self, amount):
        return f"Charged ${amount:.2f} via Wise"


def process_payment(gateway: PaymentGateway, amount):
    return gateway.charge(amount)


for gw in (StripeGateway(), PayPalGateway(), WiseGateway()):
    print(process_payment(gw, 49.99))
Charged $49.99 via Stripe
Charged $49.99 via PayPal
Charged $49.99 via Wise

process_payment() never changed, and neither did StripeGateway or PayPalGateway. WiseGateway is pure addition. This is what “closed for modification” means in practice: existing, tested code stays untouched while new capability arrives through a new class.


Liskov Substitution Principle (LSP)

The rule: any subclass must work anywhere its parent class is expected, without breaking the caller.

Ledgerly offers monthly, annual, and lifetime subscription plans. A lifetime plan is paid once and never renews, so it seems reasonable to make it a Subscription subclass that refuses to cancel. Here is that design, and what happens when a generic function tries to cancel any subscription.

class Subscription:
    def __init__(self, plan_name, monthly_price):
        self.plan_name = plan_name
        self.monthly_price = monthly_price
        self.active = True

    def cancel(self):
        self.active = False
        return f"{self.plan_name} subscription canceled"


class LifetimeSubscription(Subscription):
    """A one-time-payment plan that can never be canceled."""
    def cancel(self):
        raise Exception("Lifetime subscriptions cannot be canceled")


def cancel_if_unused(subscription):
    return subscription.cancel()


monthly = Subscription("Pro Monthly", 29)
lifetime = LifetimeSubscription("Pro Lifetime", 999)

print(cancel_if_unused(monthly))
try:
    print(cancel_if_unused(lifetime))
except Exception as e:
    print(f"Error: {e}")
Pro Monthly subscription canceled
Error: Lifetime subscriptions cannot be canceled

cancel_if_unused() was written to work with any Subscription, and it does work for Subscription and its ordinary subclasses. But it crashes on LifetimeSubscription, even though LifetimeSubscription is technically a Subscription. The subclass inherited a promise, cancel() will cancel the plan, and then broke that promise. This is exactly what LSP forbids: a subclass that cannot be substituted for its parent without surprising the caller.

The fix is to stop pretending every subscription can be canceled. Only plans that actually support cancellation inherit a cancel() method at all.

class Subscription:
    """Base type: every subscription has a plan name and a price."""
    def __init__(self, plan_name, monthly_price):
        self.plan_name = plan_name
        self.monthly_price = monthly_price
        self.active = True


class CancellableSubscription(Subscription):
    """Only plans that truly support cancellation inherit this."""
    def cancel(self):
        self.active = False
        return f"{self.plan_name} subscription canceled"


class MonthlySubscription(CancellableSubscription):
    pass


class LifetimeSubscription(Subscription):
    """A Subscription, but never a CancellableSubscription."""
    pass


def cancel_if_unused(subscription: CancellableSubscription):
    return subscription.cancel()


monthly = MonthlySubscription("Pro Monthly", 29)
lifetime = LifetimeSubscription("Pro Lifetime", 999)

print(cancel_if_unused(monthly))
# cancel_if_unused(lifetime) is never written, because a type checker
# flags it before the program runs: LifetimeSubscription has no cancel().
print(f"Lifetime plan still active: {lifetime.active}")
Pro Monthly subscription canceled
Lifetime plan still active: True

Now cancel_if_unused() only accepts a CancellableSubscription, and LifetimeSubscription is not one. A type checker catches the mismatch before the code ever runs, instead of the program crashing at runtime. Every subclass that does implement cancel() genuinely honors it.

Diagram titled 'The five SOLID principles, applied to Ledgerly'. Five boxes summarize each principle with a Ledgerly example. S, Single Responsibility: InvoiceService splits into InvoicePricer, PdfRenderer, and InvoiceRepository, each with one reason to change. O, Open/Closed: PaymentGateway lets a new WiseGateway class join Stripe and PayPal with no edits to existing gateway code. L, Liskov Substitution: LifetimeSubscription no longer claims a cancel() it can't honor, so the type system catches the mismatch early. I, Interface Segregation: DraftInvoice implements only Renderable, not Chargeable or Remindable, so it never has to promise what it can't do. D, Dependency Inversion: InvoiceService depends on the PaymentGateway interface, not StripeClient directly, so tests can swap it out. A caption at the bottom reads: together, the five principles keep Ledgerly's codebase easy to change, test, and extend as new features arrive.
The five SOLID principles summarized with the Ledgerly example used throughout this lesson: SRP, OCP, LSP, ISP, and DIP each solve a distinct design problem, and together they keep the codebase easy to change, test, and extend.

Interface Segregation Principle (ISP)

The rule: a class should not be forced to implement methods it does not use. Prefer several small, focused interfaces over one large one.

Ledgerly considers a single InvoiceOperations interface covering everything an invoice might do: render a PDF, charge a payment, send a reminder, and export to CSV. A DraftInvoice, one that has not been billed yet, is still forced to implement every method, including ones that make no sense for a draft.

from abc import ABC, abstractmethod


class InvoiceOperations(ABC):
    """One broad interface covering every invoice-related action."""
    @abstractmethod
    def render_pdf(self):
        pass

    @abstractmethod
    def charge_payment(self):
        pass

    @abstractmethod
    def send_reminder(self):
        pass

    @abstractmethod
    def export_to_csv(self):
        pass


class DraftInvoice(InvoiceOperations):
    """A draft has no payment or reminders yet, but must implement them anyway."""
    def render_pdf(self):
        return "Rendering draft PDF"

    def charge_payment(self):
        raise Exception("Draft invoices cannot be charged")

    def send_reminder(self):
        raise Exception("Draft invoices have no reminders to send")

    def export_to_csv(self):
        return "Exporting draft to CSV"


draft = DraftInvoice()
print(draft.render_pdf())
try:
    draft.charge_payment()
except Exception as e:
    print(f"Error: {e}")
Rendering draft PDF
Error: Draft invoices cannot be charged

DraftInvoice carries two methods it can never honestly fulfill. Any code that receives an InvoiceOperations object now has to guess, or catch exceptions, to find out which methods actually work. That guesswork is the cost of one interface that tries to cover every case.

The fix splits InvoiceOperations into small interfaces, one per capability. Each class implements only the capabilities it genuinely has.

from abc import ABC, abstractmethod


class Renderable(ABC):
    @abstractmethod
    def render_pdf(self):
        pass


class Chargeable(ABC):
    @abstractmethod
    def charge_payment(self):
        pass


class Remindable(ABC):
    @abstractmethod
    def send_reminder(self):
        pass


class DraftInvoice(Renderable):
    """Only implements the one capability a draft actually has."""
    def render_pdf(self):
        return "Rendering draft PDF"


class BilledInvoice(Renderable, Chargeable, Remindable):
    """A billed invoice supports every capability."""
    def render_pdf(self):
        return "Rendering billed PDF"

    def charge_payment(self):
        return "Charged customer for invoice"

    def send_reminder(self):
        return "Sent payment reminder"


draft = DraftInvoice()
billed = BilledInvoice()

print(draft.render_pdf())
print(billed.charge_payment())
print(billed.send_reminder())
Rendering draft PDF
Charged customer for invoice
Sent payment reminder

DraftInvoice no longer contains a single line of code that raises an exception. It implements exactly one interface, Renderable, and nothing else. Code that needs to charge a customer asks for a Chargeable, and a DraftInvoice simply cannot be passed there, because it never claimed to be one.

ISP and SRP often overlap, but they answer different questions

SRP asks “how many reasons does this class have to change?” ISP asks “how many methods does this interface force a client to depend on, even if it never calls them?” A class can satisfy SRP, having one clear job, while still implementing a bloated interface it only partly needs. Fixing SRP usually means splitting a class; fixing ISP usually means splitting an interface.


Dependency Inversion Principle (DIP)

The rule: high-level code should depend on abstractions, not on low-level, concrete implementations. Both should depend on the same interface.

Ledgerly’s InvoiceService needs to charge a customer, so it creates a StripeClient directly and calls it.

class StripeClient:
    """Low-level detail: talks to a specific payment provider."""
    def create_charge(self, amount):
        return f"stripe_charge_id_for_${amount:.2f}"


class InvoiceService:
    """High-level policy, tightly coupled to a low-level detail."""
    def __init__(self):
        self.stripe = StripeClient()

    def bill_customer(self, amount):
        charge_id = self.stripe.create_charge(amount)
        return f"Billed via Stripe, reference {charge_id}"


service = InvoiceService()
print(service.bill_customer(120))
Billed via Stripe, reference stripe_charge_id_for_$120.00

InvoiceService is high-level business logic, billing a customer, and StripeClient is a low-level implementation detail, one specific provider’s API. Because InvoiceService creates StripeClient itself, you cannot swap in a different provider, and you cannot test bill_customer() without a real Stripe connection.

The fix introduces a PaymentGateway abstraction that both StripeClient and a test double depend on. InvoiceService now depends only on that abstraction, supplied from outside the class.

from abc import ABC, abstractmethod


class PaymentGateway(ABC):
    """Abstraction that both high-level and low-level code depend on."""
    @abstractmethod
    def create_charge(self, amount):
        pass


class StripeClient(PaymentGateway):
    def create_charge(self, amount):
        return f"stripe_charge_id_for_${amount:.2f}"


class InMemoryTestGateway(PaymentGateway):
    """A fake gateway used only in tests - no network, no Stripe account."""
    def create_charge(self, amount):
        return f"test_charge_id_for_${amount:.2f}"


class InvoiceService:
    """High-level policy, now depending on an abstraction, not a detail."""
    def __init__(self, gateway: PaymentGateway):
        self.gateway = gateway

    def bill_customer(self, amount):
        charge_id = self.gateway.create_charge(amount)
        return f"Billed via gateway, reference {charge_id}"


production_service = InvoiceService(StripeClient())
test_service = InvoiceService(InMemoryTestGateway())

print(production_service.bill_customer(120))
print(test_service.bill_customer(120))
Billed via gateway, reference stripe_charge_id_for_$120.00
Billed via gateway, reference test_charge_id_for_$120.00

InvoiceService no longer mentions StripeClient anywhere in its code. It receives a PaymentGateway from whoever creates it, so production code passes StripeClient and tests pass InMemoryTestGateway. The dependency that used to point from high-level code down to a low-level detail now points from both sides toward the shared abstraction, which is the “inversion” the principle is named for.


How the Five Principles Work Together

None of these principles work in isolation. SRP gives you small, focused classes. OCP tells you to extend those classes by adding new ones, not by editing existing ones. LSP makes sure the classes you add can actually replace their parent without breaking callers. ISP keeps the interfaces those classes implement narrow and honest. DIP ties it together by making high-level code depend on those narrow interfaces instead of on any one concrete class.

Here is a small Ledgerly example that leans on several principles at once: a BillingService that charges a customer and then notifies them, without knowing which payment provider or which notification channel it is using.

from abc import ABC, abstractmethod


# DIP + ISP: focused abstractions high-level code depends on
class NotificationSender(ABC):
    @abstractmethod
    def send(self, recipient, message):
        pass


class PaymentGateway(ABC):
    @abstractmethod
    def charge(self, amount):
        pass


# OCP + LSP: new implementations slot in without changing existing code,
# and any one of them can substitute for another
class EmailNotificationSender(NotificationSender):
    def send(self, recipient, message):
        return f"Email to {recipient}: {message}"


class SmsNotificationSender(NotificationSender):
    def send(self, recipient, message):
        return f"SMS to {recipient}: {message}"


class StripeGateway(PaymentGateway):
    def charge(self, amount):
        return f"Charged ${amount:.2f} via Stripe"


# SRP: this class has one job - coordinate billing a customer
class BillingService:
    def __init__(self, gateway: PaymentGateway, sender: NotificationSender):
        self.gateway = gateway  # DIP: depends on an abstraction
        self.sender = sender    # DIP: depends on an abstraction

    def bill_and_notify(self, customer_email, amount):
        result = self.gateway.charge(amount)
        return self.sender.send(customer_email, f"Payment received: {result}")


service = BillingService(StripeGateway(), EmailNotificationSender())
print(service.bill_and_notify("[email protected]", 79.0))

service_sms = BillingService(StripeGateway(), SmsNotificationSender())
print(service_sms.bill_and_notify("+1-555-0134", 79.0))
Email to [email protected]: Payment received: Charged $79.00 via Stripe
SMS to +1-555-0134: Payment received: Charged $79.00 via Stripe

BillingService has one job (SRP). It can gain a new gateway or a new notification channel by adding a class, not editing one (OCP). Any NotificationSender or PaymentGateway can substitute for another without breaking bill_and_notify() (LSP). Both abstractions are narrow, one method each (ISP). And BillingService never mentions StripeGateway or EmailNotificationSender by name in its own code, only the abstractions (DIP).


Practice Exercises

Exercise 1: Spot the SRP violation

Ledgerly’s Customer class has a save() method that writes to the database, a format_for_export() method that builds a CSV row, and a validate_tax_id() method that checks a government tax ID format. List the separate reasons this class could need to change, and propose the classes you would split it into.

Hint

Three reasons to change: the database schema, the CSV export format, and the tax ID validation rules for a given country. Split into CustomerRepository (saving), CustomerCsvExporter (formatting), and TaxIdValidator (validation), leaving Customer as a plain data holder with none of these methods.

Exercise 2: Extend without modifying

Ledgerly’s NotificationSender abstraction (shown in this lesson) currently has EmailNotificationSender and SmsNotificationSender. Write the class you would add to support push notifications, and state which existing classes you had to change to add it.

Hint

Add a PushNotificationSender(NotificationSender) class with its own send() method. No existing class changes: not NotificationSender, not EmailNotificationSender, not BillingService. That is the Open/Closed Principle working as intended — extension through a new class, zero modification to working code.

Exercise 3: Find the LSP violation

A teammate proposes an AnnualSubscription(Subscription) whose apply_discount() method raises an exception unless the subscription has been active for at least 12 months, while every other Subscription subclass’s apply_discount() always succeeds. A billing job calls apply_discount() on every active subscription overnight. What breaks, and how would you redesign it?

Hint

The overnight job crashes the first time it reaches an AnnualSubscription under 12 months old, because that subclass cannot be substituted for Subscription the way the job expects — calling an inherited method should never explode. Redesign so apply_discount() returns “not yet eligible” instead of raising, or introduce a separate DiscountEligible interface that only mature subscriptions implement, and have the billing job check for that interface before calling it.


Summary

SOLID is five rules for keeping object-oriented code changeable as Ledgerly grows. The Single Responsibility Principle keeps each class focused on one job, so one kind of change only touches one class. The Open/Closed Principle lets you add behavior, like a new payment provider, by adding a class instead of editing a working one. The Liskov Substitution Principle makes sure a subclass never breaks a caller that expected its parent, catching mismatches like an uncancellable “cancellable” subscription. The Interface Segregation Principle keeps interfaces narrow, so a class like DraftInvoice never has to fake support for operations it cannot perform. The Dependency Inversion Principle points dependencies at shared abstractions instead of concrete classes, so InvoiceService can be tested and rewired without touching Stripe at all. Applied together, as in the BillingService example, the five principles reinforce each other far more than any one applied alone.

Key Concepts

  • Single Responsibility Principle (SRP) — a class should have only one reason to change.
  • Open/Closed Principle (OCP) — open for extension through new classes, closed for modification of existing ones.
  • Liskov Substitution Principle (LSP) — a subclass must work anywhere its parent is expected, without breaking the caller.
  • Interface Segregation Principle (ISP) — prefer several small, focused interfaces over one that forces unused methods on implementers.
  • Dependency Inversion Principle (DIP) — high-level code should depend on abstractions, and low-level details should implement those same abstractions.

Why This Matters

Ledgerly is a three-person team today, but every SOLID violation you leave in place compounds as the app grows. A bloated InvoiceService becomes the one file everyone is afraid to touch. A payment gateway built on if/elif branches becomes slower to extend with each new provider. Code that depends directly on Stripe becomes code you cannot test without a live Stripe account. Applying SOLID early costs a little extra design time now, in exchange for a codebase that stays easy to extend, test, and hand off to new teammates later. Next, you will step back from individual classes and look at how Ledgerly’s overall software development lifecycle organizes this kind of work from idea to release.

Next Steps

Lesson 3: The Software Development Lifecycle

See how Ledgerly's work moves from requirements through design, build, test, and release.

Back to Module Overview

Return to the Engineering Foundations module overview


Continue Building Your Skills

You can now name each SOLID principle, spot a violation of it in Ledgerly’s codebase, and rewrite the violation into a working fix. That skill applies to every object-oriented codebase you touch, not just Ledgerly. Next, you will zoom out from individual classes to the full lifecycle a feature travels through, from a written requirement to a deployed release.

Sponsor

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

Buy Me a Coffee at ko-fi.com