SOLID principles in Flutter

Ahmed Taha ElElemy
9 min readMar 10, 2023

SOLID is an acronym for five design principles that were coined by Robert C. Martin, also known as Uncle Bob. These principles are intended to guide software developers in creating more flexible, maintainable, and scalable software applications. The principles are:

  1. Single Responsibility Principle (SRP)
  2. Open-Closed Principle (OCP)
  3. Liskov Substitution Principle (LSP)
  4. Interface Segregation Principle (ISP)
  5. Dependency Inversion Principle (DIP)

In this article, we will discuss how these principles can be applied in Flutter.

Single Responsibility Principle (SRP) The SRP states that a class should have only one reason to change. In other words, a class should have only one responsibility. This principle helps in keeping the code organized, and it makes it easier to maintain and test the code.

In Flutter, we can apply the SRP by separating our UI code from the business logic code. We can use the Bloc architecture pattern to create a clear separation between the UI and business logic. This pattern allows us to have a single responsibility for each class.

Open-Closed Principle (OCP) The OCP states that a class should be open for extension but closed for modification. This principle helps in keeping the code stable and makes it easier to add new features to the code without changing the existing code.

In Flutter, we can apply the OCP by creating abstract classes and interfaces. We can define the behavior of the class in the abstract class or interface and implement the behavior in the concrete class. This approach allows us to extend the functionality of the class without modifying the existing code.

Liskov Substitution Principle (LSP) The LSP states that a subclass should be able to replace its parent class without changing the behavior of the program. This principle helps in keeping the code flexible and allows us to use polymorphism in our code.

In Flutter, we can apply the LSP by ensuring that our subclasses have the same behavior as the parent class. We can create a common interface or abstract class for our classes, and we can use the interface or abstract class to define the behavior of the classes.

Interface Segregation Principle (ISP) The ISP states that a class should not be forced to implement an interface that it does not need. This principle helps in keeping the code organized and makes it easier to maintain the code.

In Flutter, we can apply the ISP by creating small and focused interfaces. We can create interfaces that have a specific set of methods and properties, and we can use these interfaces to define the behavior of our classes.

Dependency Inversion Principle (DIP) The DIP states that high-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details. Details should depend on abstractions. This principle helps in keeping the code flexible and makes it easier to change the implementation of the code.

In Flutter, we can apply the DIP by using dependency injection. We can define the dependencies of our classes as interfaces, and we can use dependency injection to provide the implementation of the interfaces. This approach allows us to change the implementation of the dependencies without changing the code that uses the dependencies.

now, we will discuss each principle and provide a code example in Flutter.

1- Single Responsibility Principle (SRP)

The Single Responsibility Principle states that a class should only have one responsibility. This principle helps make code more maintainable by reducing complexity and making it easier to change.

For example, let’s consider a screen in a Flutter app that displays a list of users. Instead of having a single class that handles both the UI and business logic, we can separate these concerns into two classes: one for the UI and one for the business logic. Here’s an example:

class UserListScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('User List'),
),
body: UserList(),
);
}
}
class UserList extends StatelessWidget {
@override
Widget build(BuildContext context) {
final userRepository = UserRepository();
final users = userRepository.getUsers();
return ListView.builder(
itemCount: users.length,
itemBuilder: (context, index) {
return ListTile(
title: Text(users[index].name),
);
},
);
}
}
class UserRepository {
List<User> getUsers() {
// Fetch users from a data source (e.g. API, database)
// and return them as a list.
}
}

In this example, UserListScreen handles only the UI concerns of displaying the app bar and rendering the UserList widget. The UserList widget is responsible for fetching and rendering the list of users. Finally, the UserRepository class is responsible for retrieving the list of users from a data source.

2- Open-Closed Principle (OCP)

The Open-Closed Principle states that classes should be open for extension but closed for modification. This principle helps make code more maintainable by allowing new functionality to be added without modifying existing code.

For example, let’s consider a Flutter app that displays a list of products. Instead of creating a single class that handles all the functionality of the product list, we can separate this into multiple classes that follow the Open-Closed Principle. Here’s an example:

