Deep Dive into SOLID Principles

Deep Dive into SOLID Principles
Photo by Bernard Hermant / Unsplash

This article is about principles that are likely to improve your overall architectural skills and knowledge. The article is supercharged with real world examples and the power of DART language! Let's go!


Uncle Bob, the author of SOLID

Good architecture

Most of us have wondered what good architecture is. The answers are complex, but it is easier to define what good architecture is by studying the symptoms and consequences of bad one. I assume that you, my reader, have had the chance to take part in the development of a commercial product or just a pet project. If you have ever felt the difficulty of adding a new feature to an existing application, or if your job is to fix the bugs and errors and they cause new ones, then you have probably faced rotting architecture.

As Uncle Bob stated, there are four main symptoms of a rotting architecture. They are Rigidity, Fragility, Viscosity and Immobility.

Rigidity

Our journey begins with rigidity, a symptom of a decaying architecture that's as uncomfortable as it sounds. Imagine software as a living entity. When it's young and fresh, it's flexible. Changes can be made with ease. However, as it ages and grows without proper architectural practices, rigidity sets in. A change in one area cascades into numerous mandatory modifications elsewhere. This results in an increased development time, which can be costly in terms of resources, money, and customer satisfaction.

Fragility

Next, we encounter fragility, the software's tendency to break in areas that seem unrelated to the initial change. This unpredictability instills fear in developers. They become hesitant to modify the code, leaving the software stagnant and out of touch with evolving user needs and market trends.

Immobility

Immobility paints a picture of a code base so tangled that even the most useful components can't be extricated for reuse elsewhere. Instead of benefiting from proven, effective components, developers must reinvent the wheel with each new project, leading to unnecessary resource expenditure.

Viscosity

Finally, we confront viscosity, which comes in two forms - environmental and design. Environmental viscosity arises when a slow or complicated development environment makes doing things correctly more challenging, like build processes for hours. Design viscosity, on the other hand, occurs when a design is difficult to work with, encouraging developers to take shortcuts, ultimately deteriorating the architecture.

So we can define a good architecture as one that is robust, clean, flexible and whose components and modules are reusable. It is an architecture that is easy to maintain and easy to add new features to. Keep reading if you want to understand how to achieve this.


Single responsibility principle (SRP)

Have you ever seen a module that does a lot of things, like a handyman? A typical and very popular example of this would be a god object. It consists of many, many unrelated things, which increases the coupling to monstrous values. If you are a Flutter developer, you may have noticed the GetX framework, which is itself a great example of a God object.

A simplified example of an SRP violation on a real framework. It combines both navigation logic and service locator logic, and this is not the end.

SRP definition

“A module should have one, and only one, reason to change.”

I think everyone has heard this statement somewhere. But it is a bit abstract. The software itself has a pretty simple goal: it should satisfy users by providing useful features, and stakeholders by making money. So our modules generally reflect the needs of these groups and they are that reasons to change. Let's call them actors. Now we can rephrase the statement:

“A module should reflect the needs of one, and only one actor.”

Perhaps, it is really a good idea to understand the principles by examing the consequences of their violations. Let's start from this simple example:

Two actors and a Washer class with washCar and washDish methods

The Washer class violates the SRP because it reflects the needs of two different actors: Dish Washer and Car Washer. In this way, the designers have created a high coupling, as changes in one method are likely to affect the well-being of another, and vice versa.

Accidental failures

Now imagine the situation where the car washer has decided to switch to a more modern soap, for example by passing an instance of another soap into the washer class. As a result, the dishwasher is affected because it is now using the soap that was designed to work well on cars, but not on dishes. One may notice that this was not the desired goal.

Merges

Suppose both the dishwasher and the car washer decide to change something in the logic. Although it happens often, it is not really nice to fix the conflicts in the code. It usually takes some time to fix a conflict, and it's good if you haven't introduced any bugs or errors into the code base.

Solution

The best idea here would be to refactor the washer class into two smaller ones, reflecting the needs of only those actors who need it:

Two actors which use those modules which reflect their needs

Real world example - User Service

There's another SRP violation example, but much closer to the real world:

User service depends on both Marketing Team and System user actors

The user service above serves for the needs of both marketing team and system user actors therefore violating the SRP as this class has two reasons to change. Basically, it is needed to create the NotificationService and move the sendNotification here.

