Skip to content
davthecoder
The SOLID Principles Dartified
tech

The SOLID Principles Dartified

By David Cruz Anaya, Independent Senior Mobile Engineer (Android, Kotlin Multiplatform, Rust)

Updated 11 July 2026
FlutterDartCareerInterview PrepArchitecture
Share:

SOLID Principles in Dart and Flutter illustration

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.

In short: SOLID is five design principles (Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion) that keep a codebase clean, testable, and easy to extend. In this post I apply each one to a Dart and Flutter fruit shop app, showing a bad version and a good version of the code so the difference is concrete.

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: What Is the 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: What Is the 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: How Does the Dependency Inversion Principle (DIP) Work?

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

That is the whole set, applied to one small fruit-shop app in Dart. None of these principles is complicated on its own, and you do not need to apply all five at once. The payoff shows up later: a new feature slots in without a rewrite, or a bug stays trapped in one class instead of leaking across the app.

If you want somewhere to start, pick the messiest file in your current Flutter project and ask which single responsibility it is failing to keep. That one question tends to pull the rest of SOLID along behind it.

Happy coding!

davthecoder


Frequently Asked Questions

What are the SOLID principles?

SOLID is a set of five design principles: Single Responsibility (SRP), Open/Closed (OCP), Liskov Substitution (LSP), Interface Segregation (ISP), and Dependency Inversion (DIP). In this post I demonstrate each one in Dart and Flutter using a fruit shop app, with a bad and a good code example so the difference is clear.

Do I always need to follow SOLID in a Flutter app?

No. Not all apps require strict adherence to these principles. What matters is understanding them and knowing when to use them, because applied at the right moment they can significantly enhance your Flutter codebase.

What does the Single Responsibility Principle mean?

The Single Responsibility Principle advocates that a class should have only one reason to change. In the fruit shop app, that means keeping fruit data in a Fruit class and the total price calculation in an Invoice class, so a change to one does not force a change to the other.

How does the Dependency Inversion Principle work?

The Dependency Inversion Principle encourages high-level modules not to depend on low-level modules; both should depend on abstractions. In the example, FruitShop depends on a FruitListing abstraction rather than the concrete Inventory class, which decouples the two and lets FruitShop work with any implementation of that interface.


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

Share:

Comments

Loading comments…