The Python Coding Stack: A New Price Without Breaking the Old One • The O in SOLID

Wait 5 sec.

The potion shop has a special price for apprentices. They receive ten per cent off a potion’s full price, including any rare-ingredient surcharge.We’ve visited this shop before. In The Potion Shop That Does Too Much • The S in SOLID, its PotionShop class handled several jobs, from checking stock and recording sales to sending messages by owl. But you don’t need the code in that article to follow along in this one.This time, we’ll stay at the same shop and focus on one part of its work: pricing. Before adding anything new, let’s look at the apprentice rule the shop already has.For a potion with a base price of £100 and a £20 surcharge, the calculation is:£100 + £20 = £12010% off £120 = £108Let’s look at how this is implemented.Start with the Apprentice PriceThe shop’s PotionPricing class knows how to calculate the apprentice price. Its .price() method accepts a customer type because that is part of each quote. But apprentices are the only special-price customers the shop supports so far.You can follow along by putting this code in pricing.py:All code blocks are available in text format at the end of this article • #1You’ll get this output when you run the code:108.00The apprentice discount applies after adding the surcharge. That’s the rule for apprentice discounts.A Few Months Later…A few months pass. A different programmer is asked to add a price for members of the local guild. Guild members should receive five per cent off the base price, while the surcharge stays fixed. For the same potion, their price should be £115.The new programmer sees that both rules involve a percentage discount and decides to avoid repeating logic by using one shared calculation.Add the Guild Price to the Same ClassHe adds the guild rule to PotionPricing and generalises the calculation:#2Run pricing.py again:110.00115.00The guild price is right, but what happened to the apprentice price? It was £108. Now it is £110.Apprentices are getting a worse deal now, and it’s the fault of the new programmer. But should the original programmer share some of the blame, too? Yes. Let’s see why.The new calculation discounts only the £100 base price, then adds the £20 surcharge. That is the guild’s rule, not the apprentice rule.The new programmer did not have to make this mistake. A careful change could have preserved the £108 price.But adding the guild pricing meant making changes to code that already produced the correct calculation for the apprentice price. Because both rules were handled in one place, the change had an opportunity to disturb behaviour the shop had been getting right for months.We could correct the if branches and get both prices right. But each new customer rule would still send us back into PotionPricing, where a future change could affect behaviour that already works.Modifying existing code always carries risks. We should avoid this whenever possible. But we don’t want to prevent extensions to the code. This is the key to what we’re trying to achieve here. And that’s where the Open-Closed Principle, or OCP, comes in. This concept gives us a safer way to extend code without modifying what already works, as you’ll see in the next section.Subscribe nowLet Each Pricing Rule Handle Its Own PriceInstead of asking one class to choose a rule, we can represent each rule separately. First, make an ApprenticePricing class and a quote() function that delegates to whichever pricing object it receives:#3Run pricing.py:108.00The apprentice price is back to £108. The quote() function does not need to know how the policy calculates its price. It only needs an object with a .price() method.Now add a class for the guild rule. Leave ApprenticePricing and quote() as they are, and add GuildPricing:#4Run the file once more:108.00115.00The first price is still £108, and the guild price is £115. Adding the guild rule did not require us to change the apprentice calculation or the quote() function. We added a new policy that works with the existing quote() interface.This is the safety gain here. Adding guild pricing does not send the programmer back to edit the code that produces the apprentice price. The new policy could still contain a mistake, but it is much harder for that mistake to change the established apprentice calculation by accident.Where OCP Appears in This ExampleThe Open–Closed Principle says that software should be open to extension but closed to modification. Here, quote() is open to different pricing policies: it can use any object with the right .price() method. When we add guild pricing, we extend the program with GuildPricing instead of modifying ApprenticePricing or teaching quote() about a new customer type.This is one way to apply OCP. The policy objects use a strategy-style design with Python’s duck typing. They do not need a shared parent class.However, it’s possible to use abstract base classes or protocols to implement code using the OCP, too. Read And Now You Know Your ABC for an example of abstract base classes. For protocols, see the Python documentation for `typing.Protocol`.Classes are not required by OCP, and separating responsibilities alone does not make a design open for extension. The important part is the boundary that lets us add a rule without changing the stable code that already handles another rule.That boundary does not make bugs impossible. GuildPricing could still calculate the wrong amount, or a caller could choose the wrong policy. OCP narrows which existing code needs to change when behaviour is added. It does not guarantee every new change is correct.This extra structure is useful when pricing rules really do vary and new ones are likely. If the shop is unlikely to add more tiers, the separate classes may cost more to understand than they save. The useful question is not “How can we prevent all modification?” It is “Which part is likely to vary, and can we add that variation without disturbing behaviour that already works?”This is the second 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.15.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.Subscribe nowI’ll be sharing more about how my programming, my daily work, and how everything has changed over the recent months.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 BlocksCode Block #1from decimal import Decimalclass PotionPricing: def price(self, customer_type, base_price, surcharge): if customer_type == "apprentice": subtotal = base_price + surcharge return (subtotal * Decimal("0.90")).quantize(Decimal("0.01")) raise ValueError(f"Unknown customer type: {customer_type}")base_price = Decimal("100.00")surcharge = Decimal("20.00")pricing = PotionPricing()print(pricing.price("apprentice", base_price, surcharge))Code Block #2from decimal import Decimalclass PotionPricing: def price(self, customer_type, base_price, surcharge): if customer_type == "apprentice": discount = Decimal("0.10") elif customer_type == "guild": discount = Decimal("0.05") else: raise ValueError(f"Unknown customer type: {customer_type}") discounted_base = base_price * (Decimal("1.00") - discount) return (discounted_base + surcharge).quantize(Decimal("0.01"))base_price = Decimal("100.00")surcharge = Decimal("20.00")pricing = PotionPricing()print(pricing.price("apprentice", base_price, surcharge))print(pricing.price("guild", base_price, surcharge))Code Block #3from decimal import Decimalclass ApprenticePricing: def price(self, base_price, surcharge): subtotal = base_price + surcharge return (subtotal * Decimal("0.90")).quantize(Decimal("0.01"))def quote(base_price, surcharge, policy): return policy.price(base_price, surcharge)base_price = Decimal("100.00")surcharge = Decimal("20.00")apprentice_pricing = ApprenticePricing()print(quote(base_price, surcharge, apprentice_pricing))Code Block #4from decimal import Decimalclass ApprenticePricing: def price(self, base_price, surcharge): subtotal = base_price + surcharge return (subtotal * Decimal("0.90")).quantize(Decimal("0.01"))class GuildPricing: def price(self, base_price, surcharge): discounted_base = base_price * Decimal("0.95") return (discounted_base + surcharge).quantize(Decimal("0.01"))def quote(base_price, surcharge, policy): return policy.price(base_price, surcharge)base_price = Decimal("100.00")surcharge = Decimal("20.00")apprentice_pricing = ApprenticePricing()guild_pricing = GuildPricing()print(quote(base_price, surcharge, apprentice_pricing))print(quote(base_price, surcharge, guild_pricing))stephengruppetta.com