Conclusion

The single responsibility principle is likely to be the most popular principle at all. At the same time, it is often confused because of unclear responsibilities. In fact, if you design your interfaces before you do any implementation or concrete work, it is much easier to see the big picture, and you are likely to notice the violations, or on the other hand, the principles.


Open-closed principle (OCP)

Bertrand Meyer, the author of OCP
“A software artifact should be open for extension but closed for modification.”

Put simply, the functionality of a software artifact should be capable of being expanded without requiring any changes to the artifact itself.

Consider this simple example:

It looks pretty neat and tidy at first. All the logic related to calculating squares is delegated to the SquareCalculator class, and we just use it to calculate the area of all the shapes we have. Ok, that's perfect. But let's dig a little deeper. If you look closely, you can see that SquareCalculator is directly dependent on the Shape class, changes in Shape (i.e. adding new shapes) cause changes in SquareCalculator. So, what should be done to fix this violation? Actually, Uncle Bob has formulated the rule for this:

"If component A should be protected from changes in component B, then component B should depend on component A" - Uncle Bob

This may sound unclear at first, but let me clarify and rephrase it a little. My version is:

If component A should be protected from changes in component B, then component B should depend on the needs of component A.

Let's apply this rule to our shape example. Actually, it is quite obvious that we need to protect the SquareCalculator component from changes in the Shape component. So the Shape component must depend on the needs of the SquareCalculator component. It's much clearer now, isn't it?

Basically, to make Shape depend on the needs of SquareCalculator, we need to move the calcSquare method to the Shape interface like this

Now the calcSquare is moved to the Shape, making it dependent on the needs of the SquareCalculator and conforming to the Open-Closed principle. Rephrasing, the SquareCalculator is closed for changes, but opened for extensions.

Banking application

Suppose we have a banking application that manages different types of bank accounts. For simplicity, let's assume we have two types of accounts: SavingsAccount and CurrentAccount. The business logic might look something like this:

abstract interface class Account {
  double get balance;
}

class SavingsAccount implements AccountType {
  double balance;

  SavingsAccount(this.balance);
}

class CurrentAccount implements AccountType {
  double balance;

  CurrentAccount(this.balance);
}

class Bank {
  List<Account> accounts = [];

  void addAccount(Account account) {
    accounts.add(account);
  }

  double calculateInterest() {
    double totalInterest = 0.0;

    for (var account in accounts) {
      if (account is SavingsAccount) {
        totalInterest += account.balance * 0.05;  // 5% interest for savings accounts
      } else if (account is CurrentAccount) {
        totalInterest += account.balance * 0.01;  // 1% interest for current accounts
      }
    }

    return totalInterest;
  }
}

In this case, the Bank class is responsible for calculating the interest for each type of account. When we add a new type of account, we would need to modify the Bank class, specifically the calculate_interest method, to accommodate the new account type. This is a violation of the Open-Closed Principle, which states that "software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification."

To comply with the OCP, we should push the responsibility of calculating interest to the Account class or to the specific account type classes. This way, when a new account type is added, we only need to ensure that the new account type class implements the interest calculation, with no need to modify existing code.

Real world example - Discord Bot

I suspect that many of us have had to deal with Discord and its bots. I also think that many of us have probably tried to write our own bot. Let's say we're working on a bot that answers !hello and !bye commands. How does all the communication take place? Actually, Discord has its WebSocket API for real-time communication. This is how it sends new messages to the connected client.

Obviously, the commands are sent in a serialised way (in jsons) and it is necessary to somehow decode them into a recognisable format and process them. Firstly, we can come up with an idea like this:

// Let socket be something like this
abstract interface class Socket {
  void add(String data);
}

class CommandHandler {
  CommandHandler(this.socket);
  final Socket socket;
  void decodeCommand(Map<String, Object?> json) {
    if (json['command'] == '!hello') {
      hello();
    } else if (json['command'] == '!bye') {
      bye();
    }
  }

  void hello() => socket.send('hello!');

  void bye() => socket.send('bye!');
}

We created a CommandHandler class here which is responsible for decoding and reacting to commands. Guess, how does this class violate the OCP?

