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
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.
}
}
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 theGetCharacterCount
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.
}
}
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);
}
- Move each command into its own class, the implementations:
public class ByteCountCommand : ICommand
{
public string Execute(string filePath)
{ /* implementation */}
}
public class WordCountCommand : ICommand
{
public string Execute(string filePath)
{ /* implementation */}
}
public class LineCountCommand : ICommand
{
public string Execute(string filePath)
{ /* implementation */}
}
- 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");
}
}
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;
}
}
- Modify
Processor
to useCommandFactory
:
public class Processor
{
private readonly CommandFactory _factory = new();
public string ProcessCommand(string commandKey, string filePath)
{
var command = _factory.GetCommand(commandKey);
return command.Execute(filePath);
}
}
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;
}
- 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 */ }
- 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;
}
}
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 implementsICommand
. No existing code modification needed:
[CommandKey("-m")]
public class CharacterCountCommand : ICommand
{
/* Implementation */
}
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
- Open-Closed Principle by Wikipedia
- The Open-Closed Principle, In Review by Jon Skeet
NOTE: Please fully verify all code snippets before using them, as they may or may not function as shown.
Top comments (0)