The Open-Closed Principle in C#: An In-Depth Guide


Do you know why there is an ever-increasing demand for developers?

Because the code needs to be changed. All. The. Time.

However, if you don’t pay close attention, every change in code lead to more complexity in code down the road.

One principle that aims to reduce the likelihood of modification to the code is Open Closed Principle.

The Open-Closed Principle (OCP) states that software entities, such as modules, classes, functions, etc., should be open for extension but closed for modification. By following this principle, you reduce the risks of breaking the existing functionalities when introducing new features.

It also helps easily maintain codebases, improves software’s scalability, flexibility, and testability, and allows maintaining other SOLID principles.

OCP can be applied in C# in several ways, such as inheritance, dependency injection, and extension methods. In this article, let’s dig more into the OCP, learn how to implement it in C#, and discover the benefits of following it when building software applications.

What is the Open/Closed principle?

Bertrand Meyer introduced OCP in his book Object-Oriented Software Construction in 1998. The OCP states that Software entities like classes, modules, functions, etc., should be open for extension but closed for modification.

Now, before we go any further, let’s first understand the meanings of “open for extension” and “closed for modification.”

When a code is open for extension, its classes, modules, functions, etc., can be expanded to introduce new functionalities to satisfy business requirements. Examples are inheriting from parent classes, extending functions, implementing interfaces, etc. When the code is closed, you cannot modify the code to introduce new functionalities. 

When you have already developed a software system, your code is well-tested with unit tests. If you modify the existing classes, modules, functions, etc., to add new functionalities, it is possible to break other parts already developed and introduce new bugs. Instead, you need to separate those new functionalities by extending already written code so that the existing functionalities won’t be affected.

Open-closed principle is one of the five SOLID principles of software development:

How to implement the Open/Closed principle in C#

Unit Testing Books

Let’s take real-life examples to understand how to implement OCP in C#.

Consider the following example. Suppose you are building an online assessment software system allowing instructors to assign different assignment types to students.

public abstract class Assignment
{
    public int assignmentId { get; set; }
    public string title { get; set; }
    public double possiblePoints { get; set; }
    
    public abstract void CalculateGrades();
}

The Assignment abstract class here defines the general functionalities an assignment type should have. For example, the grade calculation will differ according to each assignment type. Each assignment type inherits the Assignment abstract class and implements the assignment-specific grading functionality.  

The existing code is not modifiable but extensible. Whenever you want to introduce a new assignment type to your assessment system, you can implement a new concrete class that inherits the Assignment parent abstract class.

public class EssayAssignment: Assignment
{
    public override void CalculateGrades()
    {
        //code to calculate assignment grade
        Console.WriteLine ("EssayAssignment Grades");
    }
}


public class ReadingAssignment: Assignment
{
    public override void CalculateGrades()
    {
       Console.WriteLine ("ReadingAssignment Grades");
    }
}


public class ProgrammingAssignment: Assignment
{
    public override void CalculateGrades()
    {
      Console.WriteLine ("ProgrammingAssignment Grades");
    }
}

Another common example of using OCP is implementing different log messages in an application. Different log messages exist within an application, such as error logs, warnings, access logs, etc. Each log message will be different. 

In the following code example, the Logger class consists of the virtual method Log to print the appropriate log message to the console.

public class Logger
{
    public virtual void Log(string message)
    {
        Console.WriteLine(message);
    }
}

If you want to introduce a new log message, developers can create a new class extending the Logger class and override the Log method to display the required log message.

public class ErrorLog: Logger
{
    public override void Log(string message)
    {
        Console.WriteLine("Error: " +  message);
    }
}


public class WarningLog: Logger
{
    public override void Log(string message)
    {
        Console.WriteLine("Warn: " + message);
    }
}

Another way to implement OCP is through interfaces and dependency injection (DI). To be more specific, we’ll use the Decorator pattern to add new functionality.

If you are not familiar with the Decorator pattern, it is a design pattern that allows you to add new behavior to an existing class without modifying the original code. It is implemented by creating a new class that will wrap the original class and adds the new behavior.