In fact, it is a great example of an OCP violation. This class also violates the SRP by making the class responsible for decoding and responding to commands. This way it will cause changes if the format of messages changes or the response changes. Then, if you want to add a new command, you would have to add another if clause and implement another method.

CommandHandler gets bigger and bigger as new commands are added. So how do you fix this? The solution may not be immediate, as this is a rather complicated case. First, I'd like to apply this rule:

If component A should be protected from changes in component B, then component B should depend on the needs of component A.

The CommandHandler needs to be protected from changes in Command (i.e. adding new commands). Then it is necessary to make Command dependent on the needs of CommandHandler. Basically, we can do this by introducing the Command interface with an execute method that will somehow react to the Command. I'd also like to introduce a new structure for the command data and move the decoding logic to another class. Let's go 😎

Firstly, let's create the CommandData and implement the decoding logic.

class CommandData {
  const CommandData(this.name, this.args);

  final String name;
  final List<String> args;

  static CommandData decode(String data) {
    // some logic of decoding here
  }
}

So we have moved the decoding responsibility to the CommandData class. Now let's create Command and factories whose job it is to instantiate concrete command instances.

abstract interface class CommandFactory {
  Command createCommand(CommandData data);
}

final class HelloCommandFactory implements CommandFactory {
  HelloCommandFactory(this.socket);

  final Socket socket;

  @override
  Command createCommand(CommandData data) => HelloCommand(socket);
}

final class ByeCommandFactory implements CommandFactory {
  ByeCommandFactory(this.socket);

  final Socket socket;

  @override
  Command createCommand(CommandData data) => ByeCommand(socket);
}

abstract interface class Command {
  void execute();
}

final class HelloCommand implements Command {
  HelloCommand(this.socket);

  final Socket socket;

  @override
  void execute() => socket.add('Hello');
}

final class ByeCommand implements Command {
  ByeCommand(this.socket);

  final Socket socket;

  @override
  void execute() => socket.add('Bye');
}

It is time to refactor the handler. We need to somehow match the command name to the factory. I'll use map for this. The handler's job is to get the right factory for the message and inject the necessary dependencies into it. Here's how

class CommandHandler {
  CommandHandler();

  final _factories = <String, CommandFactory>{};

  // Method that is used to register a factory
  void registerCommandFactory(String commandName, CommandFactory factory) {
    factories.put(commandName, factory);
  }

  void handle(CommandData data) {
    final factory = _factories[data.name];
    if (factory == null) {
      throw UnsupportedError('No factory for ${data.name} found!');
    }

    final command = factory.createCommand(data);

    command.execute();
  }
}

final handler = CommandHandler();
handler.registerCommandFactory('hello', HelloCommandFactory(socket));
handler.registerCommandFactory('bye', ByeCommandFactory(socket));

By doing so, we made CommandHandler conform to OCP and SRP. It is closed for changes, but opened for extensions. Let's see how to add a new command !me that will send the data about user:

final class MeCommandFactory implements CommandFactory {
  MeCommandFactory(this.socket, this.userRepository);

  final Socket socket;
  final UserRepository userRepository;

  @override
  Command createCommand(CommandData data) => MeCommand(socket, userRepository);
}

final class MeCommand implements Command {
  MeCommand(this.socket, this.userRepository);

  final Socket socket;
  final UserRepository userRepository;
  final String userId;

  @override
  void execute() {
    final me = userRepository.get(userId);
    socket.add('$me');
  }
}

// then add UserRepository dependency to command handler
// and add me to factories map
class CommandHandler {
  CommandHandler();

  final _factories = <String, CommandFactory>{};

  void handle(CommandData data) {
    final factory = _factories[data.name];
    if (factory == null) {
      throw UnsupportedError('No factory for ${data.name} found!');
    }

    final command = factory.createCommand(data);

    command.execute();
  }
}
final handler = CommandHandler();
handler.registerCommandFactory('hello', HelloCommandFactory(socket));
handler.registerCommandFactory('bye', ByeCommandFactory(socket));
handler.registerCommandFactory('me', MeCommandFactory(socket, userRepository));

It was quite a complicated example, but very close to the real world.

Conclusion

Open-Closed Principle encourages a more modular software design by applying SRP and DIP principles and contributes to the longevity and scalability of a system, making it an integral part of the SOLID principles in object-oriented programming and design. Its main objective is to reduce the number of module changes to a minimum, ideally zero.


