Skip to main content

From Good to Great: How Adopting SOLID Principles Can Save Your Flutter App from Disaster

From Good to Great: How Adopting SOLID Principles Can Save Your Flutter App from Disaster

Imagine you’re building a house. You could slap some walls together, toss on a roof, and call it a day. Sure, it might stand for a while, but what happens when a storm hits? That’s where the SOLID principles come in — they’re like the architectural blueprint for your code, ensuring it’s not just sturdy, but resilient.

Think about it: would you rather have a house built on a shaky foundation, ready to collapse at the slightest breeze, or one with a solid base, capable of weathering any storm? The same goes for your code. So, in this article, we will be learning about SOLID Principles which will help you to do that. Sure, following the five SOLID principles might take a bit more time and effort upfront, but in the end, you’ll have a codebase that’s flexible, adaptable, and built to last for your grandchildren to see. So, whether you’re constructing a skyscraper or creating your application, remember: strong foundations lead to stronger structures. With that understood, let’s get into it and learn about the S in SOLID principle which stands for Single Responsibility Principle.


This article has been created in collaboration with Rivaan Ranawat

You can use this article alone to learn the SOLID Principle or use it to read along while watching his video about the SOLID principle

Check out his YouTube channel for more high-quality Flutter content!

S — Single Responsibility Principle

The Single Responsibility Principle says, “A class should have only one reason to change”. You could also say “Gather together the things that change for the same reasons. Separate those things that change for different reasons.”

At first sight, this definition seems straightforward, but if you think about it, it can become a bit confusing. That’s because “a reason to change” can be different for different people.

Let’s use user management as an example:

class UserManager {
  // Authentication logic
  bool authenticateUser(String username, String password) {
    // Logic to authenticate user
    return true; // Simulated success for the example
  }

  // Profile management logic
  void updateUserProfile(String username, Map<String, dynamic> profileData) {
    // Logic to update user profile
    print('User profile updated for $username');
  }
}

Without the Single Responsibility Principle, you might think this code is completely fine.

However, this is a violation of the SRP.

Why?

This class combines authentication and user profile management. These can be considered different responsibilities because they might change for different reasons:

  1. Authentication Logic: This might change due to security updates, changes in authentication protocols, or improvements in the authentication process.

  2. Profile Management Logic: This might change due to updates in the user profile structure, changes in data storage mechanisms, or improvements in the profile management process.

So, to write code that follows the single responsibility principle, you can do this:

// Authentication manager class responsible for authentication logic
class AuthManager {
  bool authenticateUser(String username, String password) {
    // Logic to authenticate user
    return true; // Simulated success for the example
  }
}

// Profile manager class responsible for user profile management logic
class ProfileManager {
  void updateUserProfile(String username, Map<String, dynamic> profileData) {
    // Logic to update user profile
    print('User profile updated for $username');
  }
}

Remember, It is people who request changes. And you don’t want to confuse those people, or yourself, by mixing the code that many different people care about for different reasons. Keep this in mind before making any decision related to the single responsibility principle.

To fully understand the Single Responsibility Pattern, let’s look at a second example:

Consider a class that compiles and prints a report. This class can be changed for two reasons. First, the content of the report could change. Second, the format of the report could change. These two things change for different causes. The single responsibility principle says that these two aspects of the problem are really two separate responsibilities, and should, therefore, be in separate classes or modules. It would be a bad design to couple two things that change for different reasons at different times.

// Bad version where content and format are handled in the same class
class BadReport {
  String generateAndFormatReport() {
    // Logic to generate and format the report
    String content = 'Report Content';
    String formattedReport = 'Formatted Report: $content';

    return formattedReport;
  }

  void compileAndPrint() {
    String formattedReport = generateAndFormatReport();

    print(formattedReport);
  }
}

void main() {
  // Create an instance of the bad report class
  BadReport myBadReport = BadReport();

  // Compile and print the report
  myBadReport.compileAndPrint();
}

This is how the clean code would look like:

// Content class responsible for generating report content
class ReportContent {
  String generateContent() {
    // Logic to generate report content
    return 'Report Content';
  }
// additional methods helpful to generate content of the report
}

// Format class responsible for formatting the report
class ReportFormat {
  String format(String content) {
    // Logic to format the report
    return 'Formatted Report: $content';
  }
}
// additional methods helpful to format the report

// Report class that composes content and format
class Report {
  final ReportContent _content;
  final ReportFormat _format;

