The potion shop had been doing well.
It had a small but loyal customer base, a reliable supply of moonflower, and
three owls that carried order confirmations and other notifications to customers across the kingdom.
The shop’s ordering system was simple:
We’ll make significant changes to this code in this article, but bear with me for now.
A customer orders an invisibility potion:
The output looks good:
Invisibility £12.00
Owl dispatched to Merlin
12.00I’m using Decimal for the prices here as it’s a better data type for money than float. We discussed this a bit here, too: The Weird and Wonderful World of Descriptors in Python • The Price is Right.
The shop has sold a potion, updated its stock, recorded the sale, and notified the customer.
What could possibly go wrong?
A new label for the Moon Festival
The Moon Festival is approaching, and the shop owner wants special labels:
🌙 Moon Festival Invisibility 🌙 £12.00We could change .sell():
You can read more about the ‘rogue asterisk’ in the signature, a.k.a keyword-only arguments, in this post: A Story About Parameters and Arguments in Python Functions • “AI Coffee” Grand Opening This Monday.
Now this works:
Here’s the output from this code:
🌙 Moon Festival Invisibility 🌙 £12.00
Owl dispatched to Merlin
12.00The change is small. Perhaps this class is still perfectly fine.
The shop owner then asks for a test that checks the label without selling a potion. This is where you start to see the design problem.
At the moment, the only place that creates a label is inside .sell(). There is no suitable way to check the label policy independently. The code does not offer a way to ask for a label without also selling a potion.
The design problem is that PotionShop currently is in charge of label creation, even though the label policy can change independently of the policy that deals with sales. How about moving label creation to its own class?
Now you can check that the label is correct without having to sell an item in the first place:
This code would raise an AssertionError if the string that labels.make() returns is not identical to the required output. In this case, there are no errors.
In a real project, you could use a testing platform to run these checks. But I’ll use plain assert statements throughout to keep things simpler in this tutorial.
The shop delegates label-making to this object:
You should also remove the three lines that create a label you had further down within .sell().
We’re using composition here. The Potion Shop has a label maker, and you pass a LabelMaker object to the .label_maker attribute in the PotionShop object. The PotionShop object can now delegate label making to an object dedicated to this task.
From this point onwards, the shop receives the label-making collaborator:
PotionShop still coordinates the sale, but the label policy can now change without changing PotionShop. We started separating responsibilities. Making labels is no longer the responsibility of the PotionShop object. But we still have a bit more to go.
You got a taste of what the Single Responsibility Principle does in the example so far. But the code still has other transgressions of this principle. Let’s work through them in the rest of this post.
Coming soon: Let’s build a project from beginning to end in collaboration with AI agents. This will NOT be vibe coding. Instead, it’s a collaboration between knowledgeable humans and powerful AI.
This is the direction real programming is taking.
And we’ll explore SOLID principles in this project, which I’ll share as text and videos. Make sure you’re a premium subscriber so you don’t miss out.
The owls go on strike
A few days later, the owls refuse to fly during a thunderstorm. The potions can still be prepared, but the notification service is unavailable.
The shop needs to stop sending notifications temporarily.
Before changing the notification system, we first make the owl’s availability explicit:
But you also want to add a test to check the price is correct.
Your code extracts the price from the .recipes class attribute as part of the .sell() method. So, this is how you can test the price is what you’re expecting:
You get the following output and no AssertionError in this case:
Invisibility £12.00
Owl dispatched to MerlinHowever, you should also test this when the owls are unavailable. Of course, this shouldn’t make any difference to the price:
But, your code doesn’t get that far. The .sell() method calculates the price and prints the label. But the owl service raises OwlUnavailable and the code stops before you can verify the price is right:
Invisibility £12.00
Traceback (most recent call last):
...
OwlUnavailable: The owls are currently unavailableThe price is not the problem. The test cannot inspect the price without also triggering an owl notification.
The shop’s different jobs are now getting in each other’s way. But before we fix this problem, let’s make the mess bigger.
A discount for cauldron owners
The owner has another idea. Customers who bring their own cauldron should receive a 10% discount.
We add another parameter:
You use .quantize(), which is a method in the Decimal class, to ensure the discounted value is rounded to two decimal places. Let’s try this with a shop whose notification service is available:
The output shows the correct discounted price:
Invisibility £10.80
Owl dispatched to Merlin
10.80Let’s see what happens if the owls are unavailable:
Your code raises an error since the owls aren’t flying:
Invisibility £10.80
Traceback (most recent call last):
...
OwlUnavailable: The owls are currently unavailableBut there’s an even bigger problem now. Can you spot it?
Even though the code stopped execution and raised an error, it had already changed the stock and recorded a sale. Let’s use a try..except block to see what’s happening:
Here’s the output:
Invisibility £10.80
Catching the ‘OwlUnavailable’ Exception
{’moonflower’: 8, ‘dragon_scale’: 0}
[(’Merlin’, ‘invisibility’, Decimal(’10.80’))]You started off with 10 units of moonflower and 2 units of dragon scales in the potion shop. These values are currently hardcoded in the class’s .__init__() special method (which is in itself not a good idea, but more on this later.)
The recipe uses two units of each, as shown in the .recipes class attribute (which will also change soon.)
So you end up using up the stock even though .sell() raised an exception! You can see from the output that the sale was also registered.
One failure too many
The .sell() method is now responsible for:
Looking up recipes
Removing ingredients
Calculating prices
Applying discounts
Coordinating label creation*
Recording sales
Sending notifications
* Creating labels is the one we have already improved. It’s the others that are still a problem!
And the owner has just mentioned loyalty points, premium labels, email notifications, and a special discount for customers who arrive by broomstick.
The .sell() method is becoming the place where every new request arrives.
And it’s not just PotionShop.sell() that’s trying to do too much. The PotionShop class also takes care of the recipes available in the .recipes class attribute and the stock available in the shop.
It is time to separate these responsibilities.
Separating the recipe book
Let’s start with the recipes. The shop should not need to know the details of every recipe. That is the recipe book’s job:
This class is responsible for the recipe book. And nothing else. You add the .find() method to make it easier for a user to use the class without knowing the details of how it’s built. It’s usually best for a class to manage its own data. So, rather than requiring the user to know that the recipes are stored in a dictionary, you provide the .find() method that does the work.
Suppose the potion maker changes a recipe or adds a new potion. Those changes belong in RecipeBook; the steps for selling a potion don’t need to change.
We can test this class without creating a shop, changing stock, printing labels, or sending owls:
This code doesn’t raise an exception since the price is correct.
Separating inventory
Inventory management also doesn’t belong to the PotionShop class:
The two loops in .remove_stock() are deliberate. If we reduced each quantity as we went, we could use some ingredients and then discover that there isn’t enough of another. The sale would fail, but the stock would already be partly changed. Checking every quantity first avoids that partial update.
We can see what happens when there isn’t enough stock:
The operation fails before changing any stock:
Not enough dragon_scale
{’moonflower’: 10, ‘dragon_scale’: 2}After a successful sale, a delivery can add stock again:
Here’s the output from this code:
{’moonflower’: 13, ‘dragon_scale’: 0}Inventory owns both the stock data and the operations that change its quantities. It does not need to know who bought the potion or whether the label is festive.
Separating prices and labels
Pricing has its own rules:
Once again, this class now has just one responsibility: to deal with the pricing.
You can check the pricing strategy works without having to sell a product and everything else.
10.80Labels have their own rules too. You dealt with this earlier in the tutorial when you created LabelMaker
PriceCalculator and LabelMaker could both be functions here. We’re using classes because they act as collaborators that the shop receives from outside. Later, we’ll see how protocols or abstract base classes can describe interchangeable collaborators more explicitly. For now, the important point is the separation of responsibilities and not the fact that each responsibility has become a class.
Neither class needs an inventory, a sales ledger, or an owl.
Putting the shop back together
We still need something to coordinate the sale. First, we give the sales ledger and the notifier small, focused interfaces:
Notice the "pending" state in the ledger. Separating responsibilities does not make the sale process independent of anything else. Inventory has already changed before later steps run. Here, the narrower goal is to make notification failure visible so that the recorded sale can be investigated.
Now we can keep PotionShop as a thin coordinator. It still represents the sale process, but it delegates each part of that process to the object that owns the relevant behaviour:
We can now assemble the shop and sell a potion:
Here’s the output:
🌙 Moon Festival Invisibility 🌙 £10.80
Owl dispatched to Merlin
10.80
{’customer’: ‘Merlin’, ‘potion’: ‘invisibility’, ‘price’: Decimal(’10.80’), ‘notification’: ‘sent’}If notification fails after the ledger records the sale, the sale remains
visible with a pending notification:
Here’s the output:
Invisibility £12.00
{’customer’: ‘Merlin’, ‘potion’: ‘invisibility’,
‘price’: Decimal(’12.00’), ‘notification’: ‘pending’}
The coordinator still changes if the business process changes. That’s fine: coordinating a sale is its responsibility. But recipe changes belong in RecipeBook, inventory changes belong in Inventory, pricing changes belong in PriceCalculator, label changes belong in LabelMaker, and notification changes belong in Notifier.
The Single Responsibility Principle
This is the idea behind the Single Responsibility Principle:
A class should have one reason to change.
I always find these “official” definitions a bit cryptic when I read them at first. Here, “reason” is about the policy or stakeholder driving the change. The pricing rules may change because the shop owner changes the discounts. The label design may change because the person responsible for packaging wants something different. The notification service may change because the owls are on strike.
That does not mean that every class should have one method. Nor does it mean that every noun in the story needs its own class.
PriceCalculator can have several related methods. Inventory can have several operations. The important question is whether the methods belong to the same coherent responsibility.
The thin PotionShop coordinator is also a class with a responsibility. It coordinates the sale. A change to that business process may quite reasonably affect it.
When unrelated changes keep arriving at the same class, the class becomes harder to test and more likely to break. Extract a collaborator when it may need to change for a different reason from the surrounding code, or when separating it makes an important test simpler. A good class name alone isn’t a reason to create one.
The potion shop did not become troublesome because it had a particular number of lines. It became troublesome because recipes, inventory, pricing, labels, sales, and notifications all had different reasons to change.
A useful question to ask is:
What different kinds of change could affect this class?
If the answers are “the pricing rules”, “the label design”, “the storage system”, and “the notification service”, you may have several responsibilities sharing one home.
And if the project keeps growing, we may need to rethink the floor plan.
This is the first article in a series on the SOLID principles. I’ll look at what each principle means, but more importantly, why it matters and what can go wrong when we ignore it.
Code in this article uses Python 3.14.
Do you want to keep up with how Python programming and the art of making things are changing in this rapidly changing world? Then don’t miss out on the exclusive content for premium members here on The Python Coding Stack.
I’ll be sharing more about how my programming, my daily work, and everything has changed over the recent months.
For more Python resources, you can also visit Real Python—you may even stumble on one of my own articles or courses there!
Also, are you interested in technical writing? You’d like to make your own writing more narrative, more engaging, more memorable? Have a look at Breaking the Rules.
And you can find out more about me at stephengruppetta.com
Appendix: Code Blocks
Code Block #1
from decimal import Decimal
class PotionShop:
recipes = {
“invisibility”: {
“ingredients”: {
“moonflower”: 2,
“dragon_scale”: 2,
},
“price”: Decimal(”12.00”),
}
}
def __init__(self):
self.stock = {
“moonflower”: 10,
“dragon_scale”: 2,
}
self.sales = []
def sell(self, potion_name, customer):
potion = self.recipes[potion_name]
for ingredient, quantity in potion[”ingredients”].items():
if self.stock[ingredient] < quantity:
raise ValueError(f”Not enough {ingredient}”)
for ingredient, quantity in potion[”ingredients”].items():
self.stock[ingredient] -= quantity
price = potion[”price”]
self.sales.append((customer, potion_name, price))
print(f”{potion_name.title()}\t£{price:.2f}”)
self.send_owl(
customer,
{”potion_name”: potion_name, “price”: price},
)
return price
def send_owl(self, customer, message_content):
print(f”Owl dispatched to {customer}”)
return message_contentCode Block #2
shop = PotionShop()
print(shop.sell(”invisibility”, “Merlin”))Code Block #3
# ...
class PotionShop:
# ...
def sell(
self,
potion_name,
customer,
*,
moon_festival=False,
):
# ...
label = potion_name.title()
if moon_festival:
label = f”🌙 Moon Festival {label} 🌙”
print(f”{label}\t£{price:.2f}”)
# ...
# ...Code Block #4
shop = PotionShop()
print(shop.sell(”invisibility”, “Merlin”, moon_festival=True))Code Block #5
# ...
class LabelMaker:
def make(self, potion_name, moon_festival=False):
label = potion_name.title()
if moon_festival:
label = f”🌙 Moon Festival {label} 🌙”
return label
# ...Code Block #6
labels = LabelMaker()
assert labels.make(
“invisibility”,
moon_festival=True,
) == “🌙 Moon Festival Invisibility 🌙”Code Block #7
# ...
class PotionShop:
# ...
def __init__(self, label_maker):
self.label_maker = label_maker
# ...
def sell(
self,
potion_name,
customer,
*,
moon_festival=False,
):
# ...
label = self.label_maker.make(
potion_name,
moon_festival=moon_festival,
)
# ...
# ...Code Block #8
shop = PotionShop(LabelMaker())Code Block #9
# ...
class OwlUnavailable(Exception):
pass
# ...
class PotionShop:
# ...
def __init__(self, label_maker, owl_available=True):
self.label_maker = label_maker
self.owl_available = owl_available
# ...
# ...
def send_owl(self, customer, message_content):
if not self.owl_available:
raise OwlUnavailable(
“The owls are currently unavailable”
)
print(f”Owl dispatched to {customer}”)
return message_contentCode Block #10
shop = PotionShop(LabelMaker())
assert shop.sell(”invisibility”, “Merlin”) == Decimal(”12.00”)Code Block #11
shop = PotionShop(LabelMaker(), owl_available=False)
assert shop.sell(”invisibility”, “Merlin”) == Decimal(”12.00”)Code Block #12
class PotionShop:
# ...
def sell(
self,
potion_name,
customer,
*,
moon_festival=False,
own_cauldron=False,
):
# ...
price = potion[”price”]
if own_cauldron:
price = (price * Decimal(”0.90”)).quantize(Decimal(”0.01”))
# ...
return price
# ...Code Block #13
shop = PotionShop(LabelMaker())
print(
shop.sell(
“invisibility”,
“Merlin”,
own_cauldron=True,
)
)Code Block #14
shop = PotionShop(LabelMaker(), owl_available=False)
print(
shop.sell(
“invisibility”,
“Merlin”,
own_cauldron=True,
)
)Code Block #15
shop = PotionShop(LabelMaker(), owl_available=False)
try:
print(
shop.sell(
“invisibility”,
“Merlin”,
own_cauldron=True,
)
)
except OwlUnavailable:
print(”Catching the ‘OwlUnavailable’ Exception”)
print(shop.stock)
print(shop.sales)Code Block #16
class RecipeBook:
def __init__(self):
self.recipes = {
“invisibility”: {
“ingredients”: {
“moonflower”: 2,
“dragon_scale”: 2,
},
“price”: Decimal(”12.00”),
}
}
def find(self, potion_name):
return self.recipes[potion_name]Code Block #17
recipe_book = RecipeBook()
assert recipe_book.find(”invisibility”)[”price”] == Decimal(”12.00”)Code Block #18
class Inventory:
def __init__(self, stock):
self.stock = stock
def remove_stock(self, ingredients):
for ingredient, quantity in ingredients.items():
if self.stock[ingredient] < quantity:
raise ValueError(f”Not enough {ingredient}”)
for ingredient, quantity in ingredients.items():
self.stock[ingredient] -= quantity
def add_stock(self, ingredients):
for ingredient, quantity in ingredients.items():
self.stock[ingredient] = self.stock.get(ingredient, 0) + quantityCode Block #19
inventory = Inventory({
“moonflower”: 10,
“dragon_scale”: 2,
})
try:
inventory.remove_stock({
“moonflower”: 2,
“dragon_scale”: 3,
})
except ValueError as error:
print(error)
print(inventory.stock)Code Block #20
inventory.remove_stock({
“moonflower”: 2,
“dragon_scale”: 2,
})
inventory.add_stock({”moonflower”: 5})
print(inventory.stock)Code Block #21
class PriceCalculator:
def calculate(self, recipe, own_cauldron=False):
price = recipe[”price”]
if own_cauldron:
price = (
price * Decimal(”0.90”)
).quantize(Decimal(”0.01”))
return priceCode Block #22
pricing = PriceCalculator()
print(
pricing.calculate(
{”price”: Decimal(”12.00”)},
own_cauldron=True,
)
)Code Block #23
class SalesLedger:
def __init__(self):
self.sales = []
def record(self, customer, potion_name, price):
sale = {
“customer”: customer,
“potion”: potion_name,
“price”: price,
“notification”: “pending”,
}
self.sales.append(sale)
return sale
def mark_notified(self, sale):
sale[”notification”] = “sent”
class Notifier:
def send(self, customer, potion_name, price):
print(f”Owl dispatched to {customer}”)Code Block #24
class PotionShop:
def __init__(
self,
recipe_book,
inventory,
pricing,
labels,
ledger,
notifier,
):
self.recipe_book = recipe_book
self.inventory = inventory
self.pricing = pricing
self.labels = labels
self.ledger = ledger
self.notifier = notifier
def sell(self, potion_name, customer, *, moon_festival=False,
own_cauldron=False):
recipe = self.recipe_book.find(potion_name)
self.inventory.remove_stock(recipe[”ingredients”])
price = self.pricing.calculate(
recipe,
own_cauldron=own_cauldron,
)
label = self.labels.make(
potion_name,
moon_festival=moon_festival,
)
sale = self.ledger.record(customer, potion_name, price)
print(f”{label}\t£{price:.2f}”)
self.notifier.send(customer, potion_name, price)
self.ledger.mark_notified(sale)
return priceCode Block #25
ledger = SalesLedger()
shop = PotionShop(
RecipeBook(),
Inventory({”moonflower”: 10, “dragon_scale”: 2}),
PriceCalculator(),
LabelMaker(),
ledger,
Notifier(),
)
print(
shop.sell(
“invisibility”,
“Merlin”,
moon_festival=True,
own_cauldron=True,
)
)
assert ledger.sales[0][”notification”] == “sent”
print(ledger.sales[0])Code Block #26
class FailingNotifier:
def send(self, customer, potion_name, price):
raise OwlUnavailable(”The owls are currently unavailable”)
ledger = SalesLedger()
shop = PotionShop(
RecipeBook(),
Inventory({”moonflower”: 10, “dragon_scale”: 2}),
PriceCalculator(),
LabelMaker(),
ledger,
FailingNotifier(),
)
try:
shop.sell(”invisibility”, “Merlin”)
except OwlUnavailable:
pass
print(ledger.sales[0])
assert ledger.sales[0][”notification”] == “pending”




























