Skip to main content
Create your own
Lesson illustration

Designing Extensible and Maintainable Classes with OCP

Hello! Let's continue our journey through the SOLID principles.

In our last lesson, we focused on the Single Responsibility Principle (SRP), learning to create small, cohesive classes that each have only one reason to change. This is the foundation for clean architecture. Today, we'll build directly on that idea with the second SOLID principle.

This lesson addresses the learning outcome: Apply the Open/Closed Principle (OCP) to design classes that are open for extension but closed for modification.

At first, "open for extension, but closed for modification" might sound like a contradiction. How can you add new features (be "open") without changing existing code (being "closed")? We'll see that the key lies in mastering abstraction, a concept you're already familiar with from your work with Java interfaces. This principle is a cornerstone of creating flexible systems that can evolve without becoming fragile—a crucial skill for any system design interview.

1. The Problem: Code That Resists Change

Imagine you have a class that needs to perform different actions based on some "type" information. A common but problematic approach is to use a chain of if-else-if or a switch statement.

OCP Violation: Graphic Editor Example
This diagram illustrates a `GraphicEditor` class that must be modified every time a new shape is introduced. The conditional logic (`if-else if`) to determine which shape to draw is a classic violation of the Open/Closed Principle.

The code in the diagram above is not closed for modification. Every time the business wants to support a new shape (e.g., a triangle), a developer must go back into the GraphicEditor class, add another else if block, and re-test the entire class. This process is risky, as it can introduce new bugs into code that was previously working perfectly.

Let's watch a short video that demonstrates this exact problem in a Java context.

SOLID Design Principles with Java Examples | Clean Code and Best Practices | Geekific

This video from Geekific demonstrates a classic violation of the OCP. It shows how a seemingly simple design choice can make your code fragile and hard to extend.

Watch from 03:15 to 04:54. The presenter sets up a Video class with categories and then creates a calculator class that uses a switch statement. Pay close attention to why this design is problematic when a new video category is added.

As the video showed, the EarningsCalculator is brittle. It must be opened up and modified every time a new Category is added. This design directly opposes the Open/Closed Principle.

2. The Solution: Abstraction and Polymorphism

The solution, as redefined by Robert C. Martin, is to depend on abstractions (like interfaces or abstract classes) rather than concrete implementations. This allows you to leverage polymorphism to achieve the goals of OCP.

The core idea is:

  1. Identify a common behavior that varies across different types.
  2. Define an interface that represents this behavior.
  3. Implement this interface with concrete classes, one for each variation.
  4. Program the client code to use the interface, not the concrete classes.

This way, to add new functionality, you simply add a new class that implements the interface. The existing code, which depends only on the interface, doesn't need to change. It is open to new implementations but closed to modification.

Let's explore this with a detailed example.

The Open/Closed Principle with Code Examples

This article from Stackify provides a superb definition of OCP and walks through a practical refactoring of a Java application to adhere to the principle. This is the core of what you need to know.

Please read the following sections: 'Definition of the Open/Closed Principle': Understand the modern interpretation using interfaces, which is more robust than the original idea based on inheritance. 'Applying the Open/Closed principle': Follow the complete refactoring process, from extracting the CoffeeMachine interface to adapting the client code (CoffeeApp).

The coffee machine example from the article is a perfect illustration.

  • Before OCP: The CoffeeApp depended directly on the BasicCoffeeMachine class. To support a PremiumCoffeeMachine, the CoffeeApp itself would have to be modified.
  • After OCP: By introducing a CoffeeMachine interface, the CoffeeApp now depends on an abstraction. It can work with any class that implements CoffeeMachine, whether it's the BasicCoffeeMachine or the PremiumCoffeeMachine.

This refactored design is flexible and maintainable.

Refactored Coffee Machine Design (OCP)
This UML diagram shows the result of applying OCP. The `CoffeeApp` (not shown) would interact with the `CoffeeMachine` interface, decoupling it from the concrete `PremiumCoffeeMachine` and `BasicCoffeeMachine` implementations.

Now, let's see this same solution applied to the video example we started with.

SOLID Design Principles with Java Examples | Clean Code and Best Practices | Geekific

Let's return to the video example and see how the presenter fixes the EarningsCalculator using an interface.

Watch from 04:54 to 05:45. Notice how an IEarningsCalculator interface is introduced, and each category gets its own implementation class. The system is now 'open' to new categories via new classes, but the existing classes and the client logic are 'closed'.

3. OCP and Your Spring Boot Experience

As a Spring Boot developer, you already leverage the power of OCP every day through Dependency Injection (DI).

When you write a service, you typically inject an interface, not a concrete class:

@Service
public class OrderService {

    private final PaymentGateway paymentGateway;

