Personally, this is one of my favorite principles, and I apply its concepts almost daily.
Before we dive in, I want you to ask yourself: how polymorphic is the data you work with every day? Oftentimes, we create a base abstraction and then derive multiple classes from it, which leads us to rely on numerous switch statements and if conditions in some procedures.
The Credit Card Problem
Suppose you have an investment portfolio application that accepts a variety of credit cards. How would you design a strategy to determine which credit card instance to instantiate so that you can properly use its debit methods during checkout?
The first instinct is to use if statements in the service layer:
public abstract class CreditCard {
public abstract void MakeDebit(decimal value);
}
public class VisaCreditCard : CreditCard {
public override void MakeDebit(decimal value) {
// specific implementation
}
}
public class MasterCardCreditCard : CreditCard {
public override void MakeDebit(decimal value) {
// specific implementation
}
}
public class AmericanExpressCreditCard : CreditCard {
public override void MakeDebit(decimal value) {
// specific implementation
}
}
public async Task MakeTransaction(decimal amount, CreditCardType cardType, string cardIdentifier)
{
if (CreditCardType.Visa == cardType)
{
var visaCard = CreditCardRepository.GetVisaCreditCard();
visaCard.VisaDebit(amount);
return;
}
if (CreditCardType.MasterCard == cardType)
{
var masterCard = CreditCardRepository.GetMasterCard();
masterCard.MasterCardDebit(amount);
return;
}
if (CreditCardType.AmericanExpress == cardType)
{
var aExpressCard = CreditCardRepository.GetAExpressCreditCard();
aExpressCard.SendMetrics();
aExpressCard.ExpressDebit(amount);
return;
}
}
This works. Until it doesn’t. Every new card requires an additional if block. The method grows, becomes harder to read, and becomes a magnet for bugs.
Closing the Method with a Strategy
To address this while following the Open/Closed Principle, we can use a dictionary to map to the correct credit card behavior. In this case we could also use the Template Method pattern — but a strategy dictionary is the simpler starting point:
public class PaymentService
{
public Dictionary<CreditCardType, Func<Task>> PaymentStrategies = new();
public PaymentService()
{
PaymentStrategies.Add(CreditCardType.Visa, async () => { /* implementation */ });
PaymentStrategies.Add(CreditCardType.MasterCard, async () => { /* implementation */ });
PaymentStrategies.Add(CreditCardType.AExpress, async () => { /* implementation */ });
}
public CreditCardRepository CreditCardRepository { get; set; }
public async Task MakeTransaction(decimal amount, CreditCardType cardType, string cardIdentifier)
{
await PaymentStrategies[cardType]();
}
}
With this approach, adding a new card means adding a new strategy — not touching the MakeTransaction method. These anonymous functions could live in separate files, and the initialization could happen at the application entry point via dependency injection.
By following this approach, we adhere to one of the core principles of OCP:
“Further changes in our code are achieved by adding new code, not by changing old code that already works.”
This means our module is:
- Open for Extension — we can add new behavior.
- Closed for Modification — we extend by adding new “plugins” to our strategy, rather than modifying the source module.
The Role of Abstraction
You may have noticed that abstraction is one of the key components here. In MakeTransaction, we don’t know exactly what a function is doing — we only know it will execute the behavior associated with a certain key. As Uncle Bob states:
“Such a module can be closed for modification since it depends upon an abstraction that is fixed (the MakeTransaction method). Yet the behavior of that module can be extended by creating new derivatives of the abstraction.”
Don’t Overuse It
The first time you learn this type of concept, it’s normal to feel the urge to apply it everywhere. But abstraction comes at a cost. Excessive abstraction increases the complexity of your code, making it harder to read, maintain, and debug.
There’s actually a pattern for knowing when to apply these principles:
- Understand the domain you’re coding for.
- With this domain knowledge, identify where changes occur most frequently.
- Anticipate where changes will happen and “close” that part — meaning, implement an abstraction.
Over the past decade, this has been one of the main frameworks for applying OCP. Think of step 3 — “closing it” — as adding a hook into your code to protect it from unnecessary modifications.
The Real Problem with Hooks
Many of these hooks end up being incorrect and add no real value. They go unused, making the system unnecessarily complex.
The solution? Instead of starting by implementing the abstraction, let the abstraction call us. Take the first bullet, learn from it, and then implement the abstraction through refactoring. As Uncle Bob cites: “Fool me once, shame on you; fool me twice, shame on me.”
So stimulate change early. How?
- Use short sprints.
- Develop and show progress to stakeholders.
- Prioritize the most important features.
- Release software early and often.
The key takeaway is that domain knowledge is one of the most important aspects of software development. We develop software to solve real problems, not to prove a point. It’s like a battlefield: you wouldn’t recruit a thousand soldiers to defend an area that doesn’t require that level of attention.
The Open/Closed Principle is a vital strategy on this battlefield — use it wisely. Factory Method and Template Method are among the most commonly used tools to implement it.