abstract class Product {
String name;
double price;
  Widget buildCard();
}
class ProductListScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final products = [
Phone('iPhone 13', 999),
Book('Clean Code', 25),
];
return Scaffold(
appBar: AppBar(
title: Text('Product List'),
),
body: ListView.builder(
itemCount: products.length,
itemBuilder: (context, index) {
final product = products[index];
return product.buildCard();
},
),
);
}
}
class Phone extends Product {
Phone(this.name, this.price);
@override
Widget buildCard() {
return ListTile(
title: Text(name),
subtitle: Text('\$${price.toStringAsFixed(2)}'),
leading: Icon(Icons.phone),
);
}
}
class Book extends Product {
Book(this.name, this.price);
@override
Widget buildCard() {
return ListTile(
title: Text(name),
subtitle: Te('$${price.toStringAsFixed(2)}'),
leading: Icon(Icons.book),
);
}
}

In this example, we have created an abstract `Product` class that defines the common properties and behavior of a product. We have also created two concrete classes, `Phone` and `Book`, that extend the `Product` class and implement the `buildCard` method to render a card for the specific product type.

By following the Open-Closed Principle, we can easily add new product types without modifying existing code. For example, if we wanted to add a `Clothing` product type, we would create a new class that extends `Product` and implements the `buildCard` method to render a card for the clothing product type.

3- Liskov Substitution Principle (LSP)

The LSP states that objects of a superclass should be able to be replaced with objects of its subclass without affecting the correctness of the program. In Flutter, this means that we should be able to use a subclass of a widget in place of its superclass without causing any issues in the app.

Here’s an example of a widget that violates the LSP:

class MyWidget extends StatefulWidget {
final String title;

MyWidget({required this.title});
  @override
_MyWidgetState createState() => _MyWidgetState();
}
class _MyWidgetState extends State<MyWidget> {
@override
Widget build(BuildContext context) {
return Container(
child: Text(widget.title),
width: 100,
height: 50,
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(10),
),
);
}
}
class MyCustomWidget extends MyWidget {
final String subtitle;
MyCustomWidget({required String title, required this.subtitle}) : super(title: title); @override
_MyCustomWidgetState createState() => _MyCustomWidgetState();
}
class _MyCustomWidgetState extends State<MyCustomWidget> {
@override
Widget build(BuildContext context) {
return Container(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(widget.title),
Text(widget.subtitle),
],
),
width: 100,
height: 50,
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(10),
),
);
}
}

In this example, MyCustomWidget extends MyWidget but adds a new subtitle property and overrides the build method to display both title and subtitle using a Column widget. However, if we replace MyWidget with MyCustomWidget in the code, it may not work as expected because MyCustomWidget assumes that the title property will always be present.

To fix this, we can ensure that both MyWidget and MyCustomWidget have the same set of required properties and that MyCustomWidget does not rely on any assumptions about its superclass.

class MyWidget extends StatelessWidget {
final String title;

MyWidget({required this.title});
  @override
Widget build(BuildContext context) {
return Container(
child: Text(title),
width: 100,
height: 50,
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(10),
),
);
}
}
class MyCustomWidget extends StatelessWidget {
final String title;
final String subtitle;
MyCustomWidget({required this.title, required this.subtitle}); @override
Widget build(BuildContext context) {
return Container(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title),
Text(subtitle),
],
),
width: 100,
height: 50,
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(10),
),
);
}
}

Now, MyCustomWidget has the same set of required properties as MyWidget, and does not rely on any assumptions about its superclass. This ensures that we can use MyCustomWidget in place of MyWidget without causing any issues in the app.

4- Interface Segregation Principle (ISP)

The ISP states that a client should not be forced to depend on methods it does not use. In Flutter, this means that we should separate the interfaces of a widget or a class into smaller, more specialized interfaces that are only used by clients that need them.

Here’s an example of a widget that violates the ISP:

class MyWidget extends StatefulWidget {
final String title;
final String subtitle;

MyWidget({required this.title, required this.subtitle});
  @override
_MyWidgetState createState() => _MyWidgetState();
}
class _MyWidgetState extends State<MyWidget> {
bool _isChecked = false;
@override
Widget build(BuildContext context) {
return Container(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(widget.title),
Text(widget.subtitle),
Checkbox(
value: _isChecked,
onChanged: (newValue) {
setState(() {
_isChecked = newValue!;
});
},
),
],
),
width: 100,
height: 100,
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(10),
),
);
}
}