Let’s see one real-world example of using the Decorator pattern.

We have the following OrderRepository that implements the IOrderRepository:

record Order(int OrderId, decimal TotalAmount);


internal interface IOrderRepository
{
    void Add(Order order);
    void Update(Order order);
    void Delete(Order order);
    Order Get(int orderId);
}

class OrderRepository : IOrderRepository
{
    public void Add(Order order)
    {
        Console.WriteLine("Original Repo: Order added");
    }

    public void Update(Order order)
    {
        Console.WriteLine("Original Repo: Order updated");
    }
    public void Delete(Order order)
    {
        Console.WriteLine("Original Repo: Order deleted");
    }

    public Order Get(int orderId)
    {
        Console.WriteLine("Original Repo: Order retrieved");
        return new Order(orderId, 100);
    }
}

Now, to implement a logging functionality to the OrderRepository, add the LoggerRepository. The LoggerRepository will delegate all method calls to the decorated object while also logging information about the method call.

class LoggerRepository : IOrderRepository
{
    private readonly IOrderRepository _orderRepository;

    public LoggerRepository(IOrderRepository orderRepository)
    {
        _orderRepository = orderRepository;
    }

    public void Add(Order order)
    {
        Console.WriteLine("Logger: Order added");
        _orderRepository.Add(order);
    }

    public void Update(Order order)
    {
        Console.WriteLine("Logger: Order updated");
        _orderRepository.Update(order);
    }

    public void Delete(Order order)
    {
        Console.WriteLine("Logger: Order deleted");
        _orderRepository.Delete(order);
    }

    public Order Get(int orderId)
    {
        Console.WriteLine("Logger: Order retrieved");
        return _orderRepository.Get(orderId);
    }
}

Therefore, through DI, you could introduce new functionality without changing the existing code.

How to make a class open for extension and closed for modification?

Use the following approaches to make code open for extension and close for modification:

  1. Identify common functionalities of a group of related classes and define abstract classes and interfaces, including those common behaviors. 
  2. Use inheritance by extending the abstract classes or implementing the interfaces. The new classes can have their own implementations while inheriting the functionalities from the parent classes. Therefore, no modifications to the parent classes are required.
  3. Introduce dependency injection to implement new behaviors. Use Decorator pattern to inject new functionality into a code without modifying the original class.

How to identify when a class violates OCP?

Ok, now that you have seen some examples, you might be thinking “So what?” Open Closed Principle just looks like you define an abstract class and use inheritance to implement subclasses. Is that all there is to it?

And the answer is no.

Let’s take another example, another clas.

A class that violates the OCP requires modifying the existing codes when you want to add new functionalities. Such classes have a tight coupling with other classes. Therefore, a change in the class may need modifications to its dependent classes.

Also, you can easily spot them by looking at their methods and structures.

If the method contains too many if else statements comparing different conditions, it indicates that it violated OCP. As a result, you have to add more conditional statements whenever there is a new requirement, making it harder to maintain.

The following code is an example of this behavior.

public class Account
{
    public double CalculateInterest(double amount, string accountType)
    {
        double interest = 0.00;

        if (accountType == "Savings")
        {
            interest = amount * 0.25;
        }
        else if (accountType == "Current")
        {
            interest = amount * 0.35;
        }
        else if (accountType == "Foreign")
        {
            interest = amount * 0.75;
        }
        return interest;
    }
}

What happens when you want to add a new accountType named “Gold” and calculate its interest amount? Well, you have to change the existing code to implement the new requirement.

public class Account
{
    public double CalculateInterest(double amount, string accountType)
    {
        double interest = 0.00;

        if (accountType == "Savings")
        {
            interest = amount * 0.25;
        }
        else if (accountType == "Current")
        {
            interest = amount * 0.35;
        }
        else if (accountType == "Foreign")
        {
            interest = amount * 0.75;
        }
        else if (accountType == "Gold")
        {
            interest = amount * 0.10;
        }
        return interest;
    }
}

