Preparing Your Code for Inevitable Change 

Software development is no stranger to change. With technology evolving and client requirements shifting at a breakneck speed, an application’s codebase can never be regarded as ‘done.’  

Every developer, regardless of language or platform, should have the ability to write adaptable and maintainable code. Why, you may ask? 

Change influences every aspect of the software lifecycle.

This compels us to modify not only the codebase, database schemas, and system architecture but also our development practices.  

Understanding change in this light helps us see it as an opportunity for improvement and growth rather than a challenge. It also underscores the importance of building software that is robust, yet flexible enough to adapt to new user demands, market trends, legislative changes, and technological advancements. 

That said, change can become a hurdle when dealing with rigid code structures.  

These structures often violate key principles like the Open/Closed Principle (OCP), part of the SOLID framework. This principle dictates that “software entities should be open for extension but closed for modification.” 

Violations lead to a domino effect of code modifications, raising the risk of bugs, complicating maintenance, and slowing adaptation to user or market demands. Thus, the inflexibility of the code magnifies the complexity of implementing changes, transforming a potentially straightforward task into a major hurdle. 

Let’s go through some of examples of code challenged by change. 

Suppose we have a code solution that only supports email notifications. The code might look something like this: 

public class Notification { 
    private String email; 
    private String subject; 
    private String message; 
 
    // Constructors, getters, setters are omitted for brevity... 
 
    public void sendEmail() { 
        System.out.println("Sending email to: " + email); 
        // logic to send email... 
    } 
} 
 
public class User { 
    private String name; 
    private Notification notification; 
 
    // Constructors, getters, setters are omitted for brevity... 
 
    public void notifyUser() { 
        notification.sendEmail(); 
    } 
} 

In this example, the User class is tightly coupled with the Notification class, and the Notification class is rigid as it only supports email notifications. 

Now, let’s say the requirement changes, and we need to support SMS notifications in addition to email. This change would require us to modify our existing Notification class (and hence violate the OCP) or create another class like SMSNotification and then, modify the User class to handle the new type of notification, which leads to a lot of changes in our existing codebase. 

Here is another example of a DrawShape class: 

public class DrawShape { 
    private String type; 
    public DrawShape(String type) { 
        this.type = type; 
    } 
    public void draw() { 
        if (type.equals("circle")) { 
            drawCircle(); 
        } else if (type.equals("rectangle")) { 
            drawRectangle(); 
        } else { 
            throw new IllegalArgumentException("Invalid shape type: " + type); 
        } 
    } 
    private void drawCircle() { 
        // Code to draw a circle 
    } 
    private void drawRectangle() { 
        // Code to draw a rectangle 
    } 
} 

At first, updating this code might seem simple, but actually, every time we want to add a new shape we must come back and modify this class. Therefore, this is rigid code that is hard to maintain, and violates the OCP. 

Design patterns represent time-tested solutions to recurring design problems in software engineering. 

These patterns serve as blueprints for solutions that can be tailored to fit various situations, enabling developers to sidestep common pitfalls and enhance their design process efficiency.  

They’re categorised into three types: 

Creational: These create objects in a manner suitable to the situation. Common examples include Singleton, Factory Method, Abstract Factory, Prototype, and Builder. 

Structural: These help ensure that the different parts of a system fit together well. Common examples include Adapter, Decorator, Proxy, Composite, Bridge, and Facade. 

Behavioral: These are about communication between objects, how they interact and distribute functionality. Common examples include Observer, Strategy, Template Method, Iterator, and Command. 

Let’s apply them to previous code examples. 

As part of the behavioral category, the Decorator pattern is a structural design pattern that allows adding new behaviours to objects dynamically by placing them inside special wrapper objects which are called ‘decorators’. 

They provide an alternative to subclassing for extending functionality. With the Decorator pattern, we can add or remove responsibilities from an object at runtime, which we can’t do with inheritance. 

To refactor the Notifications code in the previous code example to use the Decorator pattern, we will start by defining an interface that both the original Notification and the new NotificationDecorator classes will implement. Here’s how it could look: 

public interface Notifiable { 
 
    void sendNotification(); 
 
} 
 
 
public class EmailNotification implements Notifiable { 
 
    private String email; 
    private String subject; 
    private String message; 
 
