Cleaner Flutter Vol. 2: SOLID principles

Cleaner Flutter Vol. 2: SOLID principles

ยท

14 min read

๐Ÿ’ก Note: This article was originally posted in my friend Elian's blog and has been slightly modified for this series. You can check the original here.

After starting in the world of programming we all reach a point where we have to look back on the road and review some of the lines of code that we have written, either 1 day ago to remember an idea or years ago to review the implementation of any module of our software.

Many times in these glances at the code of the past we come across a list of problems such as:

  • Having to search among many files for the answer to what we are looking for.
  • Not understanding the code we wrote.
  • Not understanding why we wrote the code the way we did.

https://cdn.hashnode.com/res/hashnode/image/upload/v1644121955953/K86n4DlZb.gif

These problems start when we start a project because we do not spend enough time to have a clear idea not only of what we are going to do but also of how we are going to do it.

We have to develop code imagining what would happen if I return in 2 years to review it, this ability to program clean and understandable code is essential to facilitate development, especially if you work in a team.

What are the SOLID principles?

SOLID is the acronym for a set of principles that help us develop more maintainable code that also allows easy extension without compromising code already developed.

In other words...

Write more code without damaging what already works.

We can even see them as a set of guidelines to follow.

Da_Rules.png

Now we are going to explore each of the principles, which can be applied to any programming language, but I am going to cover them using Dart language since it is the language used by the Flutter framework.

Before continuing it is important to note that these were first introduced by Uncle Bob. You can see his explanation here.

S stands for Single Responsibility

A class must have one, and only one, a reason to change.

To explain this principle we can imagine a messy room, as we have all had it at some point, perhaps even now that you are reading this.

But the truth is that within this, everything has its place and everything should be in its designated place. To put the analogy aside, this principle tells us more specifically:

  • A class must have a unique responsibility (applies to methods, variables, entities, etc).
  • There is a place for everything and everything should be in its place.
  • All the variables and methods of the class must be aligned with the objective of the class.

By following these principles, we achieve smaller and simpler classes that have unique objectives. Also, we avoid giant classes with generic properties and methods that can be redundant in development.

Let's see an example:

https://cdn.hashnode.com/res/hashnode/image/upload/v1644121958217/aYx_jT5K0.gif

Let's take a look at this signUp function. This could be the method we call from our UI layer to perform the user sign-up process.

Future<User> signUp({
  required String name,
  required String email,
  required String password,
}) async {
  final isValid = name.isNotEmpty && email.isNotEmpty && password.isNotEmpty;

  if (isValid) {
    final newUser = User(
      name: name,
      email: email,
      password: password,
    );

    final userMap = newUser.toMap();

    await saveToDb(json.encode(userMap));
  }
}

In the code, we see that there is the functionality of creation, conversion to JSON, and even the call to the database that is normally an API call, so we are not fulfilling the principle.

This is one of the most common mistakes, especially in Flutter, since developers easily make the mistake of combining different things within the same class or methods.

โš ๏ธ In other articles we will see how this applies to Clean Architecture...

Done, I got it, now... how do I apply it?

To follow the single responsibility principle correctly, we could create methods and classes with simple and unique functionalities.

In the example method signUp many things are done with different objectives, each of these functionalities could be separated into an individual class with a single objective.

Extracting logic

We could implement a class that is responsible for performing the validation, there are many ways to do this. One of them can be to use package:formz which is a Dart package that allows us to create classes for a data type and perform validations.

import 'package:formz/formz.dart';

/// Define input validation errors
enum NameInputError { empty }

/// Extend FormzInput and provide the input type and error type.
class NameInput extends FormzInput<String, NameInputError> {
  /// Call super.pure to represent an unmodified form input.
  const NameInput.pure() : super.pure('');

  /// Call super.dirty to represent a modified form input.
  const NameInput.dirty({String value = ''}) : super.dirty(value);

