Changing requirements and environments can require cascading changes through software. Nan Wang demonstrates how the Open-Closed principle can minimise changes.
Software entities (classes, modules, functions, etc.) should be open for extension but closed for modification. [ APPP ]
When the requirements of an application change, if the application confirms to OCP, we can extend the existing modules with new behaviours to satisfy the changes (Open for extension). Extending the behaviour of the existing modules does not result in changes to the source code of the existing modules (Closed for modification). Other modules that depend on the extended modules are not affected by the extension. Therefore we don’t need to recompile and retest them after the change. The scope of the change is localised and much easier to implement.
The key of OCP is to place useful abstractions (abstract classes/interfaces) in the code for future extensions. However, it is not always obvious which abstractions are necessary. It can lead to over complicated software if we add abstractions blindly. I found Robert C Martin’s ‘Fool me once’ attitude very useful [ APPP ]. I start my code with a minimal number of abstractions. When a change of requirements takes place, I modify the code to add an abstraction and protect myself from future changes of a similar kind.
I recently implemented a simple module that sends messages and made a series of changes to it afterward. I feel it is a good example of OCP to share.
At the beginning, I created a
MessageSender
that is responsible for converting an object message to a byte array and send it through a transport.
package com.thinkinginobjects; public class MessageSender { private Transport transport; public synchronized void send(Message message) throws IOException{ byte[] bytes = message.toBytes(); transport.sendBytes(bytes); } }
After the code was deployed to production, we found out that we sent messages too fast for the transport to handle. However, the transport was optimised for handling large messages, so I modified the
MessageSender
to send messages in batches of size of ten (Listing 1).
package com.thinkinginobjects; public class MessageSenderWithBatch { private static final int BATCH_SIZE = 10; private Transport transport; private List buffer = new ArrayList(); private ByteArrayOutputStream byteStream = new ByteArrayOutputStream(); public MessageSenderWithBatch(Transport transport) { this.transport = transport; } public synchronized void send(Message message) throws IOException { buffer.add(message); if (buffer.size() == BATCH_SIZE) { sendBuffer(); } } private void sendBuffer() throws IOException { for (Message each : buffer) { byte[] bytes = each.toBytes(); byteStream.write(bytes); } byteStream.flush(); transport.sendBytes(byteStream.toByteArray()); byteStream.reset(); } } |
Listing 1 |
The solution was simple but I hesitated to commit to it. There were two reasons:
-
The
MessageSender
class needs to be modified if we change how messages are batched in the future. It violated the Open-Closed Principle. -
The
MessageSender
had a secondary responsibility to batch messages in addition to the responsibility of converting/delegating messages. It violated the Single Responsibility Principle.
Therefore I created a
BatchingStrategy
abstraction, who was solely responsible for deciding how message are batched together. It can be extended by different implementations if the batch strategy changes in the future. In a word, the module was open for extensions of different batch strategy. The
MessageSender
kept its single responsibility of converting/delegating messages, which means it does not get modified if similar changes happen in the future. The module was closed for modification (see Listing 2).
package com.thinkinginobjects; public class MessageSenderWithStrategy { private Transport transport; private BatchStrategy strategy; private ByteArrayOutputStream byteStream = new ByteArrayOutputStream(); public synchronized void send(Message message) throws IOException { strategy.newMessage(message); List buffered = strategy.getMessagesToSend(); sendBuffer(buffered); strategy.sent(); } private void sendBuffer(List buffer) throws IOException { for (Message each : buffer) { byte[] bytes = each.toBytes(); byteStream.write(bytes); } byteStream.flush(); transport.sendBytes(byteStream.toByteArray()); byteStream.reset(); } } package com.thinkinginobjects; public class FixSizeBatchStrategy implements BatchStrategy { private static final int BATCH_SIZE = 0; private List buffer = new ArrayList(); @Override public void newMessage(Message message) { buffer.add(message); } @Override public List getMessagesToSend() { if (buffer.size() == BATCH_SIZE) { return buffer; } else { return Collections.emptyList(); } } @Override public void sent() { buffer.clear(); } } |
Listing 2 |
The patch was successful, but two weeks later we figured out that we can batch the messages together in time slices and overwrite outdated messages with newer versions in the same time slice. The solution was specific to our business domain of publishing market data.
More importantly, the OCP showed its benefits when we implemented the change. We only needed to extend the existing
BatchStrategy
interface with an different implementation. We didn’t change a single line of code but just the spring configuration file. (Listing 3)
package com.thinkinginobjects; public class FixIntervalBatchStrategy implements BatchStrategy { private static final long INTERVAL = 5000; private List buffer = new ArrayList(); private volatile boolean readyToSend; public FixIntervalBatchStrategy() { ScheduledExecutorService executorService = Executors.newScheduledThreadPool(1); executorService.scheduleAtFixedRate (new Runnable() { @Override public void run() { readyToSend = true; } }, 0, INTERVAL, TimeUnit.MILLISECONDS); } @Override public void newMessage(Message message) { buffer.add(message); } @Override public List getMessagesToSend() { if (readyToSend) { List toBeSent = buffer; buffer = new ArrayList(); return toBeSent; } else { return Collections.emptyList(); } } @Override public void sent() { readyToSend = false; buffer.clear(); } } |
Listing 3 |
* For the sake of simplicity, I have left the message coalescing logic out of the example.
Conclusion
The Open-Closed Principle serves as an useful guidance for writing a good quality module that is easy to change and maintain. We need to be careful not to create too many abstractions prematurely. It is worth deferring the creation of abstractions to the time when the change of requirement happens. However, when the changes strike, don’t hesitate to create an abstraction and make the module to confirm OCP. There is a great chance that a similar change of the same kind is at your door step.
References:
[APPP] Agile Software Development, Principles, Patterns, and Practices, Robert C Martin