The SOLID Principles Dartified

davthecoder
9 min readOct 21, 2023
source:https://i0.wp.com/i.postimg.cc/zB23GX8D/Solid-principles.png?w=1230&ssl=1

Hey, fellow devs! Are you interviewing for a Flutter developer? Dart? and/or perhaps are on the brink of embarking on a new Flutter project or keen on refining your existing skills?

Firstly, kudos to you — it’s the persistent learners who genuinely progress in this constantly changing profession.

In this post, as a Flutter app developer, I’ll walk you through the process of building a fruit shop app. This playful context will not only bring some vibrancy to our exploration but also serve as a concrete setting for the SOLID principles we’re going to delve into. I’ll break down each principle using scenarios directly tied to our fruit-themed project, turning theoretical concepts into practical understanding.

While not all apps require strict adherence to these principles, understanding them and knowing when to use them can significantly enhance your Flutter codebase. So, are you ready to navigate the orchard of SOLID principles and see how they blend with Dart and Flutter in our fruit shop app journey?

Let’s dive into the first principle, tailored for our delightful fruit shop app built with Flutter :)

S — Single Responsibility Principle (SRP)

The Single Responsibility Principle advocates that a class should have only one reason to change. This principle acts as a linchpin in maintaining a clean, manageable, and organised codebase. In the context of our fruit shop app, adhering to the SRP can significantly ease the process of debugging and enhancing the code over time.

Let’s illustrate this with a simple example where we follow SRP and another where it doesn’t:

BAD:

class FruitAndInvoice {
final String fruitName;
final double fruitPrice;
final List<double> allFruitPrices;

FruitAndInvoice(this.fruitName, this.fruitPrice, this.allFruitPrices);

void displayFruit() {
print('Fruit: $fruitName, Price: $fruitPrice');
}

double calculateTotal() {
return allFruitPrices.fold(0, (total, price) => total + price);
}

void displayInvoice() {
displayFruit();
print('Total: ${calculateTotal()}');
}
}

In this FruitAndInvoice class, multiple responsibilities are bundled together, making it harder to manage and debug. A change in displaying fruit information or altering invoice calculation logic necessitates a modification in this class. This tangled design, compared to the earlier separated Fruit and Invoice classes showcase how the Single Responsibility Principle leads to a more organized, manageable, and scalable codebase, simplifying the evolution of our fruit shop app.

GOOD:

class Fruit {
final String name;
final double price;

Fruit(this.name, this.price);

void displayFruit() {
print('Fruit: $name, Price: $price');
}
}

class Invoice {
final List<Fruit> fruits;

Invoice(this.fruits);

double calculateTotal() {
return fruits.fold(0, (total, fruit) => total + fruit.price);
}

void displayInvoice() {
for (final fruit in fruits) {
fruit.displayFruit();
}
print('Total: ${calculateTotal()}');
}
}

In the above snippet code, the Fruit the class holds fruit data, while the Invoice the class manages a fruit list and total price calculation. Each class follows the Single Responsibility Principle, having a distinct reason to change. For example, altering fruit data display only requires a tweak in the Fruit class, and modifying invoice calculation logic solely involves the Invoice class. Adhering to the SRP simplifies navigating our fruit shop app and fosters a clean, maintainable codebase as the app evolves over time.

O — Open/Closed Principle (OCP)

The Open/Closed Principle posits that software entities should be open for extension but closed for modification. This principle is pivotal in promoting a robust and maintainable codebase. In the scenario of our fruit shop app, adhering to the OCP can significantly streamline the process of extending functionalities without tampering with existing code.

Let’s elucidate this with a simple example where we follow the OCP and another where we don’t:

BAD:

class Fruit {
final String name;
final double price;

Fruit(this.name, this.price);

double discountPrice(double discount) {
return price - (price * discount);
}
}

class SeasonalFruit extends Fruit {
SeasonalFruit(String name, double price) : super(name, price);

// Override discountPrice method to offer a greater discount
@override
double discountPrice(double discount) {
return price - (price * discount * 1.5);
}
}

In this flawed snippet code, the SeasonalFruit class overrides the discountPrice method from the Fruit class to offer a greater discount. However, this approach modifies the existing behaviour of the Fruit class, which violates the Open/Closed Principle.

GOOD:

abstract class Fruit {
final String name;
final double price;

Fruit(this.name, this.price);

double discountPrice(double discount);
}