  /// Override validator to handle validating a given input value.
  @override
  NameInputError? validator(String value) {
    return value.isNotEmpty == true ? null : NameInputError.empty;
  }
}

Do not focus too much on the logic of the code, the important thing is to understand that now the validation is decoupled from the rest of the logic with the validator method.

Connecting dependencies

The other error is to call an API from the business logic or user interface, this would not fulfill the principle since the connection with the API is a complex functionality by itself, so it would be best to implement a class as Repository to which we pass the parameters that we are going to send and delegate the rest of the process to it.

/// Repository that handles the relationship
/// between app logic and data source or API.
class AuthRepository {
  AuthRepository({
    http.Client? client,
  }) : _client = client ?? http.Client();

  final http.Client _client;

  /// Method that makes the API call to sign-up a user.
  Future<void> signUpUser(User user) async {
    try {
      await _client.post(
        Uri.parse('https://noscope.dev/user/create'),
        body: user.toMap(),
      );
    } catch (e) {
      throw NetworkException();
    }
  }
}

This repository concept is key to meeting clean architecture standards but we can see that the principle of single responsibility is behind this whole idea.

By implementing these classes applying the principle we would achieve a simpler method compared to how we started with:

Future<void> signUp({
  required String name,
  required String password,
  required String email,
}) async {
  final newUser = User(
    email: email,
    password: password,
    name: name,
  );
  await _authRepository.signUpUser(newUser);
}

Simpler, more maintainable, and decoupled.

O stands for Open/Closed

An entity must be open to extending but closed to modify.

This principle tells us that we must extend our classes to add new code, instead of modifying the existing one.

The first time we read this it can be a bit confusing but it just is:

Don't modify what already works, just extend and add new code.

n this way, we can develop without damaging the previously tested code. To understand this principle we can see an example given by Katerina Trjchevska at LaraconEU 2018.

Let's imagine that our app has a payment module that currently only accepts debit/credit cards and PayPal as payment methods.

Future<void> pay(String paymentType) async {
  if (paymentType == 'card') {
    await _paymentRepository.payWithCard();
  } else if (paymentType == 'paypal') {
    await _paymentRepository.payWithPaypal();
  }
}

At a first glance at the code, we may think that everything is fine, but when we analyze its long-term scalability, we realize the problem.

Let's imagine that our client asks us to add a new payment method such as Alipay, gift cards, and others.

Each new payment method implies a new function and a new conditional in the pay method and we could say that this is not a problem, but if we keep adding code within the same class, we would never achieve a stable, ready for production code.

By applying the open/closed principle, we can create an abstract class PayableInterface that serves as a payment interface. In this way, each of our payment methods extends this abstract class and it can be a separate class that is not affected by modifications made to another.

abstract class PayableInterface {
  Future<void> pay();
}

class CardPayment implements PayableInterface {
  @override
  Future<void> pay() async {
    // Card payment logic.
  }
}

class PaypalPayment implements PayableInterface {
  @override
  Future<void> pay() async {
    // Paypal payment logic.
  }
}

class AliPayPayment implements PayableInterface {
  @override
  Future<void> pay() async {
    // AliPay payment logic.
  }
}

After having our payment logic implemented, we can receive a parameter with the paymentType that allows us to select the PayableInterface indicated for the transaction, and in this way we do not have to worry about how the pay method makes the payment, only to make a type of filtering so that the correct instance of the interface is used; be it Card, PayPal or Alipay.

enum PaymentType { card, payPal, aliPay }

class PaymentFactory {
  const PaymentFactory();

  PayableInterface initializePayment(PaymentType paymentType) {
    // Filter by `paymentType` and return the correct [PayableInterface].
    switch (paymentType) {
      case PaymentType.card:
        return CardPayment();
      case PaymentType.payPal:
        return PayPalPayment();
      case PaymentType.aliPay:
        return AliPayPayment();
    }
  }
}

class PaymentRepository {
  PaymentRepository({
    required PaymentFactory paymentFactory,
  }) : _paymentFactory = paymentFactory;