  Report(this._content, this._format);

  String compile() {
    // Generate report content
    String content = _content.generateContent();

    // Format the report
    String formattedReport = _format.format(content);

    return formattedReport;
  }
}

void main() {
  // Create instances of content and format classes
  ReportContent reportContent = ReportContent();
  ReportFormat reportFormat = ReportFormat();

  // Create a report instance with content and format
  Report myReport = Report(reportContent, reportFormat);

  // Compile the report
  String compiledReport = myReport.compile();

  // Print the report
  print(compiledReport);
}

Now… what are the benefits of using the Single Responsibility Principle?

  1. Make your code easier to understand: By having a single responsibility for each component, you can name your components more clearly and consistently, which improves readability and communication. For example, instead of having a class called User that handles authentication, authorization, logging, and profile management, you can have separate classes for each of these concerns, such as AuthService, Logger, and ProfileService. These classes are more likely to be reusable in different contexts, as they are designed to perform a specific, well-defined task.

  2. Great maintainability: It also helps ease maintainability. Changes related to one responsibility do not affect others.

  3. Easier to test: Code with a single responsibility is also often easier to test. Unit tests can be more focused on the specific functionality without having to account for unrelated concerns.

  4. Reduce coupling between different parts: Separating responsibilities reduces the coupling between different parts of the code. Changes in one area are less likely to impact unrelated components, promoting a more modular and flexible system.

Open/Closed Principle

The open/closed principle says that software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.

But… how can something be open for extension (meaning we can add new features), but closed for modification? That’s contradictory!

How are we supposed to add new functionalities to existing software without changing the existing code?

Let’s use our house analogy again:

Imagine you are building a house and suddenly, you need to add an extra floor. The Open/Closed Principle is like a design allowing you to add those extra floors without knocking down walls or rerouting the plumbing. Your house should be open for extension but closed for modification, which means you can add new functionality without messing with the existing features in your house. It’s the architectural dream: expandable without being a nightmare to renovate the entire thing.

Let’s take a look at a code example again:

// Shape class representing different shapes
class Shape {
  String type;

  Shape(this.type);
}

class AreaCalculator {
  double calculateArea(Shape shape) {
    if (shape.type == 'circle') {
      return 3.14 * 3.14;
    } else if (shape.type == 'rectangle') {
      return 4 * 5;
    }
    return 0;
  }
}

In this example, the AreaCalculator violates the Open/Closed Principle because every time a new shape is introduced, it requires modifying the existing AreaCalculator class. This approach is not scalable and can lead to a maintenance nightmare.

This is how code following the Open/Closed Principle would look like: 


abstract interface class Shape {
  double calculateArea();
}

// Circle class implementing the Shape interface
class Circle implements Shape {
  double radius;

  Circle(this.radius);

  @override
  double calculateArea() {
    return 3.14 * radius * radius;
  }
}

// Rectangle class implementing the Shape interface
class Rectangle implements Shape {
  double width;
  double height;

  Rectangle(this.width, this.height);

  @override
  double calculateArea() {
    return width * height;
  }
}

// AreaCalculator class now accepts shapes that implement the Shape interface
class AreaCalculator {
  double calculateArea(Shape shape) {
    return shape.calculateArea();
  }
}

A quick side note: There is an important difference between abstract classes and interface classes. Abstract classes are used to provide a base class for concrete subclasses to inherit from, while interfaces are used to define a set of methods that a class must implement

Let’s take a look at the benefits of using the Open/Closed Principle in your code:

  • Easy extension: The Open/Closed Principle allows systems to be easily extended with new functionality without modifying existing code. This promotes the scalability of the system as new requirements can be accommodated through extension rather than modification.

  • Reduce bugs: By preventing changes to existing code, OCP reduces the risk of introducing bugs or unintended side effects.

  • Reduced Coupling: OCP encourages loose coupling between different components of the system. Changes in one module can be isolated, reducing the ripple effect on other parts of the system.

  • Promote Design Patterns: OCP is a foundational principle in many design patterns, such as Strategy, Decorator, and Factory patterns. Using OCP encourages the use of these patterns, leading to more robust and flexible software designs.

  • Encourages Abstraction: Following OCP often leads to the creation of abstract interfaces or base classes that define the contract for extension. This encourages abstraction and helps in defining clear boundaries between different parts of the system.

L — Liskov Substitution