    @Autowired
    public OrderService(PaymentGateway paymentGateway) {
        this.paymentGateway = paymentGateway;
    }

    public void processOrder(Order order) {
        // ...
        paymentGateway.charge(order.getTotal());
        // ...
    }
}

Your OrderService is closed for modification regarding the payment logic. It doesn't know or care whether the PaymentGateway is StripePaymentGateway or PayPalPaymentGateway. Your application is open for extension because you can add a new BraintreePaymentGateway implementation and switch to it via configuration (@Primary, @Qualifier, or profiles) without ever touching the OrderService code.

This decoupling of a service from its dependencies via interfaces is OCP in action at an architectural level.

Test your understanding!

You are designing a notification system for an application. A central NotificationService needs to send notifications via different channels like Email, SMS, and Push Notifications.

A first-draft implementation looks like this:

public class NotificationService {
    public void sendNotification(String message, String channel) {
        if (channel.equals("email")) {
            // Logic to send an email
            System.out.println("Sending Email: " + message);
        } else if (channel.equals("sms")) {
            // Logic to send an SMS
            System.out.println("Sending SMS: " + message);
        } else if (channel.equals("push")) {
            // Logic to send a push notification
            System.out.println("Sending Push Notification: " + message);
        }
    }
}

How would you refactor this code to adhere to the Open/Closed Principle, so that adding a new channel (e.g., "Slack") doesn't require modifying the NotificationService? Describe the interface and the classes you would create.

Show answer

This if-else-if structure is a clear violation of OCP. To fix this, we'll use an interface and multiple concrete implementations. This pattern is also known as the Strategy Pattern, which we will study in a later module.

1. Create an Interface:
First, we define an interface that captures the common action: sending a notification.

public interface NotificationChannel {
    void send(String message);
}

2. Create Concrete Implementations:
Next, we create a separate class for each channel, each implementing the NotificationChannel interface.

public class EmailChannel implements NotificationChannel {
    @Override
    public void send(String message) {
        System.out.println("Sending Email: " + message);
    }
}

public class SmsChannel implements NotificationChannel {
    @Override
    public void send(String message) {
        System.out.println("Sending SMS: " + message);
    }
}

public class PushNotificationChannel implements NotificationChannel {
    @Override
    public void send(String message) {
        System.out.println("Sending Push Notification: " + message);
    }
}

3. Refactor the NotificationService:
The NotificationService no longer contains conditional logic. Instead, it uses a NotificationChannel object to perform the action. It could receive the specific channel object through its constructor or a method. A more advanced approach could use a Map to hold all available strategies.

// Simplified client/service
public class NotificationService {
    public void send(NotificationChannel channel, String message) {
        channel.send(message);
    }
}

Now, if you want to add a SlackChannel, you just create a new SlackChannel class that implements NotificationChannel. The NotificationService and all existing channel classes remain untouched. The system is open to extension but closed for modification.

4. Practical Application: Don't Over-Engineer

While OCP is powerful, it's not a rule to be applied blindly to every part of your application. Abstraction adds complexity, and creating interfaces for things that will never change is a form of over-engineering. The key is to apply OCP to areas of your code that you anticipate will change or evolve.

SOLID series: The Open-Closed Principle

Knowing when and how to apply a principle is as important as knowing the principle itself. This article from LogRocket provides excellent advice on applying OCP pragmatically.

Please read these two sections: 'When OCP helps vs. when it harms': This will help you identify good candidates for applying OCP (like plugin architectures or APIs) and recognize signs of over-engineering. 'Best practices for applying the Open-Closed Principle without over-engineering': This gives concrete advice, like focusing on real business needs and using DI.

The primary takeaway is to be strategic. Apply abstraction where you foresee future extension points, such as different types of data export formats, payment providers, notification channels, or user authentication methods.

Conclusion

Today we explored the Open/Closed Principle, a powerful guide for building software that can grow over time without breaking. By programming to interfaces rather than implementations, you create systems that are flexible, maintainable, and robust.

Key Takeaways:

  • OCP Defined: Software entities should be open for extension but closed for modification.
  • The Mechanism: Achieve OCP through abstraction (interfaces, abstract classes) and polymorphism. Avoid if-else or switch statements that operate on a type code.
  • Practical Use in Spring: Dependency Injection is a manifestation of OCP. You inject interfaces and can swap out the concrete implementations without changing the client class.
  • Be Pragmatic: Apply OCP to parts of the system that are likely to have variations or evolve. Don't create abstractions for things that are inherently stable.

In our next lesson, we will tackle the Liskov Substitution Principle (LSP). Now that we know how to design systems that can be extended with new implementations of an interface, LSP will provide the critical rules to ensure those implementations are correct and can be substituted for one another without causing errors.

Can't find a good explanation? Sign up and we'll make it for you

Sign up