  final PaymentFactory _paymentFactory;

  Future<void> pay(PaymentType paymentType) async {
    // Get the respective payableInterface based on the `paymentType`.
    PayableInterface payment = _paymentFactory.initializePayment(paymentType);

    // Make payment.
    payment.pay();
  }
}

In the end, we would have a method like this where we can see that the code was reduced to only 3 lines and it is much easier to read.

It is also more scalable since if we wanted to add a new type of payment method we would only have to extend from PayableInterface and add it as an option in the filtering method.

๐Ÿ‘† These concepts of abstractions and instances are confusing at first but throughout this series of articles and by practice they'll become simple concepts.

L stands for Liskov Substitution

We can change any concrete instance of a class with any class that implements the same interface.

The main objective of this principle is that we should always obtain the expected behavior of the code regardless of the class instance that is being used.

To be able to fulfill this principle correctly, there are 2 important parts:

  • Implementation.
  • Abstraction.

The first we can see in the previous example of open/closed principle when we have PayableInterface and the payment methods that implement it asCardPayment and PaypalPayment.

In the implementation of the code we see that it doesn't matter with the implementation we choose, our code should continue to work correctly, this is because both make correct implementation of the PayableInterface interface.

With this example, the idea is easy to understand but in practice, there are many times that we perform the abstraction process wrong, so we cannot truly make great use of the principle.

If you are not very familiar with concepts such as interface, implementation, and abstraction this may sound a bit complex but let's see it with a simple example.

https://cdn.hashnode.com/res/hashnode/image/upload/v1644121976110/I2U1PUmwY.jpeg

This is one of the iconic images of the principle as it makes it easy to understand.

Let's imagine that in our code we have a class called DuckInterface.

This gives us the basic functionality of a duck: fly, swim, and quack. And we would have theRubberDuck class that implements the interface.

abstract class DuckInterface {
  void fly();
  String swim();
  String quack();
}

class RubberDuck implements DuckInterface {
  @override
  void fly() {
    throw Exception('Rubber duck cannot fly');
  }

  @override
  String quack() {
    return "Quack!!!";
  }

  @override
  String swim() {
    return "Duck Swimming";
  }
}

At a first glance, we could say that our abstraction is fine since we are using an interface that gives us the functionality we need, but the fly method does not apply to a rubber duck, imagine that our program is going to have different Animals with shared functionality such as flying and swimming, so it would not make sense to leave this method on the DuckInterface interface.

To solve this and comply with the Liskov principle, we can create more specific interfaces that allow us to reuse code, which also makes our code more maintainable.

abstract class QuackInterface {
  String quack();
}

abstract class FlyInterface {
  String fly();
}

abstract class SwimInterface {
  String swim();
}

class RubberDuck implements QuackInterface, SwimInterface {
  @override
  String quack() {
    return "Quack!!!";
  }

  @override
  String swim() {
    return "Duck Swimming";
  }
}

With this implementation, our RubberDuck class only implements the methods it needs and now, for example, if we need an animal that fulfills a specific function such as swimming, we could use any class that implements the SwimInterface interface. This is because by fulfilling the Liskov principle we can switch any declaration of an abstract class by any class that implements it.

I stands for Interface Segregation

The code should not depend on methods that it does not use.

At first, this could seem to be the simplest principle but for this very reason, in the beginning, it can even confuse us.

In the previous principles, we have seen the importance of interfaces to decouple our code.

This principle ensures that our abstractions for creating interfaces are correct since we cannot create a new instance of an interface without implementing one of the methods defined by them. The above would be violating the principle

https://cdn.hashnode.com/res/hashnode/image/upload/v1644121983608/1y86n5w6n.jpeg

This image shows the problem of not fulfilling this principle, we have some instances of classes that do not use all the interface methods, which lead to a dirty code and indicate bad abstraction.

It is easier to see it with the typical anima example, this is very similar to the example we saw from Liskov.