Liskov Substitution Principle (LSP)

Barbara Liskov, who introduced LSP
"If a program is using a Base class, it should be able to use any Sub class without the program knowing it."

In other words, in a well-designed object-oriented system, one should be able to replace any instance of a parent class with an instance of one of its subclasses without altering the correctness of the program. This principle is named after Barbara Liskov who introduced it in a 1987 conference keynote.

Preconditions cannot be strengthened in a subclass: A precondition is a condition or predicate that must always be true just prior to the execution of some section of code or before an operation in a formal specification. If a subclass makes preconditions more restrictive, it means that the subclass might not accept all inputs that the superclass does, violating the Liskov Substitution Principle.

For example, let's say we have a Bird class with a method fly(height), where height is a non-negative integer. Now, if we have a subclass Penguin and it strengthens the precondition of fly(height) method by saying that height must be zero (because penguins can't fly), then it violates the LSP because it's not possible to substitute a Penguin for a Bird without changing the behavior of the program.

Postconditions cannot be weakened in a subclass: A postcondition is a condition or predicate that must always be true just after the execution of some section of code or after an operation in a formal specification. If a subclass weakens postconditions, it means that it might not produce the same results as the superclass for the same inputs, also violating the Liskov Substitution Principle.

As an example, suppose the Bird class has a method eat(food), which postconditions that the Bird is no longer hungry. If a subclass Duck changes this postcondition by making the Duck still hungry after calling eat(food), then it's not possible to replace a Bird object with a Duck object without changing the behavior of the program.

Invariants of the superclass must be preserved in a subclass: An invariant is a condition that can be relied upon to be true during the execution of a program. If a subclass changes these invariants, it means that it could behave differently than the superclass in a way that users of the superclass wouldn't expect, once again violating the Liskov Substitution Principle.

For instance, imagine a Car class with an invariant that the car's speed is always non-negative. If a subclass TimeMachine doesn't preserve this invariant and allows for negative speeds (to go back in time, for example), then replacing a Car with a TimeMachine could lead to unexpected behaviors.

I'll use a simple Animal hierarchy to illustrate Liskov Substitution Principle (LSP) in Dart.

First, let's define an abstract class Animal with a method makeSound():

abstract class Animal { void makeSound(); }

Next, let's define a few subclasses that inherit from Animal: Dog and Cat.

class Dog extends Animal {
  @override
  void makeSound() {
    print('Woof!');
  }
}

class Cat extends Animal {
  @override
  void makeSound() {
    print('Meow!');
  }
}

Now let's create a function that operates on the Animal base class, but it can accept any of the subclasses:

void letAnimalMakeSound(Animal animal) => animal.makeSound();

Finally, we can test this out:

void main() {
  Animal myDog = Dog();
  Animal myCat = Cat();

  letAnimalMakeSound(myDog);  // prints: Woof!
  letAnimalMakeSound(myCat);  // prints: Meow!
}

This example conforms to LSP because the letAnimalMakeSound() function can accept any object of type Animal or any of its subclasses. The base class can be substituted with any of its subclasses without changing the correctness of the program, because any Animal subclass will correctly implement the makeSound() method.

Real world example - Game

Imagine a game with two types of weapon: swords and bows.

abstract class Weapon {
  void use();
}

class Sword extends Weapon {
  @override
  void use() {
    print('Swing sword!');
  }
}

class Bow extends Weapon {
  @override
  void use() {
    print('Shoot arrow!');
  }

  void loadArrow() {
    print('Loading arrow...');
  }
}

void main() {
  Weapon weapon = getRandomWeapon();
  // This should work for any weapon. But if the weapon is a Bow and it's not loaded, it won't work correctly.
  weapon.use();
}

Weapon getRandomWeapon() {
  return (DateTime.now().millisecondsSinceEpoch % 2 == 0) ? Sword() : Bow();
}

In the code above, the base Weapon class and the Sword subclass have a use method. However, the Bow subclass has an additional method, loadArrow, which must be called before use. If we have a method that uses a Weapon object and calls use, it will work fine for Sword, but not for Bow if loadArrow has not been called before.

