Refactor a Monolith with SOLID Principles
Refactor a messy order processor into clean, modular Python using all 5 SOLID principles.
Introduction
30 Second Summary
You know that one file at work that everyone dreads touching? The class that handles validation, pricing, database saves, and notifications all in one place, where every new feature risks breaking something else.
In this project, you will take a monolithic order processing class and refactor it step-by-step using all five SOLID principles. You'll end up with a clean, extensible system where adding new features requires zero changes to existing code.
What You'll Build
A modular order processing system refactored from a single 60-line class into focused components using Abstract Base Classes, Protocols, and dependency injection.
By the end of this project, you'll have:
- A monolithic OrderProcessor that violates all 5 SOLID principles, so you can see what "bad" looks like before you fix it.
- A refactored order_system.py with focused classes, abstract interfaces, and a strategy registry for extensibility.
- A composition root in main.py that wires all dependencies together in one place.
- Secret Mission: Add a tiered discount strategy and composite notifier without modifying any existing classes.
Are there any prerequisites?
This project assumes you're comfortable with Python OOP basics: classes, inheritance, and decorators. You don't need experience with SOLID principles or design patterns.
Set Up Your Project and Write the Monolith
Every codebase has that one class that does everything. It validates, calculates, saves, notifies, and reports. It works today, but the moment requirements change, the whole thing becomes fragile.
In this step, you'll write exactly that kind of class on purpose. Starting with the monolith gives you a concrete "before" state so that every refactoring step later makes the improvement visible.
In this step, get ready to:
- Create the solid-refactor project folder and files.
- Write a monolithic OrderProcessor class that handles five responsibilities in one place.
- Identify which SOLID principles each section violates.
Create the project folder and files
You need a dedicated folder to hold the monolithic code and the refactored version side by side. Two files will live inside it: one for the order system logic and one for running it.
- Open your terminal in VS Code by pressing Ctrl+` (the backtick key, top-left of your keyboard).
- Move to your Desktop by running this command:
cd ~/Desktop
- Create the project folder and move into it by running these commands:
mkdir solid-refactor
cd solid-refactor
- Create the two project files by running this command:
macOS
touch order_system.py main.py
Windows
New-Item order_system.py
New-Item main.py
- Confirm the files exist by running:
ls
You should see order_system.py and main.py in the output.
- Open the solid-refactor folder as your workspace in VS Code.
- Click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the solid-refactor folder, then click Open.
Don't see the files in VS Code's Explorer?
Make sure you opened the solid-refactor folder itself, not the Desktop folder. The Explorer panel on the left should show order_system.py and main.py at the top level.
help me open a project folder in VS Code
Write the monolithic OrderProcessor
This class intentionally crams five different responsibilities into a single process() method: validation, pricing, persistence, notification, and reporting. It works, but any change to one concern risks breaking the others.
- Open order_system.py in the Explorer panel and paste the following code:
# Monolithic OrderProcessor — violates all 5 SOLID principles
import json
import os
import csv
from datetime import datetime
class OrderProcessor:
def __init__(self, file_path: str = "orders.json", report_path: str = "report.csv"):
self.file_path = file_path
self.report_path = report_path
def process(self, order: dict) -> dict:
# Validation (SRP violation)
if not order.get("items"):
raise ValueError("Order must have items")
if not order.get("customer_email"):
raise ValueError("Customer email required")
for item in order["items"]:
if item["quantity"] <= 0:
raise ValueError(f"Invalid quantity for {item['name']}")
# Pricing (SRP + OCP violation)
subtotal = sum(i["price"] * i["quantity"] for i in order["items"])
discount_type = order.get("discount_type", "")
discount_value = order.get("discount_value", 0)
if discount_type == "percentage":
subtotal = subtotal - (subtotal * discount_value / 100)
elif discount_type == "fixed":
subtotal = subtotal - discount_value
tax = subtotal * 0.1
total = subtotal + tax
# Persistence (SRP + DIP violation)
record = {
"id": datetime.now().strftime("%Y%m%d%H%M%S"),
"customer_email": order["customer_email"],
"items": order["items"],
"subtotal": subtotal,
"tax": tax,
"total": total,
"status": "completed",
"created_at": datetime.now().isoformat(),
}
orders = []
if os.path.exists(self.file_path):
with open(self.file_path, "r") as f:
orders = json.load(f)
orders.append(record)
with open(self.file_path, "w") as f:
json.dump(orders, f, indent=2)
# Notification (SRP + DIP violation)
print(f"Email to {order['customer_email']}: Your order total is ${total:.2f}")
# Reporting (SRP violation)
write_header = not os.path.exists(self.report_path)
with open(self.report_path, "a", newline="") as f:
writer = csv.writer(f)
if write_header:
writer.writerow(["id", "email", "total", "created_at"])
writer.writerow([record["id"], record["customer_email"], record["total"], record["created_at"]])
return record
What does this code do?
This single class handles everything an order needs. Each commented section is a separate concern that will eventually become its own class:
- Validation checks that items exist, an email is present, and quantities are positive.
- Pricing calculates subtotals, applies discounts with hardcoded if/elif branches, and adds tax.
- Persistence saves the order directly to a JSON file.
- Notification prints a fake email to the console.
- Reporting appends a row to a CSV file.
- Save order_system.py.
Now you need a script to actually run this class and confirm it works.
- Open main.py in the Explorer panel and paste this code:
from order_system import OrderProcessor
processor = OrderProcessor()
order = {
"customer_email": "alice@example.com",
"items": [
{"name": "Widget", "price": 29.99, "quantity": 2},
{"name": "Gadget", "price": 49.99, "quantity": 1},
],
"discount_type": "percentage",
"discount_value": 10,
}
record = processor.process(order)
print(f"Order processed! ID: {record['id']}, Total: ${record['total']:.2f}")
What does this code do?
This script creates a sample order with two items and a 10% discount, then feeds it into the monolithic OrderProcessor. It exercises every responsibility at once: validation, pricing, file saving, notification, and reporting.
- Save main.py.
- Run the script by entering this command in your terminal:
python main.py
You should see two lines printed. The first is the fake email notification. The second confirms the order was processed with an ID and total.
Seeing a ModuleNotFoundError?
Make sure your terminal is inside the solid-refactor folder (not one level above or inside a subfolder). Run ls to confirm you see both order_system.py and main.py in the current directory.
Also check that both files are saved. Unsaved changes in VS Code show a dot on the file tab.
help me fix my ModuleNotFoundError
- Confirm that an orders.json file appeared in your folder by running:
ls
You should now see orders.json and report.csv alongside your two Python files. The monolith created both output files in a single method call.
✔️ Awesome, I've got everything!
Great. Double check you've saved both files before continuing.
ⓧ I'd like to double check the full code
Here is the full order_system.py:
# Monolithic OrderProcessor — violates all 5 SOLID principles
import json
import os
import csv
from datetime import datetime
class OrderProcessor:
def __init__(self, file_path: str = "orders.json", report_path: str = "report.csv"):
self.file_path = file_path
self.report_path = report_path
def process(self, order: dict) -> dict:
# Validation (SRP violation)
if not order.get("items"):
raise ValueError("Order must have items")
if not order.get("customer_email"):
raise ValueError("Customer email required")
for item in order["items"]:
if item["quantity"] <= 0:
raise ValueError(f"Invalid quantity for {item['name']}")
# Pricing (SRP + OCP violation)
subtotal = sum(i["price"] * i["quantity"] for i in order["items"])
discount_type = order.get("discount_type", "")
discount_value = order.get("discount_value", 0)
if discount_type == "percentage":
subtotal = subtotal - (subtotal * discount_value / 100)
elif discount_type == "fixed":
subtotal = subtotal - discount_value
tax = subtotal * 0.1
total = subtotal + tax
# Persistence (SRP + DIP violation)
record = {
"id": datetime.now().strftime("%Y%m%d%H%M%S"),
"customer_email": order["customer_email"],
"items": order["items"],
"subtotal": subtotal,
"tax": tax,
"total": total,
"status": "completed",
"created_at": datetime.now().isoformat(),
}
orders = []
if os.path.exists(self.file_path):
with open(self.file_path, "r") as f:
orders = json.load(f)
orders.append(record)
with open(self.file_path, "w") as f:
json.dump(orders, f, indent=2)
# Notification (SRP + DIP violation)
print(f"Email to {order['customer_email']}: Your order total is ${total:.2f}")
# Reporting (SRP violation)
write_header = not os.path.exists(self.report_path)
with open(self.report_path, "a", newline="") as f:
writer = csv.writer(f)
if write_header:
writer.writerow(["id", "email", "total", "created_at"])
writer.writerow([record["id"], record["customer_email"], record["total"], record["created_at"]])
return record
And here is the full main.py:
from order_system import OrderProcessor
processor = OrderProcessor()
order = {
"customer_email": "alice@example.com",
"items": [
{"name": "Widget", "price": 29.99, "quantity": 2},
{"name": "Gadget", "price": 49.99, "quantity": 1},
],
"discount_type": "percentage",
"discount_value": 10,
}
record = processor.process(order)
print(f"Order processed! ID: {record['id']}, Total: ${record['total']:.2f}")
Identify the violations
Now that the monolith works, look at the comments inside order_system.py. Each section is tagged with the SOLID principle it violates. Count how many separate reasons this one class has to change.
What are the five reasons to change?
The Single Responsibility Principle says a class should have only one reason to change. This OrderProcessor has five:
- Validation rules change (e.g. adding a maximum order size).
- Pricing logic changes (e.g. adding a "buy one get one" discount). This also violates the Open/Closed Principle because you must modify the if/elif chain to add a new type.
- Database choice changes (e.g. switching from JSON files to a real database). This violates the Dependency Inversion Principle because the class directly depends on json file operations.
- Notification method changes (e.g. sending an SMS instead of an email). Also a DIP violation for the same reason.
- Report format changes (e.g. switching from CSV to a PDF or dashboard).
What about the other principles?
The Liskov Substitution Principle and Interface Segregation Principle are harder to spot in a monolith because they apply to hierarchies and interfaces. Right now there are no interfaces to be fat or substitutions to break.
You will introduce small, focused interfaces in Step 4. For now, the key takeaway is that this single class has five independent axes of change crammed together.
Your monolith works, but it's held together with duct tape. In the next step, you'll start pulling it apart by giving each responsibility its own class using the Single Responsibility Principle.
Apply the Single Responsibility Principle
You've got a working monolith and you've spotted the problem. Every change to one concern (pricing, notifications, storage) risks breaking something unrelated. The Single Responsibility Principle says each class should have exactly one reason to change.
In this step, you'll tear out the monolith and rebuild it as a set of focused classes. Each class owns one job: validation, pricing, persistence, or notification. An orchestrator ties them together without knowing the details of any single responsibility.
In this step, get ready to:
- Extract an OrderValidator class that owns all validation logic.
- Extract a PricingCalculator class that owns subtotal, discount, and tax math.
- Extract repository, notification, and orchestration classes so each has one reason to change.
Replace the monolith with data models and a validator
The monolith uses raw dictionaries to pass order data around. Before extracting logic into classes, you need typed data models so each class knows exactly what shape of data it receives.
What are dataclasses?
A dataclass is a Python decorator that auto-generates __init__, __repr__, and other boilerplate for classes that mainly hold data. You define the fields with type annotations and Python handles the rest.
- Select all the code in order_system.py and delete it.
- Replace it with the following imports, data models, and OrderValidator class:
from dataclasses import dataclass
from datetime import datetime
import json
import os
# --- Models ---
@dataclass
class OrderItem:
name: str
price: float
quantity: int
@dataclass
class Order:
customer_email: str
items: list[OrderItem]
discount_type: str = ""
discount_value: float = 0.0
@dataclass
class OrderRecord:
id: str
customer_email: str
items: list[OrderItem]
subtotal: float
tax: float
total: float
status: str
created_at: str
# --- SRP: Each class has one responsibility ---
class OrderValidator:
def validate(self, order: Order) -> None:
if not order.items:
raise ValueError("Order must have items")
if not order.customer_email:
raise ValueError("Customer email required")
for item in order.items:
if item.quantity <= 0:
raise ValueError(f"Invalid quantity for {item.name}")
What does this code do?
The three dataclasses (OrderItem, Order, OrderRecord) replace the raw dictionaries from the monolith. Now every class that handles order data gets a typed object with named fields instead of guessing dictionary keys.
OrderValidator owns the single responsibility of checking whether an order is valid. If validation rules change (e.g., adding a minimum order value), only this class changes.
- Save order_system.py.
- You won't see terminal output yet. The next chunks add the remaining classes that complete the system.
Seeing red underlines in VS Code?
Your editor may show errors because main.py still imports the old OrderProcessor class that no longer exists. This is expected. You'll fix main.py at the end of this step.
help me understand why my editor shows import errors after deleting a class
Extract a PricingCalculator class
The monolith's pricing logic handled subtotals, discounts, and tax in one block of code tangled with everything else. A dedicated PricingCalculator gives pricing its own reason to change. If tax rates update or discount rules evolve, only this class is affected.
- In order_system.py, add the following class below OrderValidator:
class PricingCalculator:
def __init__(self, tax_rate: float = 0.1):
self.tax_rate = tax_rate
def calculate(self, order: Order) -> tuple[float, float, float]:
subtotal = sum(item.price * item.quantity for item in order.items)
discount_type = order.discount_type
discount_value = order.discount_value
if discount_type == "percentage":
subtotal = subtotal - (subtotal * discount_value / 100)
elif discount_type == "fixed":
subtotal = subtotal - discount_value
tax = subtotal * self.tax_rate
total = subtotal + tax
return subtotal, tax, total
What does this code do?
- tax_rate is a constructor parameter instead of a hardcoded 0.1. This makes the calculator reusable for different tax jurisdictions.
- calculate() returns a tuple of (subtotal, tax, total) so the caller gets all pricing data in one call.
- The discount logic is still inline with if/elif branches. You'll improve this in the next step with the Open/Closed Principle.
- Save order_system.py.
- You still won't see output yet. The next chunk adds persistence, notification, and the orchestrator that ties everything together.
Extract repository and notification responsibilities
Two more responsibilities need their own classes: saving orders to a file and sending notifications. Once those are extracted, you need an orchestrator that calls each class in sequence without knowing the details of any one.
- In order_system.py, add the following classes below PricingCalculator:
class JsonOrderRepository:
def __init__(self, file_path: str = "orders.json"):
self.file_path = file_path
def save(self, record: OrderRecord) -> None:
orders = []
if os.path.exists(self.file_path):
with open(self.file_path, "r") as f:
orders = json.load(f)
orders.append({
"id": record.id,
"customer_email": record.customer_email,
"items": [
{"name": i.name, "price": i.price, "quantity": i.quantity}
for i in record.items
],
"subtotal": record.subtotal,
"tax": record.tax,
"total": record.total,
"status": record.status,
"created_at": record.created_at,
})
with open(self.file_path, "w") as f:
json.dump(orders, f, indent=2)
class EmailNotifier:
def send(self, recipient: str, message: str) -> None:
print(f"Email to {recipient}: {message}")
class OrderService:
def __init__(self, validator, pricing, repository, notifier):
self.validator = validator
self.pricing = pricing
self.repository = repository
self.notifier = notifier
def process_order(self, order: Order) -> OrderRecord:
self.validator.validate(order)
subtotal, tax, total = self.pricing.calculate(order)
record = OrderRecord(
id=datetime.now().strftime("%Y%m%d%H%M%S"),
customer_email=order.customer_email,
items=order.items,
subtotal=subtotal,
tax=tax,
total=total,
status="completed",
created_at=datetime.now().isoformat(),
)
self.repository.save(record)
self.notifier.send(
order.customer_email,
f"Your order total is ${total:.2f}",
)
return record
What does this code do?
- JsonOrderRepository handles only persistence. If you switch to a database later, only this class changes.
- EmailNotifier handles only sending messages. It prints to the console for now, but the signature is ready for a real email service.
- OrderService is the orchestrator. It calls validate, calculate, save, and send in sequence. It has one responsibility: coordinating the workflow.
- Save order_system.py.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on to update main.py.
ⓧ I'd like to double check the full code
from dataclasses import dataclass
from datetime import datetime
import json
import os
# --- Models ---
@dataclass
class OrderItem:
name: str
price: float
quantity: int
@dataclass
class Order:
customer_email: str
items: list[OrderItem]
discount_type: str = ""
discount_value: float = 0.0
@dataclass
class OrderRecord:
id: str
customer_email: str
items: list[OrderItem]
subtotal: float
tax: float
total: float
status: str
created_at: str
# --- SRP: Each class has one responsibility ---
class OrderValidator:
def validate(self, order: Order) -> None:
if not order.items:
raise ValueError("Order must have items")
if not order.customer_email:
raise ValueError("Customer email required")
for item in order.items:
if item.quantity <= 0:
raise ValueError(f"Invalid quantity for {item.name}")
class PricingCalculator:
def __init__(self, tax_rate: float = 0.1):
self.tax_rate = tax_rate
def calculate(self, order: Order) -> tuple[float, float, float]:
subtotal = sum(item.price * item.quantity for item in order.items)
discount_type = order.discount_type
discount_value = order.discount_value
if discount_type == "percentage":
subtotal = subtotal - (subtotal * discount_value / 100)
elif discount_type == "fixed":
subtotal = subtotal - discount_value
tax = subtotal * self.tax_rate
total = subtotal + tax
return subtotal, tax, total
class JsonOrderRepository:
def __init__(self, file_path: str = "orders.json"):
self.file_path = file_path
def save(self, record: OrderRecord) -> None:
orders = []
if os.path.exists(self.file_path):
with open(self.file_path, "r") as f:
orders = json.load(f)
orders.append({
"id": record.id,
"customer_email": record.customer_email,
"items": [
{"name": i.name, "price": i.price, "quantity": i.quantity}
for i in record.items
],
"subtotal": record.subtotal,
"tax": record.tax,
"total": record.total,
"status": record.status,
"created_at": record.created_at,
})
with open(self.file_path, "w") as f:
json.dump(orders, f, indent=2)
class EmailNotifier:
def send(self, recipient: str, message: str) -> None:
print(f"Email to {recipient}: {message}")
class OrderService:
def __init__(self, validator, pricing, repository, notifier):
self.validator = validator
self.pricing = pricing
self.repository = repository
self.notifier = notifier
def process_order(self, order: Order) -> OrderRecord:
self.validator.validate(order)
subtotal, tax, total = self.pricing.calculate(order)
record = OrderRecord(
id=datetime.now().strftime("%Y%m%d%H%M%S"),
customer_email=order.customer_email,
items=order.items,
subtotal=subtotal,
tax=tax,
total=total,
status="completed",
created_at=datetime.now().isoformat(),
)
self.repository.save(record)
self.notifier.send(
order.customer_email,
f"Your order total is ${total:.2f}",
)
return record
Now you need to update main.py to use the new classes instead of the old OrderProcessor.
- Select all the code in main.py and replace it with the following:
from order_system import (
Order,
OrderItem,
OrderValidator,
PricingCalculator,
JsonOrderRepository,
EmailNotifier,
OrderService,
)
# Instantiate each focused class
validator = OrderValidator()
pricing = PricingCalculator(tax_rate=0.1)
repository = JsonOrderRepository("orders.json")
notifier = EmailNotifier()
# Wire them into the orchestrator
service = OrderService(
validator=validator,
pricing=pricing,
repository=repository,
notifier=notifier,
)
# Create and process an order using typed dataclasses
order = Order(
customer_email="alice@example.com",
items=[
OrderItem(name="Widget", price=29.99, quantity=2),
OrderItem(name="Gadget", price=49.99, quantity=1),
],
discount_type="percentage",
discount_value=10,
)
record = service.process_order(order)
print(f"Order processed! ID: {record.id}, Total: ${record.total:.2f}")
What changed from the old main.py?
- Instead of one OrderProcessor, you instantiate each responsibility as its own object and hand them to OrderService.
- The order data uses typed Order and OrderItem dataclasses instead of a raw dictionary.
- The result is an OrderRecord object with named attributes (record.id, record.total) instead of dictionary keys.
- Save main.py.
- Delete the existing orders.json file from the previous step so you can verify a fresh file gets created. Run this in your terminal:
rm -f orders.json
- Run the refactored system by running:
python main.py
- You should see output identical to the monolith:
Email to alice@example.com: Your order total is $108.89
Order processed! ID: 20260621..., Total: $108.89
- Confirm that orders.json was recreated by running:
ls orders.json
Seeing a ModuleNotFoundError or ImportError?
- Check that main.py imports from order_system (not order_system.py) and that the class names match exactly.
- Make sure your terminal is inside the solid-refactor folder. If you're one level up, Python can't find the module.
- Verify you saved both files. Unsaved changes in the editor won't be picked up by the Python interpreter.
help me debug my import error when running python main.py
✔️ Awesome, I've got everything!
Great. Double check you've saved both files before moving on.
ⓧ I'd like to double check the full code
from order_system import (
Order,
OrderItem,
OrderValidator,
PricingCalculator,
JsonOrderRepository,
EmailNotifier,
OrderService,
)
validator = OrderValidator()
pricing = PricingCalculator(tax_rate=0.1)
repository = JsonOrderRepository("orders.json")
notifier = EmailNotifier()
service = OrderService(
validator=validator,
pricing=pricing,
repository=repository,
notifier=notifier,
)
order = Order(
customer_email="alice@example.com",
items=[
OrderItem(name="Widget", price=29.99, quantity=2),
OrderItem(name="Gadget", price=49.99, quantity=1),
],
discount_type="percentage",
discount_value=10,
)
record = service.process_order(order)
print(f"Order processed! ID: {record.id}, Total: ${record.total:.2f}")
Why is the output identical?
That's the whole point of SRP refactoring. The external behavior stays the same. What changed is the internal structure. Now each class has exactly one reason to change: validation rules, pricing math, storage format, or notification method.
Count the "reasons to change" now. If the tax rate changes, only PricingCalculator is touched. If you switch from JSON to a database, only JsonOrderRepository is touched. No more shotgun surgery.
Your system now follows the Single Responsibility Principle. Each class has one job and one reason to change. Next up, you'll tackle the discount logic. Those if/elif branches mean adding a new discount type requires editing PricingCalculator directly. The Open/Closed Principle fixes that.
Apply the Open/Closed Principle
Your classes now each have a single responsibility. But look at PricingCalculator closely.
Every time someone needs a new discount type, they have to crack open the calculate() method and add another elif branch. That is a violation of the Open/Closed Principle: code should be open for extension but closed for modification. In this step, you will replace those hardcoded branches with a pluggable strategy system.
In this step, get ready to:
- Create a DiscountStrategy abstract base class with concrete implementations.
- Refactor PricingCalculator to use a strategy registry.
- Register a brand-new discount type without modifying any existing class.
Create a DiscountStrategy abstract base class
The Abstract Base Class pattern lets you define a contract that all discount strategies must follow. Python's abc module enforces this contract at instantiation time, so a missing method is caught immediately instead of at runtime.
- In order_system.py, add this import at the very top of the file, above the existing from dataclasses import dataclass line:
from abc import ABC, abstractmethod
- Below the OrderValidator class (and above PricingCalculator), add the abstract strategy and its three concrete implementations:
class DiscountStrategy(ABC):
@abstractmethod
def apply(self, subtotal: float, value: float) -> float:
pass
class PercentageDiscount(DiscountStrategy):
def apply(self, subtotal: float, value: float) -> float:
return subtotal - (subtotal * value / 100)
class FixedDiscount(DiscountStrategy):
def apply(self, subtotal: float, value: float) -> float:
return subtotal - value
class NoDiscount(DiscountStrategy):
def apply(self, subtotal: float, value: float) -> float:
return subtotal
What does this code do?
DiscountStrategy is an Abstract Base Class. It defines a single method apply() that every discount must implement. You cannot instantiate DiscountStrategy directly. Python will raise a TypeError if you try.
Each concrete class (PercentageDiscount, FixedDiscount, NoDiscount) provides its own calculation logic. Adding a new discount later means creating a new class, not touching these.
- Save order_system.py.
Seeing an import error?
Make sure from abc import ABC, abstractmethod is on the very first line of the file. If it appears below a class that references ABC, Python will raise a NameError.
Help me debug my ABC import issue
Build a strategy registry in PricingCalculator
Instead of hardcoded branches, PricingCalculator will look up the right strategy from a dictionary. New strategies register themselves without modifying the calculator.
- In order_system.py, find the existing PricingCalculator class. It currently looks like this:
class PricingCalculator:
def __init__(self, tax_rate: float = 0.1):
self.tax_rate = tax_rate
def calculate(self, order: Order) -> tuple[float, float, float]:
subtotal = sum(item.price * item.quantity for item in order.items)
discount_type = order.discount_type
discount_value = order.discount_value
if discount_type == "percentage":
subtotal = subtotal - (subtotal * discount_value / 100)
elif discount_type == "fixed":
subtotal = subtotal - discount_value
tax = subtotal * self.tax_rate
total = subtotal + tax
return subtotal, tax, total
- Replace the entire PricingCalculator class with this version:
class PricingCalculator:
def __init__(self, tax_rate: float = 0.1):
self.tax_rate = tax_rate
# Registry maps discount type names to strategy instances
self._strategies: dict[str, DiscountStrategy] = {
"percentage": PercentageDiscount(),
"fixed": FixedDiscount(),
"": NoDiscount(),
}
def register_discount(self, name: str, strategy: DiscountStrategy) -> None:
# Add new strategies without modifying existing code
self._strategies[name] = strategy
def calculate(self, order: Order) -> tuple[float, float, float]:
subtotal = sum(item.price * item.quantity for item in order.items)
# Look up the strategy instead of branching with if/elif
strategy = self._strategies.get(order.discount_type, NoDiscount())
subtotal = strategy.apply(subtotal, order.discount_value)
tax = subtotal * self.tax_rate
total = subtotal + tax
return subtotal, tax, total
What changed here?
The if/elif chain is gone. In its place, _strategies is a dictionary that maps discount type names (like "percentage") to strategy objects. The calculate() method now just looks up the right strategy and calls apply().
The register_discount() method is the extension point. Anyone can add a new strategy to the registry from outside the class. The class itself never needs to change.
- Save order_system.py.
Seeing a NameError for DiscountStrategy?
The PricingCalculator class must appear below the DiscountStrategy, PercentageDiscount, FixedDiscount, and NoDiscount classes in the file. Python reads top to bottom, so referenced classes must be defined first.
Help me fix the class ordering in my file
Demonstrate extensibility
The real payoff of OCP is proving you can add features without touching existing code. You will now create a brand-new discount type in main.py and register it with the calculator.
- Replace the entire contents of main.py with this code:
from order_system import (
Order,
OrderItem,
OrderValidator,
PricingCalculator,
DiscountStrategy,
JsonOrderRepository,
EmailNotifier,
OrderService,
)
# A brand-new discount type defined OUTSIDE order_system.py
class BuyOneGetOneDiscount(DiscountStrategy):
def apply(self, subtotal: float, value: float) -> float:
return subtotal * 0.5
validator = OrderValidator()
pricing = PricingCalculator(tax_rate=0.1)
# Register the new strategy without modifying PricingCalculator
pricing.register_discount("bogo", BuyOneGetOneDiscount())
repository = JsonOrderRepository("orders.json")
notifier = EmailNotifier()
service = OrderService(
validator=validator,
pricing=pricing,
repository=repository,
notifier=notifier,
)
order = Order(
customer_email="alice@example.com",
items=[
OrderItem(name="Widget", price=29.99, quantity=2),
OrderItem(name="Gadget", price=49.99, quantity=1),
],
discount_type="percentage",
discount_value=10,
)
record = service.process_order(order)
print(f"Order processed! ID: {record.id}, Total: ${record.total:.2f}")
What does this code do?
BuyOneGetOneDiscount is a completely new discount strategy defined outside order_system.py. It extends DiscountStrategy and implements apply() to cut the subtotal in half.
The line pricing.register_discount("bogo", BuyOneGetOneDiscount()) plugs the new strategy into the calculator. No class in order_system.py was opened or edited to make this work. That is OCP in action.
- Save main.py.
- Run your system by executing this command in your terminal:
python main.py
You should see an email notification printed and an order total. The existing "percentage" discount still works exactly as before, and the new "bogo" strategy is ready whenever you change the order's discount_type.
Not seeing any output?
Make sure you are running the command from inside the solid-refactor folder. If your terminal is in a different directory, python main.py will fail with a file-not-found error.
Also verify that DiscountStrategy appears in the import block at the top of main.py. Without it, BuyOneGetOneDiscount cannot inherit from the base class.
Help me debug my main.py error
Why abstract base classes instead of plain inheritance?
A plain parent class lets you forget to implement apply() in a subclass. You would only discover the missing method when the code runs and hits an AttributeError.
With ABC and @abstractmethod, Python raises a TypeError the moment you try to instantiate an incomplete subclass. The bug is caught at construction time, not buried in a discount calculation path that only triggers for certain orders.
✔️ Awesome, I've got everything!
Great. Double check you have saved both files before continuing.
ⓧ I'd like to double check the full code
Here is the full order_system.py at this point:
from abc import ABC, abstractmethod
from dataclasses import dataclass
from datetime import datetime
import json
import os
@dataclass
class OrderItem:
name: str
price: float
quantity: int
@dataclass
class Order:
customer_email: str
items: list[OrderItem]
discount_type: str = ""
discount_value: float = 0.0
@dataclass
class OrderRecord:
id: str
customer_email: str
items: list[OrderItem]
subtotal: float
tax: float
total: float
status: str
created_at: str
class OrderValidator:
def validate(self, order: Order) -> None:
if not order.items:
raise ValueError("Order must have items")
if not order.customer_email:
raise ValueError("Customer email required")
for item in order.items:
if item.quantity <= 0:
raise ValueError(f"Invalid quantity for {item.name}")
class DiscountStrategy(ABC):
@abstractmethod
def apply(self, subtotal: float, value: float) -> float:
pass
class PercentageDiscount(DiscountStrategy):
def apply(self, subtotal: float, value: float) -> float:
return subtotal - (subtotal * value / 100)
class FixedDiscount(DiscountStrategy):
def apply(self, subtotal: float, value: float) -> float:
return subtotal - value
class NoDiscount(DiscountStrategy):
def apply(self, subtotal: float, value: float) -> float:
return subtotal
class PricingCalculator:
def __init__(self, tax_rate: float = 0.1):
self.tax_rate = tax_rate
self._strategies: dict[str, DiscountStrategy] = {
"percentage": PercentageDiscount(),
"fixed": FixedDiscount(),
"": NoDiscount(),
}
def register_discount(self, name: str, strategy: DiscountStrategy) -> None:
self._strategies[name] = strategy
def calculate(self, order: Order) -> tuple[float, float, float]:
subtotal = sum(item.price * item.quantity for item in order.items)
strategy = self._strategies.get(order.discount_type, NoDiscount())
subtotal = strategy.apply(subtotal, order.discount_value)
tax = subtotal * self.tax_rate
total = subtotal + tax
return subtotal, tax, total
class JsonOrderRepository:
def __init__(self, file_path: str = "orders.json"):
self.file_path = file_path
def save(self, record: OrderRecord) -> None:
orders = []
if os.path.exists(self.file_path):
with open(self.file_path, "r") as f:
orders = json.load(f)
orders.append({
"id": record.id,
"customer_email": record.customer_email,
"items": [
{"name": i.name, "price": i.price, "quantity": i.quantity}
for i in record.items
],
"subtotal": record.subtotal,
"tax": record.tax,
"total": record.total,
"status": record.status,
"created_at": record.created_at,
})
with open(self.file_path, "w") as f:
json.dump(orders, f, indent=2)
class EmailNotifier:
def send(self, recipient: str, message: str) -> None:
print(f"Email to {recipient}: {message}")
class OrderService:
def __init__(self, validator, pricing, repository, notifier):
self.validator = validator
self.pricing = pricing
self.repository = repository
self.notifier = notifier
def process_order(self, order: Order) -> OrderRecord:
self.validator.validate(order)
subtotal, tax, total = self.pricing.calculate(order)
record = OrderRecord(
id=datetime.now().strftime("%Y%m%d%H%M%S"),
customer_email=order.customer_email,
items=order.items,
subtotal=subtotal,
tax=tax,
total=total,
status="completed",
created_at=datetime.now().isoformat(),
)
self.repository.save(record)
self.notifier.send(
order.customer_email,
f"Your order total is ${total:.2f}",
)
return record
And here is the full main.py:
from order_system import (
Order,
OrderItem,
OrderValidator,
PricingCalculator,
DiscountStrategy,
JsonOrderRepository,
EmailNotifier,
OrderService,
)
class BuyOneGetOneDiscount(DiscountStrategy):
def apply(self, subtotal: float, value: float) -> float:
return subtotal * 0.5
validator = OrderValidator()
pricing = PricingCalculator(tax_rate=0.1)
pricing.register_discount("bogo", BuyOneGetOneDiscount())
repository = JsonOrderRepository("orders.json")
notifier = EmailNotifier()
service = OrderService(
validator=validator,
pricing=pricing,
repository=repository,
notifier=notifier,
)
order = Order(
customer_email="alice@example.com",
items=[
OrderItem(name="Widget", price=29.99, quantity=2),
OrderItem(name="Gadget", price=49.99, quantity=1),
],
discount_type="percentage",
discount_value=10,
)
record = service.process_order(order)
print(f"Order processed! ID: {record.id}, Total: ${record.total:.2f}")
Your pricing system is now extensible without modification. Next up, you will define small, focused interfaces using Protocol so that any class satisfying the contract can be swapped in freely.
Apply Liskov Substitution and Interface Segregation
Right now, OrderService accepts any object as its repository and notifier parameters. There's no explicit contract stating what methods those objects must have.
In this step, you'll define small Protocol interfaces that formalize these contracts. Then you'll prove that any object satisfying the contract can be swapped in freely.
In this step, get ready to:
- Define Protocol interfaces for notifications and persistence.
- Add an SMSNotifier that satisfies the same contract as EmailNotifier.
- Prove substitutability by swapping implementations with a one-line change.
Define small Protocol interfaces
The Interface Segregation Principle says no class should be forced to depend on methods it doesn't use. Instead of one large interface covering everything, you define small, focused contracts.
Python's Protocol class enables structural subtyping. Any class that implements the required methods satisfies the Protocol automatically, without needing to inherit from it.
- In order_system.py, find the import lines at the top of the file and add the Protocol import as the second line:
from abc import ABC, abstractmethod
from typing import Protocol
from dataclasses import dataclass
from datetime import datetime
import json
import os
What changed in the imports?
You added from typing import Protocol. This gives you access to Python's structural subtyping system, which lets you define interfaces without forcing classes to inherit from them.
- Now scroll down to find the EmailNotifier class. Cut it from its current position (below JsonOrderRepository) and paste it directly above JsonOrderRepository.
- Between the end of PricingCalculator and the EmailNotifier class you just moved, add the following two Protocol definitions:
# --- ISP: Small, focused interfaces via Protocol ---
class Notifier(Protocol):
def send(self, recipient: str, message: str) -> None: ...
class OrderRepository(Protocol):
def save(self, record: OrderRecord) -> None: ...
What does this code do?
Notifier defines a contract: anything that wants to be a notifier must have a send(recipient, message) method. Nothing more, nothing less.
OrderRepository defines a contract: anything that wants to be a repository must have a save(record) method. No class is forced to implement methods it doesn't need.
Why Protocol instead of ABC here?
You used ABC for discount strategies because you wanted to enforce implementation at instantiation time. Protocol works differently. It uses structural subtyping, meaning any class with the right method signature satisfies the contract automatically.
This is the idiomatic Python approach for defining small interfaces. Classes don't need to explicitly inherit from anything. If they have the right methods, they qualify.
- Save order_system.py.
Seeing a syntax error?
Make sure the ... (ellipsis) at the end of each Protocol method is exactly three dots, not a comment or pass. The ellipsis is how Python marks a Protocol method body.
help me fix a Protocol syntax error in my order_system.py
Create a substitutable SMSNotifier
The Liskov Substitution Principle says any implementation should be replaceable with another that satisfies the same contract. To prove this, you'll create a second notifier.
- In order_system.py, add the following class directly below EmailNotifier (and above JsonOrderRepository):
class SMSNotifier:
def send(self, recipient: str, message: str) -> None:
print(f"SMS to {recipient}: {message}")
What does this code do?
SMSNotifier has the same send(recipient, message) signature as EmailNotifier. It satisfies the Notifier Protocol without inheriting from it. Python sees the matching method and treats it as a valid substitute.
- Save order_system.py.
You won't see a change yet. The next substep wires this new notifier into the system and proves it works.
Verify substitutability
The final piece is making OrderService declare its dependencies using the Protocol types. Then you'll swap implementations in main.py to prove it works.
- In order_system.py, scroll down to the OrderService class and find this line:
def __init__(self, validator, pricing, repository, notifier):
- Replace that entire __init__ line with the following typed constructor:
def __init__(
self,
validator: OrderValidator,
pricing: PricingCalculator,
repository: OrderRepository,
notifier: Notifier,
):
What does this change do?
The constructor now declares that repository must satisfy the OrderRepository Protocol and notifier must satisfy the Notifier Protocol. Anyone reading this code immediately knows what contract each dependency must fulfill.
- Save order_system.py.
Now switch to main.py to demonstrate the substitution. You'll swap EmailNotifier for SMSNotifier and clean up the custom discount from the previous step.
- Replace the entire contents of main.py with the following:
from order_system import (
Order,
OrderItem,
OrderValidator,
PricingCalculator,
JsonOrderRepository,
SMSNotifier,
OrderService,
)
validator = OrderValidator()
pricing = PricingCalculator(tax_rate=0.1)
repository = JsonOrderRepository("orders.json")
notifier = SMSNotifier()
service = OrderService(
validator=validator,
pricing=pricing,
repository=repository,
notifier=notifier,
)
order = Order(
customer_email="alice@example.com",
items=[
OrderItem(name="Widget", price=29.99, quantity=2),
OrderItem(name="Gadget", price=49.99, quantity=1),
],
discount_type="percentage",
discount_value=10,
)
record = service.process_order(order)
print(f"Order processed! ID: {record.id}, Total: ${record.total:.2f}")
What changed here?
The key change is one line: notifier = SMSNotifier() instead of notifier = EmailNotifier(). The rest of the system works without any modifications because SMSNotifier satisfies the same Notifier Protocol.
You also removed the BuyOneGetOneDiscount class since it served its purpose demonstrating OCP in the previous step.
- Save main.py.
- Run your system by executing this command in the terminal:
python main.py
You should see output like SMS to alice@example.com: Your order total is $... followed by the order processed confirmation. The word "SMS" proves the new notifier is active.
Seeing an ImportError?
Make sure you added the SMSNotifier class to order_system.py and that the class name matches exactly (capital S, M, S).
Also check your import block at the top of main.py includes SMSNotifier and no longer references EmailNotifier or DiscountStrategy.
help me debug an ImportError in my SOLID refactoring project
✔️ Awesome, I've got everything!
Great. Double check you've saved both files before moving on.
ⓧ I'd like to double check the full code
Here's the full order_system.py at this point:
from abc import ABC, abstractmethod
from typing import Protocol
from dataclasses import dataclass
from datetime import datetime
import json
import os
@dataclass
class OrderItem:
name: str
price: float
quantity: int
@dataclass
class Order:
customer_email: str
items: list[OrderItem]
discount_type: str = ""
discount_value: float = 0.0
@dataclass
class OrderRecord:
id: str
customer_email: str
items: list[OrderItem]
subtotal: float
tax: float
total: float
status: str
created_at: str
class OrderValidator:
def validate(self, order: Order) -> None:
if not order.items:
raise ValueError("Order must have items")
if not order.customer_email:
raise ValueError("Customer email required")
for item in order.items:
if item.quantity <= 0:
raise ValueError(f"Invalid quantity for {item.name}")
class DiscountStrategy(ABC):
@abstractmethod
def apply(self, subtotal: float, value: float) -> float:
pass
class PercentageDiscount(DiscountStrategy):
def apply(self, subtotal: float, value: float) -> float:
return subtotal - (subtotal * value / 100)
class FixedDiscount(DiscountStrategy):
def apply(self, subtotal: float, value: float) -> float:
return subtotal - value
class NoDiscount(DiscountStrategy):
def apply(self, subtotal: float, value: float) -> float:
return subtotal
class PricingCalculator:
def __init__(self, tax_rate: float = 0.1):
self.tax_rate = tax_rate
self._strategies: dict[str, DiscountStrategy] = {
"percentage": PercentageDiscount(),
"fixed": FixedDiscount(),
"": NoDiscount(),
}
def register_discount(self, name: str, strategy: DiscountStrategy) -> None:
self._strategies[name] = strategy
def calculate(self, order: Order) -> tuple[float, float, float]:
subtotal = sum(item.price * item.quantity for item in order.items)
strategy = self._strategies.get(order.discount_type, NoDiscount())
subtotal = strategy.apply(subtotal, order.discount_value)
tax = subtotal * self.tax_rate
total = subtotal + tax
return subtotal, tax, total
class Notifier(Protocol):
def send(self, recipient: str, message: str) -> None: ...
class OrderRepository(Protocol):
def save(self, record: OrderRecord) -> None: ...
class EmailNotifier:
def send(self, recipient: str, message: str) -> None:
print(f"Email to {recipient}: {message}")
class SMSNotifier:
def send(self, recipient: str, message: str) -> None:
print(f"SMS to {recipient}: {message}")
class JsonOrderRepository:
def __init__(self, file_path: str = "orders.json"):
self.file_path = file_path
def save(self, record: OrderRecord) -> None:
orders = []
if os.path.exists(self.file_path):
with open(self.file_path, "r") as f:
orders = json.load(f)
orders.append({
"id": record.id,
"customer_email": record.customer_email,
"items": [
{"name": i.name, "price": i.price, "quantity": i.quantity}
for i in record.items
],
"subtotal": record.subtotal,
"tax": record.tax,
"total": record.total,
"status": record.status,
"created_at": record.created_at,
})
with open(self.file_path, "w") as f:
json.dump(orders, f, indent=2)
class OrderService:
def __init__(
self,
validator: OrderValidator,
pricing: PricingCalculator,
repository: OrderRepository,
notifier: Notifier,
):
self.validator = validator
self.pricing = pricing
self.repository = repository
self.notifier = notifier
def process_order(self, order: Order) -> OrderRecord:
self.validator.validate(order)
subtotal, tax, total = self.pricing.calculate(order)
record = OrderRecord(
id=datetime.now().strftime("%Y%m%d%H%M%S"),
customer_email=order.customer_email,
items=order.items,
subtotal=subtotal,
tax=tax,
total=total,
status="completed",
created_at=datetime.now().isoformat(),
)
self.repository.save(record)
self.notifier.send(
order.customer_email,
f"Your order total is ${total:.2f}",
)
return record
And here's the full main.py:
from order_system import (
Order,
OrderItem,
OrderValidator,
PricingCalculator,
JsonOrderRepository,
SMSNotifier,
OrderService,
)
validator = OrderValidator()
pricing = PricingCalculator(tax_rate=0.1)
repository = JsonOrderRepository("orders.json")
notifier = SMSNotifier()
service = OrderService(
validator=validator,
pricing=pricing,
repository=repository,
notifier=notifier,
)
order = Order(
customer_email="alice@example.com",
items=[
OrderItem(name="Widget", price=29.99, quantity=2),
OrderItem(name="Gadget", price=49.99, quantity=1),
],
discount_type="percentage",
discount_value=10,
)
record = service.process_order(order)
print(f"Order processed! ID: {record.id}, Total: ${record.total:.2f}")
Your system now has proper contracts. Any class that matches the Protocol's method signature can be plugged in without touching OrderService. Next up, you'll apply the Dependency Inversion Principle to make sure OrderService never even knows about concrete types.
Apply the Dependency Inversion Principle
Your order system now has small, focused interfaces and substitutable implementations. But there is one final principle to formalize: where do the concrete choices live?
The Dependency Inversion Principle says that high-level modules should not depend on low-level modules. Both should depend on abstractions. In this step, you will formalize that pattern by ensuring OrderService knows nothing about concrete types and creating a single composition root in main.py where all wiring decisions live.
In this step, get ready to:
- Confirm OrderService depends only on abstractions and add architectural comments.
- Create a composition root in main.py that wires all concrete dependencies.
- Run the full system end-to-end to prove any implementation can be swapped with a one-line change.
Refactor OrderService to depend on abstractions
Look at your OrderService class in order_system.py. Its constructor already accepts repository: OrderRepository and notifier: Notifier. Those are Protocol types, not concrete classes. OrderService never imports or references JsonOrderRepository or EmailNotifier directly.
The DIP structure is already in place from your Protocol work. Now you will add section comments throughout the file to make the architecture self-documenting for anyone reading the code.
- Replace the entire contents of order_system.py with this organized version:
from abc import ABC, abstractmethod
from typing import Protocol
from dataclasses import dataclass
from datetime import datetime
import json
import os
# --- Models ---
@dataclass
class OrderItem:
name: str
price: float
quantity: int
@dataclass
class Order:
customer_email: str
items: list[OrderItem]
discount_type: str = ""
discount_value: float = 0.0
@dataclass
class OrderRecord:
id: str
customer_email: str
items: list[OrderItem]
subtotal: float
tax: float
total: float
status: str
created_at: str
# --- SRP: Each class has one responsibility ---
class OrderValidator:
def validate(self, order: Order) -> None:
if not order.items:
raise ValueError("Order must have items")
if not order.customer_email:
raise ValueError("Customer email required")
for item in order.items:
if item.quantity <= 0:
raise ValueError(f"Invalid quantity for {item.name}")
# --- OCP: Open for extension via abstract strategy ---
class DiscountStrategy(ABC):
@abstractmethod
def apply(self, subtotal: float, value: float) -> float:
pass
class PercentageDiscount(DiscountStrategy):
def apply(self, subtotal: float, value: float) -> float:
return subtotal - (subtotal * value / 100)
class FixedDiscount(DiscountStrategy):
def apply(self, subtotal: float, value: float) -> float:
return subtotal - value
class NoDiscount(DiscountStrategy):
def apply(self, subtotal: float, value: float) -> float:
return subtotal
class PricingCalculator:
def __init__(self, tax_rate: float = 0.1):
self.tax_rate = tax_rate
self._strategies: dict[str, DiscountStrategy] = {
"percentage": PercentageDiscount(),
"fixed": FixedDiscount(),
"": NoDiscount(),
}
def register_discount(self, name: str, strategy: DiscountStrategy) -> None:
self._strategies[name] = strategy
def calculate(self, order: Order) -> tuple[float, float, float]:
subtotal = sum(item.price * item.quantity for item in order.items)
strategy = self._strategies.get(order.discount_type, NoDiscount())
subtotal = strategy.apply(subtotal, order.discount_value)
tax = subtotal * self.tax_rate
total = subtotal + tax
return subtotal, tax, total
# --- ISP: Small, focused interfaces via Protocol ---
class Notifier(Protocol):
def send(self, recipient: str, message: str) -> None: ...
class OrderRepository(Protocol):
def save(self, record: OrderRecord) -> None: ...
# --- LSP: All implementations honor the contract ---
class EmailNotifier:
def send(self, recipient: str, message: str) -> None:
print(f"Email to {recipient}: {message}")
class SMSNotifier:
def send(self, recipient: str, message: str) -> None:
print(f"SMS to {recipient}: {message}")
class JsonOrderRepository:
def __init__(self, file_path: str = "orders.json"):
self.file_path = file_path
def save(self, record: OrderRecord) -> None:
orders = []
if os.path.exists(self.file_path):
with open(self.file_path, "r") as f:
orders = json.load(f)
orders.append({
"id": record.id,
"customer_email": record.customer_email,
"items": [
{"name": i.name, "price": i.price, "quantity": i.quantity}
for i in record.items
],
"subtotal": record.subtotal,
"tax": record.tax,
"total": record.total,
"status": record.status,
"created_at": record.created_at,
})
with open(self.file_path, "w") as f:
json.dump(orders, f, indent=2)
# --- DIP: High-level module depends on abstractions ---
class OrderService:
def __init__(
self,
validator: OrderValidator,
pricing: PricingCalculator,
repository: OrderRepository,
notifier: Notifier,
):
self.validator = validator
self.pricing = pricing
self.repository = repository
self.notifier = notifier
def process_order(self, order: Order) -> OrderRecord:
self.validator.validate(order)
subtotal, tax, total = self.pricing.calculate(order)
record = OrderRecord(
id=datetime.now().strftime("%Y%m%d%H%M%S"),
customer_email=order.customer_email,
items=order.items,
subtotal=subtotal,
tax=tax,
total=total,
status="completed",
created_at=datetime.now().isoformat(),
)
self.repository.save(record)
self.notifier.send(
order.customer_email,
f"Your order total is ${total:.2f}",
)
return record
What do the section comments do?
Each comment labels the SOLID principle that governs the classes below it. Anyone reading this file can immediately see the architectural intent.
The key line is # --- DIP: High-level module depends on abstractions --- above OrderService. OrderService's constructor accepts repository: OrderRepository and notifier: Notifier. It never mentions JsonOrderRepository or EmailNotifier anywhere in its body.
- Save order_system.py.
Create a composition root in main.py
A composition root is a single location in your application where all concrete types are chosen and wired together. Everything outside this root depends only on abstractions.
- Replace the entire contents of main.py with this composition root:
from order_system import (
Order,
OrderItem,
OrderValidator,
PricingCalculator,
JsonOrderRepository,
EmailNotifier,
OrderService,
)
def main():
# Composition root: wire up all dependencies
validator = OrderValidator()
pricing = PricingCalculator(tax_rate=0.1)
repository = JsonOrderRepository("orders.json")
notifier = EmailNotifier()
service = OrderService(
validator=validator,
pricing=pricing,
repository=repository,
notifier=notifier,
)
# Create and process an order
order = Order(
customer_email="alice@example.com",
items=[
OrderItem(name="Widget", price=29.99, quantity=2),
OrderItem(name="Gadget", price=49.99, quantity=1),
],
discount_type="percentage",
discount_value=10,
)
record = service.process_order(order)
print(f"\nOrder processed! ID: {record.id}, Total: ${record.total:.2f}")
if __name__ == "__main__":
main()
What does this code do?
The import block at the top is the only place in the entire project that references concrete classes like JsonOrderRepository and EmailNotifier. This is the composition root.
The def main(): function wraps everything so the script only runs when executed directly. The if __name__ == "__main__": guard at the bottom is standard Python practice for runnable scripts.
Why is this structure powerful?
If you want to swap your JSON file storage for a PostgreSQL database, you change one line in main.py. If you want to switch from email to SMS, you change one line. OrderService itself never changes because it only knows about the Protocols.
- Save main.py.
Demonstrate the full SOLID system
Time to prove the entire refactored system works end-to-end. The order will flow through validation, pricing with a percentage discount, JSON persistence, and email notification.
- If you have an orders.json file left over from earlier runs, delete it so you start fresh.
- Run the system by executing this command in your terminal:
python main.py
- You should see output like this:
Email to alice@example.com: Your order total is $108.89
Order processed! ID: 20260621143022, Total: $108.89
What should I see?
The exact ID will differ (it is a timestamp), but the total should be $108.89. That is 2 Widgets ($59.98) + 1 Gadget ($49.99) = $109.97 subtotal, minus 10% discount = $98.97, plus 10% tax = $108.87. The exact cents may vary due to floating-point arithmetic.
You should also see a new orders.json file created in your project folder containing the order record.
Seeing a ModuleNotFoundError?
Make sure your terminal is inside the solid-refactor folder where both main.py and order_system.py live. Python can only import from the current directory.
Also check that your import list in main.py includes EmailNotifier (not SMSNotifier).
Help me debug a ModuleNotFoundError in my SOLID refactoring project
Notice that order_system.py has zero hardcoded dependencies. All concrete choices live in main.py. Changing any implementation (a different database, a different notifier, a different discount strategy) touches only the composition root.
✔️ Awesome, I've got everything!
Great. Double check you have saved both files.
ⓧ I'd like to double check the full code
Here is the complete order_system.py:
from abc import ABC, abstractmethod
from typing import Protocol
from dataclasses import dataclass
from datetime import datetime
import json
import os
# --- Models ---
@dataclass
class OrderItem:
name: str
price: float
quantity: int
@dataclass
class Order:
customer_email: str
items: list[OrderItem]
discount_type: str = ""
discount_value: float = 0.0
@dataclass
class OrderRecord:
id: str
customer_email: str
items: list[OrderItem]
subtotal: float
tax: float
total: float
status: str
created_at: str
# --- SRP: Each class has one responsibility ---
class OrderValidator:
def validate(self, order: Order) -> None:
if not order.items:
raise ValueError("Order must have items")
if not order.customer_email:
raise ValueError("Customer email required")
for item in order.items:
if item.quantity <= 0:
raise ValueError(f"Invalid quantity for {item.name}")
# --- OCP: Open for extension via abstract strategy ---
class DiscountStrategy(ABC):
@abstractmethod
def apply(self, subtotal: float, value: float) -> float:
pass
class PercentageDiscount(DiscountStrategy):
def apply(self, subtotal: float, value: float) -> float:
return subtotal - (subtotal * value / 100)
class FixedDiscount(DiscountStrategy):
def apply(self, subtotal: float, value: float) -> float:
return subtotal - value
class NoDiscount(DiscountStrategy):
def apply(self, subtotal: float, value: float) -> float:
return subtotal
class PricingCalculator:
def __init__(self, tax_rate: float = 0.1):
self.tax_rate = tax_rate
self._strategies: dict[str, DiscountStrategy] = {
"percentage": PercentageDiscount(),
"fixed": FixedDiscount(),
"": NoDiscount(),
}
def register_discount(self, name: str, strategy: DiscountStrategy) -> None:
self._strategies[name] = strategy
def calculate(self, order: Order) -> tuple[float, float, float]:
subtotal = sum(item.price * item.quantity for item in order.items)
strategy = self._strategies.get(order.discount_type, NoDiscount())
subtotal = strategy.apply(subtotal, order.discount_value)
tax = subtotal * self.tax_rate
total = subtotal + tax
return subtotal, tax, total
# --- ISP: Small, focused interfaces via Protocol ---
class Notifier(Protocol):
def send(self, recipient: str, message: str) -> None: ...
class OrderRepository(Protocol):
def save(self, record: OrderRecord) -> None: ...
# --- LSP: All implementations honor the contract ---
class EmailNotifier:
def send(self, recipient: str, message: str) -> None:
print(f"Email to {recipient}: {message}")
class SMSNotifier:
def send(self, recipient: str, message: str) -> None:
print(f"SMS to {recipient}: {message}")
class JsonOrderRepository:
def __init__(self, file_path: str = "orders.json"):
self.file_path = file_path
def save(self, record: OrderRecord) -> None:
orders = []
if os.path.exists(self.file_path):
with open(self.file_path, "r") as f:
orders = json.load(f)
orders.append({
"id": record.id,
"customer_email": record.customer_email,
"items": [
{"name": i.name, "price": i.price, "quantity": i.quantity}
for i in record.items
],
"subtotal": record.subtotal,
"tax": record.tax,
"total": record.total,
"status": record.status,
"created_at": record.created_at,
})
with open(self.file_path, "w") as f:
json.dump(orders, f, indent=2)
# --- DIP: High-level module depends on abstractions ---
class OrderService:
def __init__(
self,
validator: OrderValidator,
pricing: PricingCalculator,
repository: OrderRepository,
notifier: Notifier,
):
self.validator = validator
self.pricing = pricing
self.repository = repository
self.notifier = notifier
def process_order(self, order: Order) -> OrderRecord:
self.validator.validate(order)
subtotal, tax, total = self.pricing.calculate(order)
record = OrderRecord(
id=datetime.now().strftime("%Y%m%d%H%M%S"),
customer_email=order.customer_email,
items=order.items,
subtotal=subtotal,
tax=tax,
total=total,
status="completed",
created_at=datetime.now().isoformat(),
)
self.repository.save(record)
self.notifier.send(
order.customer_email,
f"Your order total is ${total:.2f}",
)
return record
And here is the complete main.py:
from order_system import (
Order,
OrderItem,
OrderValidator,
PricingCalculator,
JsonOrderRepository,
EmailNotifier,
OrderService,
)
def main():
# Composition root: wire up all dependencies
validator = OrderValidator()
pricing = PricingCalculator(tax_rate=0.1)
repository = JsonOrderRepository("orders.json")
notifier = EmailNotifier()
service = OrderService(
validator=validator,
pricing=pricing,
repository=repository,
notifier=notifier,
)
# Create and process an order
order = Order(
customer_email="alice@example.com",
items=[
OrderItem(name="Widget", price=29.99, quantity=2),
OrderItem(name="Gadget", price=49.99, quantity=1),
],
discount_type="percentage",
discount_value=10,
)
record = service.process_order(order)
print(f"\nOrder processed! ID: {record.id}, Total: ${record.total:.2f}")
if __name__ == "__main__":
main()
Your order system is now fully SOLID. Each class has one responsibility. New discount types slot in without modifying existing code. Implementations can be swapped freely because everything depends on Protocols. And the composition root in main.py is the only place that knows which concrete classes exist. Next up, you will prove this architecture pays off by extending the system without touching a single existing class.
Secret mission
Extend Without Modifying
You've built a SOLID architecture. Now prove it works. Add two brand-new features to your order system without changing a single line in any existing class.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally using the Python standard library, so there are no ongoing costs. Decide whether to keep your project folder as a reference or delete it entirely.
Resources you used:
- solid-refactor folder on your Desktop (contains order_system.py and main.py).
- orders.json file generated inside the project folder when you ran the program.
Keep everything running
No action needed. Choose this if you plan to revisit this project as a reference for applying SOLID principles to your own codebase.
- Your solid-refactor folder stays on your Desktop.
- The orders.json file will grow each time you run python main.py, but it stays small.
Pause - I'll come back to this later
There are no running processes to stop. Everything is already paused by default since the project only runs when you execute the script manually.
- Your files remain on disk until you decide to return or delete them.
Delete - I don't want to use this again
Remove all project files from your machine.
- Delete the entire project folder by running this command in your terminal:
macOS
rm -rf ~/Desktop/solid-refactor
Windows
rmdir /s /q %USERPROFILE%\Desktop\solid-refactor
- Confirm the folder is gone by running:
ls ~/Desktop | grep solid-refactor
You should see no output, confirming the folder has been removed.
Nice Work!
Nice Work!
You just took a messy, monolithic order processor and transformed it into a clean, modular system built on all 5 SOLID principles. Every class has one job, new features plug in without touching existing code, and the entire architecture is wired through a single composition root.
You've learned how to:
- Refactored a monolithic class into focused components using the Single Responsibility Principle, giving each class exactly one reason to change.
- Built a pluggable discount system using Abstract Base Classes and a strategy registry that accepts new strategies without modifying existing code.
- Created a dependency-injected architecture with Protocol interfaces where swapping any implementation requires changing only the wiring in main.py.
- Secret Mission: Added a TieredDiscount and CompositeNotifier without touching any existing class.
Ready to quiz yourself?