What is the Open Closed Principle?
SOLID
This is a continuation of posts on the SOLID principles. Find the rest here.
Open Closed Principle
The Open-closed Principle (OCP) is, in a similar fashion to SRP, about minimising the damage that making code changes does to a project. A nasty smell that would indicate that some software is in need of OCP is cascading changes - a simple change to the source code results in a cascade of changes to other parts of the code and their dependencies. When the principle is applied, a required change to software would result in new code rather than modification to old code.
Definition
So this takes us to the definition of the principle itself. It says that software should be open for extension, but closed for modification. Initially it can seem like these two rules counteract each other - how do you extend software without modifying it? The answer to this is to create extensible classes that allow for any new kind of behaviours by creating implementations of that class - in OOP terms, abstraction.
There are many ways to do this, and some really nice patterns exist that are made with this exact idea in mind. Two I have used recently to implement the ideas of OCP are strategy and observer patterns.
The strategy pattern gives a nice way of extracting behaviours into multiple child classes, whilst defining a common strategy interface that the “client” of the strategy can use. The observer pattern is more of an event-driven pattern that lets you notify different "subscribers"
of state change and the subscribers that need to react to that state change can do so.
Example
In a project I’ve been working on recently, I used some kind of amalgamation of these two patterns in a client-server architecture to allow for extensible responding to packets in the server from the client.
The project uses socket.io for its socket handling, which uses an .on
function for responding to events it receives on an active connection. It looks like this:
1 | socket.on("details", (...args) => { |
In this example, the socket
is an active connection from a client, and we’re reacting to a "details"
event.
From the documentation, it would seem the standard expectation would be to write multiple .on
functions to react to individual events. This means we could quite easily, and quickly, write a fully-functional server that responds to the clients events without much fuss.
If we wrote this code, we’d probably end up with something that looked like this:
1 | function handleDetails(...args) { |
However, if a new requirement was made for some extra features in the client and the client would be sending new events to the server, this change would require going into the server and modifying the code. But this breaks OCP, the more we go into this code and change or add features, the more bloated it is going to get and the more dreaded cascading changes we might see.
My approach to apply OCP for this was to create an EventHandler
class that acts as the Publisher
in the Observer pattern:
1 | export class EventHandler { |
Observers to this publisher are of type GameEventListener
- this is the abstract class that allows for endless extensibility in event listeners. Every listener that wants to implement some behaviour that reacts to an event must extend GameEventListener:
1 | export abstract class GameEventListener { |
The base GameEventListener
implements an execute
function that loops through the event listener’s handlers, Handler
is a class with two properties:
1 | class Handler { |
When any event is received by socket.io, it is sent to the event handler’s execute
function:
1 | io.on("connection", (socket: Socket) => { |
The .onAny
function allows me to send all events through to the event handler, and eventType
is the string for that event - so will be able to match on a Handler.event
string!
Implementations of the GameEventListener
abstract class are where things start to take a turn into the Strategy
pattern - each child event listener defines its own “strategy” of how to deal with an event, depending on what the event is. Taking the event listeners from the first example, this means you could have a DetailsEventListener
class, a EventEventListener
and a DisconnectEventListener
- these would all define their own behaviour for each event - AKA their strategy. So I end up with a nice mix of patterns that adheres to OCP and helps my use case.
Back to the GameEventListener.execute()
function - it loops through every implementation’s event handlers and tries to find a matching event. If it finds one, the function for that handler is executed. This means that if I ever want to add new functionality to my event listeners all I need to do is write a new class. It will implement GameEventListener
, write a handler function for an event, and then subscribe that handler to the EventHandler
class. There is no need to modify any existing code for new functionality, just create new code - OCP!
A simple example of an implementation of a GameEventListener
, AKA a strategy:
1 | export class DisconnectListener extends GameEventListener { |
This is a strategy I have in my server, with some details abstracted. The DisconnectListener
extends GameEventListener
meaning it keeps that execute
function - it then registers the event it listens to by adding a Handler
to its eventHandlers
array. It then subscribes to the EventHandler (my publisher) back in the main file:
1 | this.eventHandler.subscribe(new DisconnectListener()); |
Now, whenever the server receives a disconnect
event, the event handler pushes that event to its subscribers, who decide if they want to handle it or not. The DisconnectListener
strategy has some defined behaviour for that event, so its function is executed and it handles the event.
This means that any new functionality can be added simply by creating new classes, without worrying about modifying existing code.