SOLID principles in Flutter
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:
- Single Responsibility Principle (SRP)
- Open-Closed Principle (OCP)
- Liskov Substitution Principle (LSP)
- Interface Segregation Principle (ISP)
- 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.