Welcome fellow Dart and Flutter developers! Today we’re talking about SOLID principles in Dart. This is not about new language features; it’s instead about code quality and maintainability. These best practices are meant to be used in any object oriented language, so they do not only apply to Dart.
The term SOLID is an acronym for five famous design principles, Single Responsibility, Open-Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion, which we will examine below. They are very popular among developers and are generally recognized as good practices to follow.
Abbreviated with SRP, this principle states (very intuitively) that a class should only have a single reason to change. In other words, you should create classes with a single “responsibility” so that they’re easier to maintain and harder to break.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Shapes {
List < String > cache = List < > ();
// Calculations
double squareArea(double l) {
/* ... */ }
double circleArea(double r) {
/* ... */ }
double triangleArea(double b, double h) {
/* ... */ }
// Paint to the screen
void paintSquare(Canvas c) {
/* ... */ }
void paintCircle(Canvas c) {
/* ... */ }
void paintTriangle(Canvas c) {
/* ... */ }
// GET requests
String wikiArticle(String figure) {
/* ... */ }
void _cacheElements(String text) {
/* ... */ }
}
This class totally destroys the SRP as it handles internet requests, paintings, and calculations all in one place. This class is going to change very often in the future: whenever a method requires maintenance, you’ll have to change some code and this may potentially break other parts of the class. What about this?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// Calculations and logic
abstract class Shape {
double area();
}
class Square extends Shape {}
class Circle extends Shape {}
class Rectangle extends Shape {}
// UI painting
class ShapePainter {
void paintSquare(Canvas c) {
/* ... */ }
void paintCircle(Canvas c) {
/* ... */ }
void paintTriangle(Canvas c) {
/* ... */ }
}
// Networking
class ShapesOnline {
String wikiArticle(String figure) {
/* ... */ }
void _cacheElements(String text) {
/* ... */ }
}
Now each class has consistent methods and overall they have a single responsibility (respectively: making calculations, painting to the UI and fetching data from the Internet).
The open-closed principle states that in good architecture the developer should add new behaviors without changing the existing source code. This concept is notoriously described as “classes should be open for extension and closed for changes”. Look at this example:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Rectangle {
final double width;
final double height;
const Rectangle(this.width, this.height);
}
class Circle {
final double radius;
const Circle(this.radius);
double get PI => 3.1415;
}
class AreaCalculator {
double calculate(Object shape) {
if (shape is Rectangle) {
return r.width * r.height;
} else {
final c = shape as Circle;
return c.radius * c.radius * c.PI;
}
}
}
Note that Rectangle and Circle respect the SRP as they only have a single responsibility. The problem is inside AreaCalculator, because if we added other shapes, we would have to add more if conditions, and thus the calculate method would require maintenance.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class AreaCalculator {
double calculate(Object shape) {
if (shape is Rectangle) {
return shape.width * shape.height;
} else if (shape is Triangle) {
return shape.base * shape.height / 2;
} else if (shape is Square) {
return shape.side * shape.side;
} else {
final c = shape as Circle;
return c.radius * c.radius * c.PI;
}
}
}
The code itself may also become hard to read. Thanks to abstraction and dependency injection, we can do much better.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
abstract class Area { const Area(); double computeArea(); } class Rectangle extends Area { final double width; final double height; const Rectangle(this.width, this.height); @override double computeArea() => width * height; } class Circle extends Area { final double radius; const Circle(this.radius); @override double computeArea() => radius * radius * 3.1415; } class AreaCalculator { double calculate(Area shape) => shape.computeArea(); }
Thanks to inheritance, now we have the possibility to add or remove as many classes as we want without changing AreaCalculator. For example, if we added class Triangle extends Area {}, we wouldn’t need to update the AreaCalculator.calculate(double) because the overridden method is inherited.
The LSP states that subclasses should be replaced with superclasses without changing the logical correctness of the program. In simpler terms, it means that a subtype must guarantee the “usage conditions” of its supertype plus some more behaviors. For example:
1
2
3
4
5
6
7
8
9
class Rectangle {
double width;
double height;
const Rectangle(this.width, this.height);
}
class Square extends Rectangle {
const Square(double length): super(length, length);
}
The code compiles but there is a bad logic error. All the sides of a square must have the same length but a rectangle doesn’t have this restriction. However, we can do this at the moment:
1
2
3
4
5
6
7
void main() {
final Rectangle error = Square(3);
// Creating a square with various sides lengths... what??
error.width = 4;
error.height = 8;
}
Whoops! Now we have a square with different length sides (which is… impossible). The LSP is broken because this architecture does not guarantee that the subclass will maintain the logical correctness of the hierarchy. The solution is to simply make Rectangle and Square two separate classes and remove the inheritance relationship.
This principle states that clients don’t have to implement a behavior they don’t need. The gist of this principle is: you should create small interfaces with minimal methods. Let’s look at this example:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
abstract class Worker {
void work();
void sleep();
}
class Human implements Worker {
void work() => print("I do a lot of work");
void sleep() => print("I need 10 hours per night...");
}
class Robot implements Worker {
void work() => print("I always work");
void sleep() {} // ??
}
The problem here is that robots don’t need to sleep so we don’t know how to implement that method. Worker represents an entity able to make a job and it assumes that if you can work, then you can also sleep. In our case this assumption is not always valid, so let’s split the class:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
abstract class Worker {
void work();
}
abstract class Sleeper {
void sleep();
}
class Human implements Worker, Sleeper {
void work() => print("I do a lot of work");
void sleep() => print("I need 10 hours per night...");
}
class Robot implements Worker {
void work() => print("I always work");
}
This is much better. Now Robot is not forced to implement sleep() anymore and we have loosened the constraints for implementers.
The DIP states that we should favor abstractions over implementations. Extending an abstract class or implementing an interface is good but descending from a concrete class is bad. We’ve already seen why in the open-closed principle:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
abstract class EncryptionAlgorithm {
const EncryptionAlgorithm();
String encrypt(); // <-- abstraction
}
class AlgoAES implements EncryptionAlgorithm {
/* .. */ }
class AlgoRSA implements EncryptionAlgorithm {
/* .. */ }
class AlgoSHA implements EncryptionAlgorithm {
/* .. */ }
The usage of abstractions gives the freedom to be independent from the implementation.The point is that anyone using the EncryptionAlgorithm only knows about the encrypt() method and the other internal details of the class don 't matter at all!
class FileManager {
void secureFile(EncryptionAlgorithm algo) => algo.encrypt()
}
FileManager doesn’t care if we add one or more methods to AlgoRSA, for example, because it only cares about the encryption() method. It only knows that encrypt() exists: no matter how it works internally, FileManager only cares about the return type (if any).
1 2 3 4 5 6
void main() { const fm = FileManager(); fm.secureFile(const AlgoAES()); fm.secureFile(const AlgoRSA()); }
In the next article, we will talk about transition widgets in Flutter. They are a series of pre-built widgets you can find in the Flutter core that automatically apply scale, rotation, or fade animated transitions to any widget. That’s all for now, stay tuned!
Flutter Complete Reference book
The official Dart Tour guide