"O" so SOLID

Apr 9, 2025

The 'O' in SOLID stands for the Open/Closed Principle. This principle suggests that software entities (classes, modules, functions, etc.) should be open for extension but closed for modification. In other words, you should be able to add new functionality without changing existing code.

Rewiring a remote control

A real-world analogy is a universal remote. You can program it to control new devices without altering its internal circuitry—just by pressing a few buttons. Similarly, well-designed code allows for new features to be added with minimal changes to existing structures.

What does this mean in practice?

The concept means writing code in a way which allows for the functionality to be extended without changing the actual underlying code. This can be done with config objects, maps etc passed as arguments (or by use of hooks in React's case) to internal logic.

Take the following code:

1function sendNotification(type, message) {
2  if (type === 'email') {
3    console.log(`Sending EMAIL with message: ${message}`);
4  } else if (type === 'sms') {
5    console.log(`Sending SMS with message: ${message}`);
6  } else if (type === 'slack') {
7    console.log(`Sending SLACK message: ${message}`);
8  } else {
9    console.log('Unknown notification type');
10  }
11}
12
13// Usage
14sendNotification('email', 'Hello via Email!');
15sendNotification('sms', 'Hello via SMS!');

This code violates the Open/Closed Principle because adding support for a new notification service requires modifying the existing sendNotification function. Each time we add a new type, we have to edit and retest the function, increasing the chances of introducing bugs and making the code harder to maintain over time.

Code that doesn't follow OCP often includes large if or switch statements to handle multiple cases. These conditionals grow as new types are added, making the logic more complex and less scalable.

Instead, sendNotification should delegate responsibility to objects that follow a common interface, allowing the logic to be extended through new implementations without changing the function itself:

1class Notifier {
2  send(message) {
3    throw new Error('send() must be implemented by subclass');
4  }
5}
6
7class EmailNotifier extends Notifier {
8  send(message) {
9    console.log(`Sending EMAIL with message: ${message}`);
10  }
11}
12
13class SMSNotifier extends Notifier {
14  send(message) {
15    console.log(`Sending SMS with message: ${message}`);
16  }
17}
18
19const notificationProviders = {
20  email: new EmailNotifier(),
21  sms: new SMSNotifier(),
22};
23
24function sendNotification(type, message) {
25  const provider = notificationProviders[type];
26  if (provider) {
27    provider.send(message);
28  } else {
29    console.log('Unknown notification type');
30  }
31}
32
33// Usage
34sendNotification('email', 'Hello via Email!');
35sendNotification('sms', 'Hello via SMS!');
36

Now the internal logic for sendNotification is generic. Instead of embedding specific behavior within the function, it delegates the responsibility to external provider classes. This means that to support a new notification type, we only need to create a new class that implements the same interface and register it in the notificationProviders map.

If we want to add a new provider, it's simply a case of creating one and adding it to the notificationProviders object:

1class SlackNotifier extends Notifier {
2  send(message) {
3    console.log(`Sending SLACK with message: ${message}`);
4  }
5}
6
7const notificationProviders = {
8  email: new EmailNotifier(),
9  sms: new SMSNotifier(),
10  slack: new SlackNotifier()
11};
12
13
14sendNotification('slack', 'Hello via SLACK!');

Why is this useful?

As a result, the sendNotification function remains untouched, even as we introduce new functionality. This separation of concerns makes the codebase more modular, testable, and maintainable. Developers can safely extend functionality without risking regressions in existing code, which is the essence of the Open/Closed Principle.

React specific example

We can apply the Open/Closed Principle in React by using custom hooks and components that encapsulate behavior, making it easy to extend functionality without modifying core logic.

For example, we can create a useNotifier hook that returns an appropriate notifier object based on internal logic or props, and a NotificationSender component that uses this hook to send messages:

1import { useMemo } from 'react';
2
3class Notifier {
4  send(message) {
5    throw new Error('send() must be implemented by subclass');
6  }
7}
8
9class EmailNotifier extends Notifier {
10  send(message) {
11    console.log(`Sending EMAIL with message: ${message}`);
12  }
13}
14
15class SMSNotifier extends Notifier {
16  send(message) {
17    console.log(`Sending SMS with message: ${message}`);
18  }
19}
20
21class SlackNotifier extends Notifier {
22  send(message) {
23    console.log(`Sending SLACK with message: ${message}`);
24  }
25}
26
27const useNotifier = (type) => {
28  return useMemo(() => {
29    const notifiers = {
30      email: new EmailNotifier(),
31      sms: new SMSNotifier(),
32      slack: new SlackNotifier(),
33    };
34    return notifiers[type];
35  }, [type]);
36};
37
38function NotificationSender({ message, type = "email", }) {
39  const notifier = useNotifier(type);
40
41  if (notifier) {
42    notifier.send(message);
43  } else {
44    console.log('Unknown notification type');
45  }
46
47  return null;
48}

This allows us to add new notifier types without modifying NotificationSender logic, staying true to OCP. Instead, we create new notifier classes and optionally extend the mapping inside the hook if needed.

Use cases

The Open/Closed Principle is beneficial in various scenarios:

  • Payment Processing: Supporting multiple payment gateways without altering the core transaction logic.
  • Form Validation: Implementing different validation strategies without modifying the form component.
  • Logging: Adding new logging mechanisms (e.g., file, console, remote server) without changing the existing logging interface.

How OCP Fits Into SOLID

The Open/Closed Principle is the second principle in the SOLID acronym and plays a central role in creating flexible and maintainable software architecture. It complements the other principles by encouraging developers to write code that can adapt to future requirements without destabilising existing functionality.

When combined with the Single Responsibility Principle (SRP), OCP ensures that classes have narrowly defined purposes and can be extended independently. For example, by creating a new notifier class instead of modifying an existing one, you respect both SRP and OCP.

OCP also naturally encourages the use of abstraction, which ties closely into the Liskov Substitution Principle (LSP) and the Dependency Inversion Principle (DIP). Through interfaces or base classes, you can swap or inject new behaviors without affecting dependent components. In this way, OCP is not just a standalone idea—it interlocks with the rest of SOLID to support clean, robust design.

Potential Downsides or Cautions

While OCP promotes extensibility, overusing it can lead to:

  • Over-Engineering: Introducing unnecessary abstractions that complicate the codebase.
  • Configuration Overload: Managing numerous configurations can become cumbersome.
  • Reduced Readability: Excessive use of patterns may make the code harder to understand for new developers.

TL;DR: The Open/Closed Principle helps keep code extensible and maintainable. But don’t over-engineer. Abstract only when needed, and keep your architecture easy to understand and evolve.