Let’s take a look at the third letter & principle: The Liskov Substitution. It states the following: “Let Φ(x) be a property provable about objects x of type T. Then Φ(y) should be true for objects y of type S where S is a subtype of T.”

Wow. This is a bit too complex (for me at least). Let’s explain it in easier terms:

The Liskov Substitution Principle (LSP) is a principle that states that objects of a superclass should be replaceable with objects of its subclasses without affecting the correctness of the program

If Lion is a subtype of Animal, then objects of type Animal can be replaced with objects of type Lion without altering the desired behavior of the program.

abstract class Animal {
  void makeSound();
}

class Lion extends Animal {
  @override
  void makeSound() {
    print("Roar!");
  }
}

void makeAnimalSound(Animal animal) {
  animal.makeSound();
}

void main() {
  Animal animal = Lion();
  makeAnimalSound(animal);

  Lion lion = Lion();
  makeAnimalSound(lion);
}

This shows the Liskov Substitution Principle, as objects of type Animal (the superclass) can be replaced with objects of the type Lion (the subclass) without altering the desired behavior of the program.

But this looks more like the inheritance principle, right?

While the Liskov Substitution Principle is closely related to inheritance, LSP tells us more about how inheritance should be used to maintain the correctness of programs and ensure the substitutability of objects.

This means that simply inheriting from a superclass doesn’t automatically ensure that our code is following the Liskov Substitution Principle. You also need to make sure that the subclass doesn’t violate the contracts established by the superclass and doesn’t alter the behavior in unexpected ways when substituting objects. Let’s see an example of a violation:

class Animal {
  void run() {
    print('The animal is running.');
  }
}

class Lion extends Animal {
  @override
  void run() {
    super.run();
    print('The lion is roaring while running.');
  }
}

void main() {
  Animal animal = Lion(); // Using Lion as an Animal
  animal.run(); // Output: The animal is running. The lion is roaring while running.
}

We violate the Liskov Substitution Principle because substituting a Lion object for an Animal object leads to unexpected behavior: while it behaves like an Animal in most respects, the addition of roaring behavior means it doesn’t fully adhere to the behavior expected of an Animal.

When using LSP, you have two main benefits:

  1. Each class can be developed and tested independently: When we use LSP to create a set of related classes, each class can be treated as a standalone module, which means it can be developed and tested independently of other classes. It promotes modularity, meaning it allows us to break down a complex system into smaller and more manageable parts.

  2. Enhances code quality: When we follow LSP, we can be sure that every class in the group behaves consistently. This means that each class will implement the same set of methods and have the same behavior as the parent class. This consistency can help reduce the chances of catching bugs and unexpected behavior, thereby improving the overall quality of our code.

I — Interface Segregation

Interface Segregation means, that “Clients should not be forced to depend upon interfaces that they do not use”

The Interface Segregation Principle (ISP) emphasizes the importance of keeping interfaces slim and relevant to the clients that implement them. This is why it might feel very similar to the Single Responsibility Principle. Each class or interface serves a single purpose in this principle too.

Let’s consider an example involving workers who have different roles. Suppose we have an interface Worker with methods work() and eat(). However, not all workers have the same capabilities. Some workers, like a Developer, only need to work, while others, like a Waiter, only need to eat (let’s assume they have a break to eat). This setup violates the ISP because it forces all workers to implement both methods, even if they are irrelevant to their role.

Here’s the initial incorrect implementation:

abstract interface class Worker {
  void work();
  void eat();
}

class Developer implements Worker {
  @override
  void work() {
    print('Developer is working.');
  }

  @override
  void eat() {
    print('Developer is eating.');
  }
}

class Waiter implements Worker {
  @override
  void work() {
    print('Waiter is working.');
  }

  @override
  void eat() {
    print('Waiter is eating.');
  }
}

void main() {
  var developer = Developer();
  developer.work();
  developer.eat();

  var waiter = Waiter();
  waiter.work();
  waiter.eat();
}

In this implementation, both the Developer and Waiter classes are forced to implement the eat() method, even though it’s not relevant to their roles.

To address this, we can segregate the Worker interface into more specific interfaces, each representing a different role, and have the classes implement only the interfaces that are relevant to them.

Here’s the corrected implementation:

abstract interface class Worker {
  void work();
}

abstract interface class Eater {
  void eat();
}

class Developer implements Worker {
  @override
  void work() {
    print('Developer is working.');
  }
}