    // Constructors, getters, setters are omitted for brevity...  
   public void sendNotification() { 
 
        System.out.println("Sending email to: " + email); 
        // logic to send email...  
   } 
 
} 
 
 
 
public abstract class NotificationDecorator implements Notifiable { 
 
    protected Notifiable decoratedNotifiable; 
 
    public NotificationDecorator(Notifiable notifiable) { 
        this.decoratedNotifiable = notifiable; 
 
    } 
 
 
    public void sendNotification() { 
        decoratedNotifiable.sendNotification(); 
    } 
 
}

Now, we can create decorator classes for different types of notifications. For example, let’s create an SMSNotificationDecorator

public class SMSNotificationDecorator extends NotificationDecorator { 
 
    public SMSNotificationDecorator(Notifiable notifiable) { 
 
        super(notifiable); 
 
    } 
 
 
 
    public void sendNotification() { 
 
        super.sendNotification(); 
 
        sendSMS(); 
 
    } 
 
 
 
    private void sendSMS() { 
 
        System.out.println("Sending SMS notification..."); 
 
        // logic to send SMS...  
 
   } 
 
} 

Now the User class can use any Notifiable object to notify the user:

public class User { 
 
    private String name; 
 
    private Notifiable notifiable; 
 
 
 
    // Constructors, getters, setters are omitted for brevity...  
 
 
 
   public void notifyUser() { 
 
        notifiable.sendNotification(); 
 
    } 
 
} 

And you can decorate Notifiable objects with additional behaviours like this: 

public static void main(String[] args) { 
 
    Notifiable emailNotification = new EmailNotification(); 
 
    Notifiable emailAndSmsNotification = new       SMSNotificationDecorator(emailNotification);  
 
    User user = new User("John Doe", emailAndSmsNotification); 
 
    user.notifyUser(); 
 
} 

In this example, User.notifyUser() will first send an email and then an SMS, demonstrating how the Decorator pattern allows adding behaviours to objects dynamically. 

Now, let’s explore the strategy pattern. 

In the Shape example, the DrawShape class is responsible for both choosing which shape to draw and how to draw it. We can use the Strategy pattern to make this design more flexible. Here’s how we can do it: 

First, define a Shape interface with a draw method: 

 public interface Shape { 
 
    void draw(); 
 
} 

Then, implement this interface for each shape: 

public class Circle implements Shape { 
 
    public void draw() { 
 
        // Code to draw a circle  
 
       System.out.println("Drawing a circle"); 
 
    } 
 
} 
 
 
 
public class Rectangle implements Shape { 
 
    public void draw() { 
 
        // Code to draw a rectangle  
 
       System.out.println("Drawing a rectangle"); 
 
    } 
 
} 

Now, we can modify the DrawShape class to use a Shape instead of deciding itself which shape to draw: 

public class DrawShape { 
 
    private Shape shape; 
 
 
 
    public DrawShape(Shape shape) { 
 
        this.shape = shape; 
 
    } 
 
 
 
    public void draw() { 
 
        shape.draw(); 
 
    } 
 
} 

With this design, we can easily add new shapes without modifying the DrawShape class, just by creating a new class that implements the Shape interface. Here’s how we can use the DrawShape class with different shapes: 

public static void main(String[] args) { 
 
    DrawShape drawer = new DrawShape(new Circle()); 
 
    drawer.draw();  // Outputs: Drawing a circle  
 
    drawer = new DrawShape(new Rectangle()); 
 
    drawer.draw();  // Outputs: Drawing a rectangle  
 
}

Experienced designers balance the need for adaptability with the risks of over-engineering. 

They achieve this by focusing on the most probable changes, informed by research, experience, and common sense, recognizing that some adaptations can only be made in response to actual changes.  

What we want to leave you with is an understanding that embracing change is an inherent aspect of software development. It’s about recognising the challenges of rigid code structures and the importance of adhering to principles like SOLID and the effective use of design patterns. The examples of the Decorator and Strategy patterns showcased how they turn rigid code into flexible, easy-to-change code that adheres to the OCP. These techniques not only make your code more adaptable to change but also make it more readable, maintainable, and testable. 

If you found this article useful, you should check out my article on the SRP (Single Responsibility Principle)

Scroll to Top