class RegularFruit extends Fruit {
RegularFruit(String name, double price) : super(name, price);

@override
double discountPrice(double discount) {
return price - (price * discount);
}
}

class SeasonalFruit extends Fruit {
SeasonalFruit(String name, double price) : super(name, price);

@override
double discountPrice(double discount) {
return price - (price * discount * 1.5);
}
}

In this snippet code, we’ve declared an abstract Fruit class with a discountPrice method. The RegularFruit and SeasonalFruit classes extend the Fruit class and implement the discountPrice method. This design adheres to the Open/Closed Principle by allowing new fruit types to extend the existing behaviour without modifying the Fruit class, the Fruit the class provides a blueprint for discount calculations, while the RegularFruit and SeasonalFruit classes offer specific implementations.

Each class aligns with the Open/Closed Principle, enabling distinct discount calculations. For instance, introducing a new fruit category with a different discount logic only entails creating a new class extending Fruit. Following the OCP not only facilitates extending our fruit shop app but also preserves a clean, adaptable codebase as the app matures over time.

L — Liskov Substitution Principle (LSP)

The Liskov Substitution Principle (LSP) prescribes that objects of a derived class should be able to replace objects of the base class without affecting the correctness of the program. This principle is instrumental in ensuring the interchangeability of objects, thereby promoting code reusability and maintainability. In the milieu of our fruit shop app, adhering to LSP can significantly simplify the process of introducing new types of fruits or other products without upsetting the existing code structure.

Let’s delineate this with a simple example where we follow LSP and another where we don’t:

BAD:

class Fruit {
final String name;
final double price;

Fruit(this.name, this.price);

double discountPrice(double discount) {
return price - (price * discount);
}
}

class SpecialFruit extends Fruit {
SpecialFruit(String name, double price) : super(name, price);

@override
double discountPrice(double discount) {
if (discount > 0.5) {
throw Exception("Discount for special fruit can't be more than 50%");
}
return price - (price * discount);
}
}

In this flawed snippet code, the SpecialFruit class overrides the discountPrice method from the Fruit class and throws an exception if the discount is more than 50%. This violation of LSP makes the SpecialFruit class not substitutable for the Fruit class without altering the program's correctness.

GOOD:

abstract class Fruit {
final String name;
final double price;

Fruit(this.name, this.price);

double discountPrice(double discount);
}

class RegularFruit extends Fruit {
RegularFruit(String name, double price) : super(name, price);

@override
double discountPrice(double discount) {
return price - (price * discount);
}
}

class SpecialFruit extends Fruit {
SpecialFruit(String name, double price) : super(name, price);

@override
double discountPrice(double discount) {
final adjustedDiscount = discount > 0.5 ? 0.5 : discount;
return price - (price * adjustedDiscount);
}
}

In this snippet code, the SpecialFruit class modifies the discount logic without breaking the contract set by the Fruit class. The SpecialFruit class can now safely replace the Fruit class without affecting the program's correctness, adhering to the Liskov Substitution Principle, and the Fruit class sets a blueprint for discount calculations, whilst the RegularFruit and SpecialFruit classes provide specific implementations.

Each class aligns with the Liskov Substitution Principle, allowing seamless substitution and ensuring a coherent discount calculation logic. For instance, adding a new fruit category with a unique discount logic only entails creating a new class extending Fruit. Following the LSP facilitates a smoother extension of our fruit shop app's capabilities whilst preserving a clean, modular codebase as the app flourishes over time.

I — Interface Segregation Principle (ISP)

The Interface Segregation Principle (ISP) asserts that clients should not be forced to depend on interfaces they do not use. This principle is crucial for reducing the coupling between classes and making the codebase more modular, manageable, and adaptable to changes. In the scenario of our fruit shop app, adhering to ISP can significantly streamline the process of extending or modifying functionalities without creating ripple effects across the codebase.

Let’s elucidate this with a simple example where we follow ISP and another where we don’t:

BAD:

abstract class FruitOperations {
void displayFruit();
double calculateDiscountPrice(double discount);
void updateInventory(int quantity);
}

class Apple implements FruitOperations {
final String name = 'Apple';
final double price = 2.0;

@override
void displayFruit() {
print('Fruit: $name, Price: $price');
}

@override
double calculateDiscountPrice(double discount) {
return price - (price * discount);
}

/**
* code to update inventory
**/
@override
void updateInventory(int quantity) {
// no-op
}
}