class Waiter implements Worker, Eater {
  @override
  void work() {
    print('Waiter is working.');
  }

  @override
  void eat() {
    print('Waiter is eating.');
  }
}

void main() {
  var developer = Developer();
  developer.work();

  var waiter = Waiter();
  waiter.work();
  waiter.eat();
}

In this corrected implementation, the Worker interface has been segregated into Worker and Eater interfaces. Now, each class only implements the interfaces that are relevant to its role. This adheres to the Interface Segregation Principle, as classes are not forced to depend on interfaces they don’t use, resulting in a more maintainable and flexible design.

This might seem simple to follow but when the code grows, it can get tough to follow this principle. As a rule of thumb, whenever your implementations provide empty methods, the Segregation Interface Principle is not followed.

The benefits of ISP are the same as the benefits of SRP, because they are very similar principles.

D — Dependency Inversion

Last but not least, is the Dependency Inversion Principle (DIP). The DIP is “The strategy of depending upon interfaces or abstract functions and classes rather than upon concrete functions and classes.”

When components of our system have a dependency on each other, we don’t want to directly inject a component’s dependency into another. Instead, we should use a level of abstraction between them.

Let’s use our house analogy again to understand it better:

The Dependency Inversion Principle suggests that instead of designing your house to depend directly on a specific electrical system, you should design it to depend on an interface or an abstract representation of an electrical system. This way, any electrical system that fits this interface can be used, making it easy to switch systems without having to redesign the house. This approach makes your house design more flexible and adaptable to change.

Here is an example of a violation of the DIP:

// Low-level module
class IncandescentBulb {
  void turnOn() {
    print("Incandescent bulb turned on");
  }

  void turnOff() {
    print("Incandescent bulb turned off");
  }
}

// High-level module
class Room {
  IncandescentBulb bulb;

  Room(this.bulb);

  void switchLightOn() {
    bulb.turnOn();
  }

  void switchLightOff() {
    bulb.turnOff();
  }
}

In this example, Room is directly dependent on IncandescentBulb, a specific low-level module. This violates DIP because Room is not flexible to changes in the type of bulb used.

Now, how do we follow the DIP in this example?

To follow the Dependency Inversion Principle, we introduce an abstraction (interface) for the bulb, which Room will depend on, and then implement this interface in any specific bulb class:

// Abstraction
abstract interface class Bulb {
  void turnOn();
  void turnOff();
}

// Low-level module
class IncandescentBulb implements Bulb {
  @override
  void turnOn() {
    print("Incandescent bulb turned on");
  }

  @override
  void turnOff() {
    print("Incandescent bulb turned off");
  }
}

// Another low-level module
class LedBulb implements Bulb {
  @override
  void turnOn() {
    print("LED bulb turned on");
  }

  @override
  void turnOff() {
    print("LED bulb turned off");
  }
}

// High-level module
class Room {
  Bulb bulb;

  Room(this.bulb);

  void switchLightOn() {
    bulb.turnOn();
  }

  void switchLightOff() {
    bulb.turnOff();
  }
}

In the revised example, Room now depends on the Bulb interface, not on any specific bulb type. This adheres to the Dependency Inversion Principle, making Room flexible to changes in the bulb type. You can easily switch from an IncandescentBulb to a LedBulb without changing the Room’s code, thereby achieving decoupling between high-level modules and low-level modules.

The benefit of the DIP is, that by depending on abstractions rather than concrete classes, high-level modules are not tied to the details of low-level modules. This separation allows developers to change the behavior of the system by modifying or replacing low-level modules without affecting high-level modules.

Conclusion

In summary, the SOLID principles equip developers with a blueprint for crafting resilient and adaptable software, similar to building a robust house capable of withstanding storms. By following these principles, developers ensure their code is flexible, maintainable, and scalable, laying a solid foundation for future improvements. Embracing SOLID means creating software that not only meets the current needs but is also ready to evolve with the changing technological landscape, ensuring longevity and relevance in the fast-paced world of software development.

I have put a lot of work into this article. If you appreciate it, I would be very grateful if you could just give this article a clap! For you, it’s just one click, but for me, it means a lot more than you think!

Further Reading

Here are some more articles that you could be interested in:

Understanding Impeller: A deep-dive into Flutter’s Rendering Engine

Flutter’s Dependence on Third-Party Libraries: A Blessing or a Curse?

Master Accessibility in Flutter (without the hassle)