So it violates the Liskov substitution principle, because the base class Weapon doesn't guarantee that all subclasses can "use" the weapon immediately after instantiation, it's precondition strengthening. It may be more appropriate to include a prepare method in the base Weapon class, which can be overridden in subclasses to ensure that any necessary preparation steps are performed. For the Sword class, prepare could do nothing, while for the Bow class it could do arrow loading. This would ensure that all weapons could be used interchangeably without unexpected behaviour.

LSP Violations

Violation of the Liskov Substitution Principle can lead to several issues:

  1. Unexpected Behavior: If a subclass behaves differently from what the code using the superclass expects, it can lead to unexpected and incorrect results. This is because the code using the superclass is written with certain assumptions about the behavior of the superclass's methods.
  2. Increased Complexity: Violating LSP can lead to increased complexity in the code. If a subclass cannot be substituted for a superclass without changing the behavior, then the code using these classes needs to have checks to handle the different subclasses differently. This increases the complexity of the code and makes it harder to maintain.
  3. Fragility: Violating LSP can make the code more fragile. If a new subclass is added, the code using the superclass may need to be updated to handle the new subclass. This can lead to bugs if not done correctly.

Prevent LSP violations

  1. Design with LSP in Mind: When designing your classes and inheritance hierarchy, keep the Liskov Substitution Principle in mind. Make sure that any subclass can be used wherever the superclass is used without changing the behavior.
  2. Use Interface Segregation: If a subclass doesn't need all the methods of the superclass, consider using interfaces instead. This way, the subclass only needs to implement the methods it actually uses.
  3. Follow the Contract: Ensure that subclasses do not violate the "contract" of the superclass. This means that they should not only have the same method signatures, but also the same behavior. If a method in the superclass guarantees certain post-conditions, the subclass should also guarantee the same post-conditions.

Interface Segregation Principle (ISP)

The Interface Segregation Principle (ISP) is a crucial part of the SOLID principles of object-oriented programming and design. It encourages developers to split larger interfaces into smaller, more specific ones, ensuring that implementing classes only need to worry about the methods and properties relevant to them.

The ISP states that no client should be forced to depend on interfaces that they don't use. This encourages us to keep our system modular and our classes decoupled, resulting in more maintainable and flexible software.

Example of Violating ISP

Let's start with an example that violates the Interface Segregation Principle.

Suppose we have a Worker class, representing a worker in a company:

class Worker { 
	void work() {} 
    void eat() {} 
}

Now, we have HumanWorker and RobotWorker classes which implement the Worker interface.

class HumanWorker implements Worker { 
	@override 
    void work() { 
    	print('Human working'); 
    }
    
    @override 
    void eat() { 
    	print('Human eating'); 
    } 
} 
class RobotWorker implements Worker { 
	@override 
    void work() { 
    	print('Robot working'); 
    } 

    @override 
    void eat() { 
    // Robots don't eat 
    } 
}

In the example above, the RobotWorker class is forced to implement the eat() method, which it doesn't use. This violates the Interface Segregation Principle.

Applying ISP

To adhere to the ISP, we can create two separate classes, Workable and Eatable, which will serve as our interfaces:

class Workable { 
	void work() {}
} 

class Eatable { 
	void eat() {}
}

Then, we can have HumanWorker implement both Workable and Eatable, and RobotWorker implement only Workable:

class HumanWorker implements Workable, Eatable { 
	@override 
    void work() { 
    	print('Human working'); 
    } 
    
    @override 
    void eat() { 
    	print('Human eating'); 
    } 
} 

class RobotWorker implements Workable { 
	@override 
    void work() { 
    	print('Robot working'); 
    } 
}

With this setup, the RobotWorker is no longer forced to implement an eat() method, and our interface design now adheres to the Interface Segregation Principle. By splitting our interfaces, we have created a more flexible, adaptable, and maintainable system.

ISP Printer & Scanner example

abstract class AllInOnePrinter {
  void print();
  void scan();
}

However, this violates the Interface Segregation Principle because a printer that only supports printing would still have to implement the scan method.

To adhere to the ISP, we segregate the interfaces like so:

abstract class Printer {
  void print();
}

abstract class Scanner {
  void scan();
}

Now, a printer that can only print implements the Printer interface, and a printer that can scan implements the Scanner interface. If a printer can do both, it implements both interfaces:

class JustPrinter implements Printer {
  @override
  void print() {
    // implementation here
  }
}

