DEV Community

Vedant Phougat
Vedant Phougat

Posted on • Edited on

Applying Open Closed Principle to Real-World Code

Table of Contents

1. Introduction

Have you ever had to dig through an existing codebase to modify it, just to add a new feature? After making changes, you then find yourself re-testing the modified part of the system, that was previously functioning well. This is a classic example of violation of the Open-Closed Principle (OCP from here), which emphasizes designing software so that new features can be added with minimal or no changes to existing code.

In his article—The Open-Closed Principle, Uncle Bob describes a plugin system as “the ultimate example of the Open-Closed Principle”.
In a related post—An Open and Closed Case, he clarifies OCP’s essence, stating: “... you should aim to structure your code so that, when behavior changes in expected ways, you won’t need to make sweeping changes across the system. Ideally, you’ll be able to add new behavior by adding new code and making minimal or no changes to existing code.”

In this blog post, we will use an example of a command-utility tool, let's call it wc.NET, where commands are added as new requirements. The design of wc.NET aligns closely with a plugin architecture. While it may not be an external plugin system, where commands are loaded from separate assemblies or packages at runtime, the architecture achieves internal extensibility. This means new commands (or "internal plugins") can be added simply by writing new code, with minimal or zero modification to the existing codebase—the essence of OCP.

Let's start with the official definition:

Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.


2. Requirements

Develop a command line tool (wc.NET from here) using C# & .NET. The wc.NET must accept commands in the following format:

> -c <file_path>.txt
> -w <file_path>.txt
> -l <file_path>.txt
> -m <file_path>.txt
Enter fullscreen mode Exit fullscreen mode

Here is the table indicating which command performs what type of count on a text file:

Command Key Type of count to be performed
-c Gets the number of Bytes
-w Gets the number of Word
-l Gets the number of Line
-m Gets the number of Character

I believe these requirements are sufficient for our purpose, but in case you want detailed information about requirements, please visit Build Your Own wc Tool.


3. Development

As per the requirements, the initial development led us to the following code:

public class Processor
{
    public Int32 ProcessCommand(String[] args)
    {
        //validate 'args' to make sure that it
        //has the required arguments
        var filepath = args[^1];  //gets the last item of array
        var commandKey = args[0];
        switch (commandKey)
        {
            case "-c":
                return GetByteCount(filepath);
            case "-w":
                return GetWordCount(filepath);
            case "-l":
                return GetLineCount(filepath);
            default:
                throw new InvalidOperationException("Command not found.");
        }
    }

    private Int32 GetByteCount(String filepath)
    {
        //process and return the Byte Count.
    }

    private Int32 GetWordCount(String filepath)
    {
        //process and return the Word Count.
    }

    private Int32 GetLineCount(String filepath)
    {
        //process and return the Line Count.
    }
}
Enter fullscreen mode Exit fullscreen mode

The class Processor currently has three commands, and per the requirements, when we implements a fourth, we will have to update the Processor class by adding:

  • a GetCharacterCount method
  • a case "-m" statement to handle the GetCharacterCount method.

And here is how it will look:

public class Processor
{
    public Int32 ProcessCommand(String[] args)
    {
        //validate 'args' to make sure that it
        //has the required arguments
        var filename = args[^1];  //gets the last item of array
        var commandKey = args[0];
        switch (commandKey)
        {
            case "-c":
                return GetByteCount(filepath);
            case "-w":
                return GetWordCount(filepath);
            case "-l":
                return GetLineCount(filepath);
            case "-m":    //<--- newly added case block
                return GetCharacterCount(filepath);
            default:
                throw new InvalidOperationException("Command not found.");
        }
    }

    private Int32 GetByteCount(String filepath)
    {
        //process and return the Byte Count.
    }

    private Int32 GetWordCount(String filepath)
    {
        //process and return the Word Count.
    }

    private Int32 GetLineCount(String filepath)
    {
        //process and return the Line Count.
    }

    //<--- newly added method to be called in 'case "-m"' block --->
    private Int32 GetCharacterCount(String filepath)
    {
        //process and return the Line Count.
    }
}
Enter fullscreen mode Exit fullscreen mode

We wouldn’t just need to add a case block and a method to calculate the count, we would also have to add several other helper methods to support the count calculations. Over time, this approach would turn the class into an unmanageable, overly complex piece of code.

3.1. Challenges of initial approach

  • Risk of introducing bugs in a stable system: Adding each new command modifies the Processor class directly. And modifying an existing class directly, raises the risk of introducing bugs in a stable system, that was previously functioning well.
  • Hard to read, maintain, and extend: As more commands are added, the switch block grows, making the code harder to read and maintain. This complexity makes it tough to extend or update the class over time.

4. Refactor

To ensure the code adheres to the OCP and improves modularity, we will take a phased approach to refactoring.

4.1. Move each command to individual classes

In this phase, we will apply Single Responsibility Principle (SRP), to move each command's logic into its own class, and Polymorphism with each command implementing ICommand interface. This will remove Processor's direct dependency on each command's implementation.

  • Define the ICommand interface:
// <--- Interface --->
public interface ICommand
{
    string Execute(string filepath);
}
Enter fullscreen mode Exit fullscreen mode
  • Move each command into its own class, the implementations:
public class ByteCountCommand : ICommand
{
    public string Execute(string filePath)
    { /* implementation */}
}
Enter fullscreen mode Exit fullscreen mode
public class WordCountCommand : ICommand
{
    public string Execute(string filePath)
    { /* implementation */}
}
Enter fullscreen mode Exit fullscreen mode
public class LineCountCommand : ICommand
{
    public string Execute(string filePath)
    { /* implementation */}
}
Enter fullscreen mode Exit fullscreen mode
  • Refactoring Processor class to use a dictionary of commands:
public class Processor
{
    private readonly Dictionary<string, ICommand> _commands;

    public Processor()
    {
        _commands = new Dictionary<string, ICommand>
        {
            { "-c", new ByteCountCommand() },
            { "-w", new WordCountCommand() },
            { "-l", new LineCountCommand() }
        };
    }

    public string ProcessCommand(string commandKey, string filePath)
    {
        if (_commands.TryGetValue(commandKey, out var command))
        {
            return command.Execute(filePath);
        }

        throw new InvalidOperationException("Unknown command");
    }
}
Enter fullscreen mode Exit fullscreen mode

4.2. Introduce a factory for command creation

Here we will create a CommandFactory class to centralize command creation. This change removes the direct dependency of creating individual command objects from the Processor.

  • Define CommandFactory:
public class CommandFactory
{
    private readonly Dictionary<string, ICommand> _commands = new()
    {
        { "-c", new ByteCountCommand() },
        { "-w", new WordCountCommand() },
        { "-l", new LineCountCommand() }
    };

    public ICommand GetCommand(string commandKey)
    {
        if (!_commands.TryGetValue(commandKey, out var command))
        {
            throw new CommandNotFound();
        }
        return command;
    }
}
Enter fullscreen mode Exit fullscreen mode
  • Modify Processor to use CommandFactory:
public class Processor
{
    private readonly CommandFactory _factory = new();

    public string ProcessCommand(string commandKey, string filePath)
    {
        var command = _factory.GetCommand(commandKey);
        return command.Execute(filePath);
    }
}
Enter fullscreen mode Exit fullscreen mode

In this phase OCP is nearly achieved, as the current design allows us to add new features (commands) with only small changes in existing code—the class CommandFactory will be modified by adding each new command to the dictionary.

4.3. Dynamic command discovery and auto-registration

To fully comply with OCP, we need to eliminate any need to change CommandFactory by using attribute-based discovery and auto-registration using reflection. This approach allow new commands to be discovered and auto-registered, without changing any existing code—achieving complete OCP compliance.

  • Define CommandKeyAttribute:
[AttributeUsage(AttributeTargets.Class)]
public class CommandKeyAttribute : Attribute
{
    public string Key { get; }
    public CommandKeyAttribute(string key) => Key = key;
}
Enter fullscreen mode Exit fullscreen mode
  • Decorate each command with CommandKeyAttribute as follows:
[CommandKey("-c")]
public class ByteCountCommand : ICommand { /* Same implementation */ }

[CommandKey("-w")]
public class WordCountCommand : ICommand { /* Same implementation */ }

[CommandKey("-l")]
public class LineCountCommand : ICommand { /* Same implementation */ }
Enter fullscreen mode Exit fullscreen mode
  • Modify CommandFactory to discover and register commands dynamically:
public class CommandFactory
{
    private readonly Dictionary<string, ICommand> _commands;

    public CommandFactory()
    {
        //>>> This will fetch all the concrete classes that implement ICommand.
        //>>> And then it will filter out those that are not decorated using the CommandKeyAttribute.
        //>>> And then, the remaining commands are stored as key-value pairs in the dictionary.
        //>>> Key: command key (c, l etc.) | Value: object of that commands
        _commands = Assembly
            .GetExecutingAssembly()
            .GetTypes()
            .Where(type => typeof(ICommand).IsAssignableFrom(type) && !type.IsAbstract && type.IsClass)
            .Where(type => type.GetCustomAttribute<CommandKeyAttribute>() is not null)
            .ToDictionary(
                type => type.GetCustomAttribute<CommandKeyAttribute>()!.Key,
                type => (ICommand)Activator.CreateInstance(type)!);
    }

    public ICommand GetCommand(string commandKey)
    {
        if (!_commands.TryGetValue(commandKey, out var command))
        {
            throw new CommandNotFound();
        }

        return command;
    }
}
Enter fullscreen mode Exit fullscreen mode

5. Exercise

Lets test our complete OCP compliance by adding a new command, "-m", which calculates the character count of the text file, as described in the Requirements section.

  • All we need to do is add a new CharacterCountCommand class that implements ICommand. No existing code modification needed:
[CommandKey("-m")]
public class CharacterCountCommand : ICommand
{
    /* Implementation */
}
Enter fullscreen mode Exit fullscreen mode

6. Conclusion

The OCP is a powerful principle for designing and developing software that are easier to extend with minimal or no code changes to existing codebase.

Step-by-Step OCP application in wc.NET: We transformed a tightly coupled design into a fully flexible one, by:

  • Separating commands into their own classes.
  • Using a factory to handle command creation.
  • Adding attribute-based discovery and reflection to auto-register new commands.

Advantages of following OCP

  • Easier to Extend: This approach keeps the code easier to extend by writing new code, for new requirements, instead of changing existing parts of the code.
  • Fewer Bugs: As long as existing code is stable and remains untouched, it is less likely to break and resulting in fewer bugs.

To fully implement OCP, we needed the Single Responsibility Principle (SRP) to keep each command focused.


7. See also


NOTE: Please fully verify all code snippets before using them, as they may or may not function as shown.


Top comments (0)