In this example, MyWidget has a title and subtitle property, and also includes a Checkbox widget. However, a client that only needs to display the title and subtitle properties would still have to depend on the Checkbox widget, which violates the ISP.

To fix this, we can split MyWidget into two smaller, more specialized widgets: MyTextWidget and MyCheckboxWidget.

class MyTextWidget extends StatelessWidget {
final String title;
final String subtitle;
  MyTextWidget({required this.title, required this.subtitle});  @override
Widget build(BuildContext context) {
return Container(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title),
Text(subtitle),
],
),
width: 100,
height: 50,
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(10),
),
);
}
}
class MyCheckboxWidget extends StatefulWidget {
final bool isChecked;
final ValueChanged<bool> onChanged;
MyCheckboxWidget({required this.isChecked, required this.onChanged}); @override
_MyCheckboxWidgetState createState() => _MyCheckboxWidgetState();
}
class _MyCheckboxWidgetState extends State<MyCheckboxWidget> {
bool _isChecked = false;
@override
void initState() {
super.initState();
_isChecked = widget.isChecked;
}
@override
Widget build(BuildContext context) {
return Checkbox(
value: _isChecked,
onChanged: (newValue) {
setState(() {
_isChecked = newValue!;
widget.onChanged(newValue);
});
},
);
}
}

Now, a client that only needs to display the title and subtitle properties can use MyTextWidget without depending on the Checkbox widget. A client that needs the Checkbox widget can use MyCheckboxWidget without depending on the title and subtitle properties. This ensures that clients only depend on the methods they need, and reduces unnecessary dependencies between widgets and classes.

5- Dependency Inversion Principle (DIP)

The DIP states that high-level modules should not depend on low-level modules. Instead, both should depend on abstractions. In Flutter, this means that we should use dependency injection to avoid creating tight coupling between different widgets or classes.

Here’s an example of a widget that violates the DIP:

class MyWidget extends StatelessWidget {
final MyService myService = MyService();
  @override
Widget build(BuildContext context) {
return Container(
child: Text(myService.getData()),
width: 100,
height: 50,
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(10),
),
);
}
}
class MyService {
String getData() {
return "Hello, world!";
}
}

In this example, MyWidget depends directly on MyService, which creates tight coupling between the widget and the service. If we later decide to change or replace MyService, we would have to modify MyWidget as well, which violates the DIP.

To fix this, we can use dependency injection to decouple the widget from the service. We can create an abstract class or interface for the service, and use a dependency injection framework like get_it to inject the service into the widget.

abstract class MyService {
String getData();
}
class MyServiceImpl implements MyService {
@override
String getData() {
return "Hello, world!";
}
}
class MyWidget extends StatelessWidget {
final MyService myService = getIt<MyService>();
@override
Widget build(BuildContext context) {
return Container(
child: Text(myService.getData()),
width: 100,
height: 50,
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(10),
),
);
}
}

Now, MyWidget depends only on the abstract class MyService, which can be implemented by any concrete class that implements the getData() method. The getIt function from the get_it package is used to inject an instance of MyServiceImpl into MyWidget. This way, we can easily replace MyServiceImpl with a different implementation of MyService without modifying MyWidget.

By applying the SOLID principles in Flutter, we can create more maintainable, reusable, and extensible code that is easier to test and modify. While it may take some extra time and effort to follow these principles, the benefits in the long run are well worth it. Here’s a quick recap of the SOLID principles in Flutter:

  • Single Responsibility Principle (SRP): A widget or class should have only one reason to change.
  • Open/Closed Principle (OCP): A widget or class should be open for extension but closed for modification.
  • Liskov Substitution Principle (LSP): A subclass should be substitutable for its superclass without affecting the correctness of the program.
  • Interface Segregation Principle (ISP): A client should not be forced to depend on methods it does not use.
  • Dependency Inversion Principle (DIP): High-level modules should not depend on low-level modules. Both should depend on abstractions.

By following these principles, we can create a more flexible and maintainable architecture that can adapt to changing requirements and avoid common pitfalls in software development.

Happy coding!

--

--

Ahmed Taha ElElemy

Passionate Flutter dev & tech enthusiast. Collaborative problem-solver, eager to create innovative solutions. Let's code and build together!