A Quick Guide to SOLID Principles in Dart

What are SOLID Principles?

The term SOLID should actually be written as S.O.L.I.D. because it’s an acronym for 5 design principles, one for each letter, which help the programmer writing maintainable and flexible code. Not to take it mistakenly from the title, these principles are NOT exclusive to Dart but we will try to implement them using Dart in this article.

1- Single Responsibility Principle

Very intuitively, this principle (abbreviated with SRP) states that a class should only have a single responsibility so that it could change for one reason and no more. In other words, you should create classes dealing with a single duty so that they’re easier to maintain and harder to break.

				
					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, painting and calculations all in one place. You’ll have to make changes very often to Shape because it has many duties, all in one place; maintenance for this class is not going to be pleasant. What about this?

				
					// Calculations and logic
abstract class Shape {
    double area();
}

class Square extends Shape {}
class Circle extends Shape {}
class Rectangle extends Shape {}

// UI painting
class ShapePainter {}

// Networking
class ShapesOnline {}
				
			

There are 3 separated classes focusing on a single task to accomplish: they are easier to read, test, maintain and understand. With this approach the attention of the developer is focused on a certain area of interest (such as mathematical calculations on Shape) rather than on a messy collection of methods, each with different purposes.

2- Open Closed Principle

The open closed principle states that in a good architecture you should be able to add new behaviors without modifying the existing source code. This concept is notoriously described with the sentence “software entities should be open for extensions but closed for modifications”. Look at this example:

				
					class Rectangle {
    final double width;
    final double height;
    
    Rectangle(this.width, this.height);
}

class Circle {
    final double radius;

    Rectangle(this.radius);
    double get PI => 3.1415;
}
				
			
				
					class AreaCalculator {
    double calculate(Object shape) {
    if (shape is Rectangle) {
        // Smart cast
        return r.width * r.height;
    } else {
        final c = shape as Circle;
        return c.radius * c.radius * c.PI;
         }
    }
}
				
			

Both Rectangle and Circle respect the SRP as they only have a single responsibility (which is representing a single geometrical shape). The problem is inside AreaCalculator because if we added other shapes, we would have to edit the code to add more if conditions.

				
					class Rectangle {...}
class Circle {...}
class Triangle {...}
class Rhombus {...}
class Trapezoid {...}
class AreaCalculator {

double calculate(Object shape) {
    if (shape is Rectangle) {
        // code for Rectangle...
    } else if (shape is Circle) {
        // code for Circle...
    } else if (shape is Triangle) {
        // code for Triangle...
    } else if (shape is Rhombus) {
        // code for Rhombus...
    } else {
        //code for Trapezoid...
    }
  }
}
				
			

Having added 3 new classes, the double calculate(…) must be changed because it requires more if conditions to handle proper type casts. In general, every time that a new shape is added or removed, this method has to be maintained due to the presence of type casts. We can do better!

				
					// Use it as an interface
abstract class Area {
    double computeArea();
}

// Every class calculates the area by itself
class Rectangle implements Area {}
class Circle implements Area {}
class Triangle implements Area {}
class Rhombus implements Area {}
class Trapezoid implements Area {}
class AreaCalculator {
    double calculate(Area shape) {
        return shape.computeArea();
    }
}
				
			

Thanks to the interface, now we have the possibility to add or remove as many classes as we want
without changing AreaCalculator. For example, if we added class Square implements Area
it would automatically be “compatible” with the double calculate(...) method.

The gist of this principle is: depend on abstractions and not on implementations. Thanks to abstract classes you work with abstractions and not with the concrete implementations: your code doesn’t rely on “predefined” entities.

3- Liskov Substitution Principle

The Liskov Substitution Principle states that subclasses should be replaceable with superclasses without altering the logical correctness of the program. In practical terms, it means that a subtype must guarantee the “usage conditions” of its supertype plus something more it wants to add. Look at this example:

				
					class Rectangle {
    double width;
    double height;
    Rectangle(this.width, this.height);
}

class Square extends Rectangle {
    Square(double length): super(length, length);
}

				
			

We have a big logic problem here. A square must have 4 sides with the same length but the rectangle doesn’t have this restriction. We’re able to do this:

				
					void main() {
    Rectangle fail = Square(3);
    fail.width = 4;
    fail.height = 8;
}
				
			

At this point we have a square with 2 sides of length 4 and 2 sides of length 8… which is absolutely wrong! Sides on a square must be all equal but our hierarchy is logically flawed. The LSP is broken because this architecture does NOT guarantee that the subclass will maintain the logic correctness of the code.

This example also shows that inheriting from abstract classes or interfaces, rather than concrete classes, is a very good practice. Prefer composition (with interfaces) over inheritance.

To solve this problem, simply make Rectangle and Square two independent classes. Breaking LSP does not occur if you depend from interfaces: they don’t provide any logic implementation as it’s deferred to the actual classes.

4- Interface Segregation Principle

This principle states that a client doesn’t have to be forced to implement a behavior it doesn’t need. What turns out from this is: you should create small interfaces with minimal methods. Generally it’s better having 8 interfaces with 1 method instead of 1 interface with 8 methods.

				
					// Interfaces
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() {} // ??
}
				
			

Robots don’t need to sleep and thus the method is actually useless, but it still needs to be there otherwise the code won’t compile. To solve this, let’s just split Worker into multiple interfaces:

				
					// Interfaces
    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 definitely better because there are no useless methods and we’re free to decide which behaviors should the classes implement.

5- Dependency Inversion Principle

This is very important and useful: DIP states that we should code against abstractions and not implementations. Extending an abstract class or implement an interface is good but descending from a concrete classed with no abstract methods is bad.

				
					// Use this as interface
abstract class EncryptionAlgorithm {
    String encrypt(); // <-- abstraction
}

class AlgoAES implements EncryptionAlgorithm {}

class AlgoRSA implements EncryptionAlgorithm {}

class AlgoSHA implements EncryptionAlgorithm {}
				
			

Dependency injection (DI) is a very famous way to implement the DIP. Depending on abstractions gives the freedom to be independent from the implementation and we’ve already dealt with this point. Look at this example:

				
					class FileManager {
    void secureFile(EncryptionAlgorithm algorithm) {
    algorithm.encrypt();
    }
}
				
			

The FileManager class knows nothing about how algorithm object works, it’s just aware that the encrypt() method secures a file. This is essential for maintenance because we can call the method as we want:

				
					final fm = FileManager(...);

fm.secureFile(AlgoAES());
fm.secureFile(AlgoRSA());
				
			

If we added another encryption algorithm, it would be automatically compatible with secureFile as it is a subtype of EncryptionAlgorithm. In this example, we  respected the 5 SOLID principles all together.

Subscribe
Notify of
guest
0 Comments
Inline Feedbacks
View all comments
Scroll to Top