class JustScanner implements Scanner {
  @override
  void scan() {
    // implementation here
  }
}

class AllInOne implements Printer, Scanner {
  @override
  void print() {
    // implementation here
  }

  @override
  void scan() {
    // implementation here
  }
}

In this way, we've correctly applied the Interface Segregation Principle to our Dart code, leading to more flexible and maintainable code.

Conclusion

As you can see, the ISP has a lot in common with SRP. It is really exciting to see how all these principles are interconnected.

To summarise, we can't force clients to depend on things they don't really need, as this drastically reduces cohesion. Often, by violating ISP, other principles are violated as well.


Dependency Inversion Principle (DIP)

The dependency inversion principle states that

High-level modules should not depend on low-level modules. Both should depend on abstractions

You may have noticed that we have already applied this principle to achieve the goal of OCP (module closed for changes, but open for extensions) by inverting module dependencies (making them dependent on abstractions). Remember:

If component A should be protected from changes in component B, then component B should depend on the needs of component A.

So, what's the purpose of DIP? Let's examine it:

Reader class is directly dependent on Book class

What's wrong with this picture? Well, first of all, the Reader class depends on the Book class. Book is a concrete class, not an interface, so Reader only works with it or its derivatives. Actually, everything is fine until Article is introduced. Now we have another readable entity and we want it to be readable too, but our Reader only works with Book. This is the violation of DIP. Guess which principle is also violated?

Answer

OCP. As Reader module must be changed in order to extend the functionality.

The solution comes quickly. It is needed to introduce Readable class and derive Book and Article from it. Take Readable instead of the book into reader.

Book and Article depend on Readable. Reader takes Readable as an argument.

Real-world example - Logger

Imagine our application has a dependency called super_duper_logger. It has a global variable named logger and a bunch of useful methods. This is quite usual, isn't it? Now, imagine we decided to switch to even better alternative. What changes should be done in order to migrate the app to new logger? Well, we will probably need to change all logger callers and the initialization logic.

Obviously, this is a breaking change that affects the entire application. But wait, what if I told you that this migration could have been done with minimal effort? Believe me, it's possible!

Basically, the app depends on super_duper_logger API, thefore violating the DIP as this dependency is not reverted. I suppose you've already got me. It is needed to create an interface like:

abstract interface class ILogger {
	log(Object data, [StackTrace? s]);
}

And create concrete implementations like so (just an example):

class SuperDuperLogger implements ILogger {
	@override
	log(Object data, [StackTrace? s]) {
    	superDuperLog(data, stackTrace: s);
    }
}

class EvenBetterLogger implements ILogger {
	@override
	log(Object data, [StackTrace? s]) {
    	superDuperLog(data.toString(), stackTrace: s);
    }
}

Now, create a global variable or inject logger dependency in your preferred way using the interface ILogger. So, what is needed now to switch to EvenBetterLogger? Right! Just change the value of the variable you use for logger. As our derives conform to the ILogger interface, they could be easily substituted.

Conclusion

In conclusion, the SOLID principles provide a robust framework for designing and organizing code in an efficient and maintainable manner. They promote the development of systems that are easier to understand, manage, and extend, making them an indispensable part of modern software engineering.

  • The Single Responsibility Principle (SRP) encourages the separation of concerns by ensuring that a class should have only one reason to change. This increases the cohesion and reduces the complexity of the system.
  • The Open-Closed Principle (OCP) promotes software entities to be open for extension but closed for modification. This enables us to add new features or change existing behavior without altering or breaking existing code.
  • The Liskov Substitution Principle (LSP) ensures that any child type can substitute their parent types without affecting the correctness of the program. This improves the substitutability and enhances reusability.
  • The Interface Segregation Principle (ISP) advises that clients should not be forced to depend on interfaces they do not use. This principle helps to create a system that is decoupled and thus easier to refactor, change, and deploy.
  • The Dependency Inversion Principle (DIP) states that high-level modules should not depend on low-level modules but should depend on abstractions. This principle leads to a system that is more decoupled and thus easier to change, maintain, and test.

By adhering to the SOLID principles, we can build software that is more understandable, flexible, and maintainable. While they might require additional effort in the initial stages of development, the long-term benefits in terms of reduced bugs, improved extensibility, and easier maintenance make the investment worthwhile.