All about SOLID Principles in Flutter: Examples and Tips

joshua-reddekopp-syymxsdnj54-unsplash

As a senior software engineer, one of the most important principles I adhere to is the SOLID principles of object-oriented programming. These principles are a set of guidelines that help developers write code that is easy to maintain, extend, and test. In this article, we'll take a look at the SOLID principles in the context of Flutter and provide real-world examples to help you understand them better. And of course, we'll sprinkle in a few memes to keep things interesting!

SOLID Principles Overview

SOLID is an acronym that stands for five principles of object-oriented programming:

  1. Single Responsibility Principle (SRP): A class should have only one reason to change.
  2. Open/Closed Principle (OCP): A class should be open for extension but closed for modification.
  3. Liskov Substitution Principle (LSP): Subtypes should be substitutable for their base types.
  4. Interface Segregation Principle (ISP): Clients should not be forced to depend on interfaces they don't use.
  5. Dependency Inversion Principle (DIP): High-level modules should not depend on low-level modules. Both should depend on abstractions.

Now let's dive into each principle and see how they apply in Flutter development.

Single Responsibility Principle (SRP)

The SRP states that a class should have only one reason to change. In other words, a class should have only one responsibility. This helps to keep the code clean, organized, and easy to maintain.

Let's take an example of a class that handles both the UI rendering and the business logic. This is not a good design because if we want to change the UI, we have to modify the business logic code as well. Instead, we can separate the UI and the business logic into different classes to follow the SRP.

Here's an example of how to separate the UI and business logic in Flutter:

Copy
class MyHomePage extends StatefulWidget {
  
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('My App'),
      ),
      body: Center(
        child: Text(
          'Hello, World!',
          style: TextStyle(fontSize: 30),
        ),
      ),
    );
  }
}

class HomePageViewModel {
  String getText() => 'Hello, World!';
}

In this example, the MyHomePage widget only handles the UI rendering, while the HomePageViewModel class handles the business logic. This separation makes it easier to modify each component independently without affecting the other.

Open/Closed Principle (OCP)

The OCP states that a class should be open for extension but closed for modification. This means that we should be able to add new features or behaviors to a class without changing its existing code. This helps to maintain the existing code and avoid introducing new bugs.

Let's take an example of a class that handles the rendering of different shapes. We can use the OCP principle to allow the addition of new shapes without modifying the existing code.

Here's an example of how to use the OCP principle in Flutter:

Copy
abstract class Shape {
  void draw();
}

class Circle implements Shape {
  
  void draw() => print('Drawing a circle.');
}

class Rectangle implements Shape {
  
  void draw() => print('Drawing a rectangle.');
}

class ShapePainter {
  List<Shape> shapes;

  void paint(Canvas canvas) {
    shapes.forEach((shape) => shape.draw());
  }
}

In this example, we have an abstract Shape class with a draw method that will be implemented by its concrete subclasses. The ShapePainter class takes a list of shapes and paints them on the canvas by calling their draw method.

Now, let's say we want to add a new shape, such as a triangle. We can do this without modifying the ShapePainter class by creating a new subclass of Shape:

Copy
class Triangle implements Shape {
  
  void draw() => print('Drawing a triangle.');
}

And then we can use it like this:

Copy
final shapes = [Circle(), Rectangle(), Triangle()];
final painter = ShapePainter(shapes);

This way, we can add new shapes without modifying the existing code, which makes it easier to maintain and extend the application.

Liskov Substitution Principle (LSP)

The LSP states that subtypes should be substitutable for their base types. This means that if we have a class A that is a subtype of class B, we should be able to use an object of type A wherever an object of type B is expected, without affecting the correctness of the program.

Let's take an example of a class hierarchy that represents different types of animals. We can use the LSP to ensure that any subclass can be used interchangeably with its superclass.

Here's an example of how to use the LSP in Flutter:

Copy
abstract class Animal {
  String name;
  String speak();
}

class Dog extends Animal {
  
  String speak() => 'Woof!';
}

class Cat extends Animal {
  
  String speak() => 'Meow!';
}

class AnimalApp {
  void run() {
    final animals = [Dog(), Cat()];
    animals.forEach((animal) => print('${animal.name} says ${animal.speak()}'));
  }
}

In this example, we have an abstract Animal class with a name property and a speak method that will be implemented by its concrete subclasses. The AnimalApp class creates a list of animals and prints out their names and the sounds they make.

Thanks to the LSP, we can treat each animal as an Animal object, even though they are actually Dog and Cat objects. This makes the code more flexible and easier to maintain.

Interface Segregation Principle (ISP)

The ISP states that clients should not be forced to depend on interfaces they don't use. This means that we should break down large interfaces into smaller, more specialized ones, so that clients only need to implement the methods they actually use.

Let's take an example of an interface that represents a shape. We can use the ISP to break down this interface into smaller interfaces that represent specific shape behaviors.

Here's an example of how to use the ISP in Flutter:

Copy
abstract class Shape {
  double get area;
}

abstract class HasPerimeter {
  double get perimeter;
}

class Circle implements Shape, HasPerimeter {
  final double radius;

  Circle(this.radius);

  
  double get area => 3.14 * radius * radius;

  
  double get perimeter => 2 * 3.14 * radius;
}

class Square implements Shape, HasPerimeter {
  final double side;

  Square(this.side);

  
  double get area => side * side;

  
  double get perimeter => 4 * side;
}

In this example, we have an abstract Shape interface with a get area method, and an HasPerimeter interface with a get perimeter method. The Circle and Square classes implement both interfaces, since they have both an area and a perimeter.

By breaking down the Shape interface into smaller interfaces, we make it easier for clients to use only the methods they need. For example, if a client only needs to calculate the area of a shape, they can depend only on the Shape interface, and not on the HasPerimeter interface.

Dependency Inversion Principle (DIP)

The DIP states that high-level modules should not depend on low-level modules. Instead, both should depend on abstractions. This means that we should define interfaces or abstract classes for our dependencies, so that we can easily switch between different implementations without affecting the rest of the application.

Let's take an example of a class that sends notifications. We can use the DIP to make this class more flexible, by defining an interface for its dependencies.

Here's an example of how to use the DIP in Flutter:

Copy
abstract class NotificationService {
  Future<void> sendNotification(String message);
}

class EmailService implements NotificationService {
  
  Future<void> sendNotification(String message) async {
    // send email notification
  }
}

class PushNotificationService implements NotificationService {
  
  Future<void> sendNotification(String message) async {
    // send push notification
  }
}

class NotificationSender {
  final NotificationService _service;

  NotificationSender(this._service);

  Future<void> send(String message) => _service.sendNotification(message);
}

In this example, we have an abstract NotificationService interface with a sendNotification method, and two concrete implementations: EmailService and PushNotificationService. The NotificationSender class depends on the NotificationService interface, instead of depending directly on either EmailService or PushNotificationService.

By depending on an interface instead of a concrete implementation, we make it easier to switch between different notification services, without having to modify the NotificationSender class.

Conclusion:

The SOLID principles are an important set of guidelines for writing maintainable and flexible software. By following these principles, we can create code that is easier to understand, modify, and extend.

In this article, we've covered the five SOLID principles, and provided real-world examples of how to use them in Flutter. We hope this article has helped you understand the importance of these principles, and how to apply them to your own Flutter projects.

Tags:

Check out more posts in my blogs