Lesson 2 - The SOLID Principles
On this page
- Welcome to The SOLID Principles
- Single Responsibility Principle (SRP)
- Open/Closed Principle (OCP)
- Liskov Substitution Principle (LSP)
- Interface Segregation Principle (ISP)
- Dependency Inversion Principle (DIP)
- How the Five Principles Work Together
- Practice Exercises
- Summary
- Next Steps
- Continue Building Your Skills
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-1042This 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-1042The 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 PayPalThe 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 Wiseprocess_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 canceledcancel_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: TrueNow 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.
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 chargedDraftInvoice 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 reminderDraftInvoice 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.00InvoiceService 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.00InvoiceService 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 StripeBillingService 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.