In this flawed snippet code, the FruitOperations interface bundles multiple operations together, forcing the Apple class to implement all of them, even if some operations are not relevant to it. This design violates the Interface Segregation Principle.

GOOD:

abstract class FruitDisplay {
void displayFruit();
}

abstract class FruitDiscount {
double calculateDiscountPrice(double discount);
}

abstract class FruitInventory {
void updateInventory(int quantity);
}

In the snippet above, we’ve segregated the operations into separate interfaces: FruitDisplay, FruitDiscount, and FruitInventory. The Apple the class can now implement only the relevant interfaces, adhering to the Interface Segregation Principle.

class Apple implements FruitDisplay, FruitDiscount {
final String name = 'Apple';
final double price = 2.0;

@override
void displayFruit() {
print('Fruit: $name, Price: $price');
}

@override
double calculateDiscountPrice(double discount) {
return price - (price * discount);
}
}

class InventoryManager implements FruitInventory {
@override
void updateInventory(int quantity) {
// code to update inventory
}
}

In the snippet above, each interface delineates a specific contract, allowing the Apple class and InventoryManager class to implement only the operations pertinent to them. This segregation not only aligns with the Interface Segregation Principle but also fosters a more modular and manageable codebase, simplifying the addition or modification of functionalities in our fruit shop app as it blossoms over time.

D — Dependency Inversion Principle (DIP)

The Dependency Inversion Principle (DIP) encourages high-level modules not to depend on low-level modules, but both should depend on abstractions. This principle is fundamental for promoting a loosely coupled architecture, making the codebase more modular, testable, and flexible. In the context of our fruit shop app, adhering to DIP can significantly facilitate the process of extending or modifying the app’s functionalities without causing widespread disruptions in the codebase.

Let’s illustrate this with a simple example where we follow DIP and another where we don’t:

BAD:

class Inventory {
final List<Fruit> fruits;

Inventory(this.fruits);

void listFruits() {
for (final fruit in fruits) {
print('Fruit: ${fruit.name}, Price: ${fruit.price}');
}
}
}

class FruitShop {
final Inventory inventory;

FruitShop(this.inventory);

void displayInventory() {
inventory.listFruits();
}
}

In this flawed snippet code, the FruitShop class is tightly coupled to the Inventory class, making it hard to adapt to the FruitShop class to changes in the Inventory class.

GOOD:

abstract class FruitListing {
void listFruits();
}

In the snippet above, we’ve introduced an abstraction FruitListing which the Inventory class implements. The FruitShop the class now depends on the abstraction rather than the concrete Inventory class, aligning with the Dependency Inversion Principle.

class Inventory implements FruitListing {
final List<Fruit> fruits;

Inventory(this.fruits);

@override
void listFruits() {
for (final fruit in fruits) {
print('Fruit: ${fruit.name}, Price: ${fruit.price}');
}
}
}

class FruitShop {
final FruitListing fruitListing;

FruitShop(this.fruitListing);

void displayInventory() {
fruitListing.listFruits();
}
}

In the snippet above, the FruitListing abstraction decouples the FruitShop class from the Inventory class, enabling the FruitShop class to interact with different implementations of the FruitListing interface. This not only adheres to the Dependency Inversion Principle but also fosters a more flexible, testable, and maintainable codebase, easing the process of evolving our fruit shop app's capabilities over time.

Wrapping Up

We’ve travelled through the orchard of SOLID principles, exploring how each principle, when applied appropriately, can significantly strengthen the structural integrity and maintainability of our Dart code, especially in a Flutter context with our whimsical fruit shop app as a backdrop.

The SOLID principles are more than mere theoretical concepts. They are the bedrock of a clean, well-organised, and adaptable codebase. By understanding and applying these principles, you not only make your code more understandable to your fellow devs (and yourself of course) but also ensure that your application can grow and evolve.

As we navigated each principle with real-world examples, the essence of a well-structured codebase in a growing Flutter application was disclosed. The beauty of SOLID lies in its simplicity over time and its powerful impact on the code we write.

Whether you’re just starting a new Flutter project or refactoring an existing one, keeping the SOLID principles in mind can be a game changer. It’s a step towards writing code that is not only functional but also clean, manageable, and ready to stand the test of time.

Thank you for reaching the end of this post. I hope this article has shed light on the importance of these fundamental principles and how they can be Dartified in your next Flutter project.

Happy coding!

davthecoder

If you have enjoyed this blog post, remember to follow me and clap this post ;).

--

--