The alternative is to split the implementation into several classes and have a class per account type. To achieve this, you can create an interface called IAccount and have each account type implement this interface.

Then, we can create a factory class that creates the appropriate account type based on the account type string passed in.

Here’s the modified code:

public interface IAccount
{
    double CalculateInterest(double amount);
}

public class SavingsAccount : IAccount
{
    public double CalculateInterest(double amount)
    {
        return amount * 0.25;
    }
}

public class CurrentAccount : IAccount
{
    public double CalculateInterest(double amount)
    {
        return amount * 0.35;
    }
}

public class ForeignAccount : IAccount
{
    public double CalculateInterest(double amount)
    {
        return amount * 0.75;
    }
}

public class GoldAccount : IAccount
{
    public double CalculateInterest(double amount)
    {
        return amount * 0.10;
    }
}

public class AccountFactory
{
    public static IAccount CreateAccount(string accountType)
    {
        switch (accountType)
        {
            case "Savings":
                return new SavingsAccount();
            case "Current":
                return new CurrentAccount();
            case "Foreign":
                return new ForeignAccount();
            case "Gold":
                return new GoldAccount();
            default:
                throw new ArgumentException("Invalid account type", "accountType");
        }
    }
}

In this code, we have defined an interface IAccount with a single method CalculateInterest. We then create four classes SavingsAccount, CurrentAccount, ForeignAccount, and GoldAccount. Each class implements the IAccount interface and provide their own implementation of the CalculateInterest method.

Finally, we have a factory class called AccountFactory with a static method CreateAccount that takes an account type string and returns an instance of the appropriate account type. This factory class can be used to create account objects without having to modify the existing code, making it easier to extend in the future.

Benefits of using the Open/Closed principle in C#

The following are benefits of the Open Closed Principle:

  1. Improved code quality and maintainability – The code that follows OCP is less error-prone when introducing new behaviors. Also, since such codes are modularized, they are easy to unit tests. These characteristics largely contribute to higher code quality and maintainability.
  2. Increased code reuse –  Using OCP removes duplicated codes packing the common codes into reusable classes, modules, functions, etc., that can be extensible and customized to have different behaviors. 
  3. Better separation of concerns –  It promotes the Single Responsibility Principle so that each class, function, or module has exactly one well-defined responsibility. Also, it encourages you to use abstractions and interfaces to separate the concerns into separate components. 
  4. Enhanced extensibility – OCP allows code to be open for extension without the need to modify, rewrite, or refactor the existing code. This ability lets developers easily extend the software application to have enhanced features.

Should you bother with following Open Closed Principle all the time?

After you have seen some examples and see the benefits, you might be wondering: Should I follow OCP all the time and have all those tiny classes to avoid modifying the existing code at all times?

The answer to that very long question is No.

OCP, while having its benefits, is an overkill if you use it all the time.

Look, the code in most applications is more complicated than it should be. And if you add further decoupling to that code, you will not only add more complexity to it. But also have a code that looks over-engineed.

Having an abstract class and subclasses for every if statement in your application is not necessary. Instead, use your judgement and see how to utilize Open-Closed Principle to the part of the code that needs it most.

Of course, there may be situations where following the OCP is not practical or possible.

For example, if you are working with legacy code or dealing with external dependencies, you may not be able to modify the existing code to follow the OCP.

In these situations, you have to be more pragmatic and make the most out of the situation you are in. That means you should make trade-offs and prioritize other design principles, such as simplicity or performance. You should also make sure you add some characterization tests around the code you have before attempting to change it.

Conclusion

In summary, the OCP is one of the SOLID principles, which states that Software entities like classes, modules, functions, etc., should be open for extension but closed for modification.

The code that follows OCP cannot modify its entities, like classes, modules, functions, etc., to add new functionalities. It can do so only by extending them.

Designing software according to OCP is important to improve the software quality by reducing bugs when introducing new functionalities.

Also, it is important to have easily testable and maintainable code.

In addition, OCP is important when designing reusable and scalable software systems. C# lets you implement OCP through abstractions, interfaces, dependency injections, and extension methods.

Recent Posts