๐Ÿ’ก At this point the examples become similar but the important thing is to see the code from another perspective.

We have an abstract class Animal that is our interface, it has 3 methods defined eat, sleep, and fly.

If we create a Bird class that implements the animal interface we don't see any problem, but what if we want to create the Dog class?

Exactly, we realize that we cannot implement the fly method because it does not apply to a dog.

We could leave it like that and avoid the time needed to restructure the code since we logically know that this would not affect our code, but this breaks the principle.

abstract class Animal {
  void eat();
  void sleep();
  void fly();
}

class Bird implements Animal {
  @override
  void eat() {}

  @override
  void sleep() {}

  @override
  void fly() {}
}

class Dog implements Animal {
  @override
  void eat() {}

  @override
  void sleep() {}

  @override
  void fly() => throw Exception('Dogs cannot fly!');
}

The mistake is made by having a bad abstraction in our class and the right thing to do is always refactor to ensure that the principles are being met.

It may take us a little longer at the moment but the results of having a clean and scalable code should always be priorities.

A solution to this could be that our Animal interface only has the methods shared by animals like eat, sleep and we create another interface for the fly method. In this way, only animals that need this method to implement its interface.

abstract class FlyInterface {
  void fly();
}

abstract class Animal {
  void eat();
  void sleep();
}

class Bird implements Animal, FlyInterface {
  @override
  void eat() {}

  @override
  void sleep() {}

  @override
  void fly() {}
}

class Dog implements Animal {
  @override
  void eat() {}

  @override
  void sleep() {}
}

D stands for Dependency Inversion

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

This principle tells us:

  • You never have to depend on a concrete implementation of a class, only on its abstractions (interfaces).

  • Same as the image presented on volume 1, we follow the rule that high-level modules should not strictly rely on low-level modules.

To understand it more simply, let's look at the example.

Nowadays, every app or software that is developed needs to communicate with the outside world. Normally this is done through code repositories that we instantiate and call from the business logic in our software.

class DataRepository {
  Future<void> logInUser() async {}

  Future<void> signUpUser() async {}
}

class BusinessLogic {
  const BusinessLogic({
    required DataRepository repository,
  }) : _repository = repository;

  final DataRepository _repository;

  Future<void> logIn() async {
    await _repository.logInUser();
  }

  Future<void> signUp() async {
    await _repository.signUpUser();
  }
}

Declaring and using a concrete class, such as the DataRepository within BusinessLogic, is a very common practice and is one of the common mistakes that make our code not very scalable. By depending on a particular instance of a class we surely know it will never be stable because you are constantly adding code to it.

To solve this problem, the principle tells us to create an interface that communicates both modules. You can even develop the whole functionality of the business logic and user interface of an app by depending on an interface that hasn't been implemented.

This also allows better communication in a team of developers because when creating an interface, everyone is clear about the objectives of the module, and from that definition, it can be verified that the SOLID principles are being met.

abstract class DataRepositoryInterface {
  Future<void> logInUser();

  Future<void> signUpUser();
}

class DataRepository extends DataRepositoryInterface {
  @override
  Future<void> logInUser() async {
    // Implementation...
  }

  @override
  Future<void> signUpUser() async {
    // Implementation...
  }
}

With this implementation, we create a DataRepositoryInterface that we can then implement inDataRepository and the magic happens inside the class that uses this functionality when we do not depend on a concrete instance but instead on an interface we could pass as parameters any concrete class that implements this interface.

It could be a local or external database and that would not affect the development, I repeat it: we do not depend on a single concrete instance, we can use any class that complies with the implementation of the interface.

And this is what allows us to fulfill the magic word of clean architecture: decoupling!

Wrap-up

I remind you that these are principles, not rules, so there is no single way to follow them, their use and compliance with the code will depend on each project since for many of these the objectives of the project are key to making decisions. Just as something within the scope of a project can be considered small it may under other requirements become something large.


If you liked Elian's content, you can find even more and keep in touch with him on his social networks:

ย