Software Design 101 — Encapsulate What May Change

Hi again! Let me start with a few simple questions — do you need to buy a new phone every time you want to download and try a new app? Or do you have to go buy a new antenna for your TV set when you want to change your subscribed channel pack? Or do you need to re-wire your home electrical supply when you buy a new appliance? No. Why? Because even though every new appliance may have a different Voltage and Ampere rating, the appliance itself comes with an electrical adapter responsible for interfacing the appliance with the standard electrical supply at your home. In other words, the changes happening in the power requirements of household appliances are encapsulated from the standard power supply at your home thus ensuring that
what stays the same is isolated from what changes often.Let us first look at a simple software design example. Let us say we have an instance of class Animal. The Animal can have a move routine which could vary based on the type of animal, something like:
if (animal.type == dog)
print("animal is walking");
else if (animal.type == bird)
print("animal is flying");
else if (animal.type == snake)
print("animal is crawling");Now, when we are implementing the move routine this way, wherever in our project we need the Animal to move, we need to write this if-else or say a switch-case kind of block. If tomorrow we want to add a new animal type Kangaroo that prints “animal is hopping”, we may have to add a new case to all the places in our app where we have the move routine being used.
OOPs to the rescue?
I’m sure you already have, in your head, created the class Animal with a move() routine and its subclasses Dog, Snake, Bird, Kangaroo all with their own overriden version of the move() method and Voila! And you will be absolutely right in thinking of encapsulation in this fashion (though to be fair, there are both Encapsulation and Abstraction in play here but that is a story for another time), but let us take a step back. Encapsulation is not just an Object Oriented Programming Principle, it is rather a general Software Design Principle which exists even in functional programming.
How do we encapsulate changing pieces of our code in the absence of objects and inheritance? Simple enough if we go back to ask what was the primary objective — Encapsulate away what changes from what doesn’t. So if we have ten places in our code where we have the switch-case block for the animal move routine, we can move that into a method, like below:
void move(animal) {
if (animal.type == dog)
print("animal is walking");
else if (animal.type == bird)
print("animal is flying");
else if (animal.type == snake)
print("animal is crawling");
}So now when adding a new type of animal, we just need to add a line of code at one place in our project. As trivial as this may sound — this is also encapsulation! What I wanted to highlight here is that Encapsulation, though much more evidently powerful in an Object-Oriented environment, exists even outside of OOP and is equally useful and significant if we understand its primary objectives and benefits.
Encapsulation is Everywhere!
When you turn your car’s steering and the car wheels turn — there is a multitude of moving pieces working together to transform your action into the final effect on the car’s wheels, but all those details are encapsulated from you. Don’t be confused if someone says this is an example of Abstraction — they’re also right! And so am I when I say that this and most other instances we can find, both in the software world and the real world, are examples of both Encapsulation and Abstraction in action together. These two concepts are distinct but related and are mostly found to be functioning together (and yes, we will discuss all of that in detail in a later piece).
Similarly, any system (software or not) where the internal details are hidden from the end user and the user can interact only via a well-defined and (often) heavily abstracted interface, is an example of encapsulation in action. All the USB enabled devices that we use are based on encapsulation as they all interact with the USB devices via a global, well-defined interface and once the USB port and the USB device are connected, what is happening on either side of that connection is of no concern to the other side.
Encapsulation in Software Design
Encapsulation in software design is commonly used for two purposes — hiding internal details (like private data, internal implementation etc.) and keeping unrelated concerns isolated from each other. The first one is pretty obviously visible and very clearly desirable as you always want to have some private data inside a class (or system) which is not exposed to its clients and you also want to have actual implementations hidden so that the interaction between a client and a service is via interfaces and has no dependency on implementation. Among the many benefits of doing so, is the fact that your implementation can change without bothering the client. Also, many information security methods are based on data hiding as a feature.
The latter purpose of keeping unrelated concerns separate (also called decoupling) is a huge one when it comes to designing better software systems. Coming back to the title of this piece — encapsulation is super powerful when it comes to bulletproofing your systems and designs from foreseeable changes. Change is inevitable so there’s no point shying away from its possibility or impact on existing systems and the best we can do is ensure that we design our systems keeping future changes in mind. Of course one can only foresee so much but your objective should be to isolate aspects that could undergo changes more frequently from others that may remain the same, so that separate modules of your project can evolve independently. This has many related benefits, one of them being lesser overhead while making changes — less amount of code to be changed, lesser testing and more confidence on the impact area of those changes. Then there is the fact that you can distribute your project work among team members efficiently, with lesser disruption on one member’s work from that of another, because individual pieces can be developed separately.
Design Example
Let us take a look at a typical software design example using encapsulation.
Say we’re building a messaging app like WhatsApp, and our first objective is to support text messages between two client devices. Our first design for the Message object may have only one ‘text’ or ‘String’ field:
class Message {
String text;
}Based on this ‘assumption’ that there is only a text field in the message (and nothing else), we may write the message generating code and the message handling code:
class MessageDisplay {
public void displayMessage(Message message) {
System.out.println(message.text);
}
}Such routines will result in many problems when we try to introduce other types of messages like, say Audio or Video or Photo messages (like most messenger apps do). Can you see how encapsulation could help us here?
First of all, the Message object needs to encapsulate (and hide) what’s inside it, and thus the message sending and receiving routines should not know (or assume) the internal composition of the message so that they take no decisions based on that. You can argue that someone, somewhere in the code will need to know what is inside the message and that’s perfectly alright. The piece of code that is responsible for showing each message on the UI will need to know how to display the message contents and it will need knowledge of how to handle various types of content. But, we can still hide the internals of a message from other parts of the code which do not need that knowledge. One such module may be the one responsible for sending the message.
For example, let us assume these messages are being sent over the network and we are using some kind of encoding-decoding for the same, the encoding routine should be carefully chosen such that it can handle say, URLs and other special characters (maybe emojis) and not just plain text. If we do not make any assumptions about the internal composition of a message object, our message sending and receiving code will be as generic as possible.
Secondly, we need to foresee that we are going to add new message types in the future and hence, keep the scope of such changes in our design decisions. A very trivial way of doing that is having some extra fields in the message object that can support future changes:
enum MessageType {
TEXT,
PHOTO,
VIDEO;
}class Message {
MessageType type;
String messageData; //text, or URL
}In the above example, our message object has a type field which currently has only one possible value ‘TEXT’ but later, it can have new types like ‘PHOTO’ or ‘VIDEO’ as the type of a message. Now, in addition to the type field, we may need a data field which will hold a different meaning in case of different types of messages — in case of text messages, this data may be plain text, in case of a photo, this may be a URL of the photo. As the software evolves, we may need more data for a message type, instead of just a single String field — maybe we want to send a start time and end time when sharing a YouTube video URL as a message. In such cases, it will make sense to again encapsulate this data and maybe call it a new object type — MessageData or something like that:
class Message {
Long messageId;
MessageType messageType;
String senderId;
String receivedId;
MessageData messageData; //text, or URL
}abstract class MessageData {
}class TextMessageData extends MessageData {
String textMessage;
}class PhotoMessageData extends MessageData {
String imageDownloadUrl;
Integer width;
Integer height;
String imageCaption;
//add more params as needed
}class VideoMessageData extends MessageData {
String videoDownloadUrl;
Long videoStartOffsetMillis;
//more such params
}Thus, your message object may have some fields common to all types of messages — like messageID, messageType, senderID, receiverID etc. and the data that varies based on message type, will be encapsulated in a MessageData object (or interface) which can later be extended into PhotoMessageData or VideoMessageData etc. For the curious souls, this is the Strategy Pattern.
Also, adding a new message type should need minimal changes, maybe only to the message creation and message display routines. There are many design patterns like the Factory Pattern which specialize in encapsulating the creation of objects to ensure adding a new object type doesn’t disrupt other pieces of the codebase. The biggest advantage of this is not lesser lines of code changes, but lesser disruption and impact on existing pieces (especially the ones that are isolated from these changes because of our excellent encapsulation techniques) and hence, higher confidence on stability and lesser testing overhead whenever adding a new message type.
Pinch of Salt
In a real software development team, there will always be unprecedented feature requirements coming in from the product team — features that may break some fundamental invariants or assumptions that the developer initially had made, or features that need changes in some part of the code that the developer was sure would never need a change, or even feature changes that impact multiple modules of the software. Often, you will not be able to safeguard and plan against such disruptive changes because that kind of foresight doesn’t exist in the real world. And to be honest, if we start to assume that every piece of our software can evolve independently and hence, we start encapsulating (or isolating) even related concerns from each other, then the benefits of smart encapsulation get replaced by the problems of overuse of encapsulation.
Like everything else in real life, this is also a decision-making problem and one that needs you to find the right balance. With smart use of encapsulation (and its cousin Abstraction), many design problems and roadblocks can be avoided by planning ahead, and some can be handled easily even if not entirely avoided.
PS: If you liked the article, please support it with claps 👏 below. Cheers!