Flutter + S.O.L.I.D for high-quality mobile apps
SOLID is a set of design principles for creating maintainable and scalable software.
These principles help ensure that software is flexible, easy to modify, and resistant to bugs and errors. They are widely used in software development, and have been shown to improve code quality and reduce development time.
When used together, Flutter and SOLID can provide developers with a powerful toolkit for creating high-quality mobile apps. By applying SOLID principles to Flutter app development, developers can create modular, maintainable, and scalable code that is easy to test and debug.
In the rest of this article, we’ll explore how Flutter and SOLID are related and how you can apply SOLID principles to your Flutter projects to create better, more maintainable code.
How Flutter and S.O.L.I.D are related ?
Flutter and SOLID are related in a few different ways. First, Flutter is designed to make it easy to create modular and reusable components, which is a key goal of SOLID. By breaking down an app into smaller, more focused components, developers can create code that is easier to understand, modify, and test.
1.SRP( Single Responsibility Principle) :
The Single Responsibility Principle (SRP) encourages developers to create classes or modules that have only one responsibility. In Flutter, this could mean creating widgets that have a specific and focused role, such as a widget that handles user input or a widget that displays data.
Let’s say we have a Flutter app that displays a list of weather forecasts. The app needs to fetch the weather data from an external API and display it to the user.
We want to follow SRP by separating the responsibilities of fetching and displaying the weather data into two different classes.
First, we can create a WeatherService class that is responsible for fetching the weather data from the API:
class WeatherService {
final String _apiKey;
final http.Client _client;
WeatherService({required String apiKey, http.Client? client})
: _apiKey = apiKey,
_client = client ?? http.Client();
Future<WeatherData> getWeatherData(String city) async {
final url =
'https://api.openweathermap.org/data/2.5/weather?q=$city&appid=$_apiKey';
final response = await _client.get(Uri.parse(url));
if (response.statusCode == 200) {
return WeatherData.fromJson(jsonDecode(response.body));
} else {
throw Exception('Failed to fetch weather data');
}
}
}
2.OCP(Open-Closed Principle):
The Open-Closed Principle (OCP) is a principle in object-oriented design that states that software entities should be open for extension but closed for modification. In other words, we should strive to design our code in a way that makes it easy to add new functionality without having to modify existing code.
And here, we want to follow OCP by allowing the app to display the weather data in different ways without modifying the original code.
We can create an abstract WeatherDisplay class that defines a build method:
abstract class WeatherDisplay {
Widget build(BuildContext context, WeatherData data);
}
Then, we can create concrete subclasses of WeatherDisplay that implement the build method in different ways.
For example:
class TemperatureDisplay extends WeatherDisplay {
@override
Widget build(BuildContext context, WeatherData data) {
final temperature = data.temperature;
return Text(
'${temperature.toStringAsFixed(1)}\u00B0C',
style: TextStyle(fontSize: 20),
);
}
}
class HumidityDisplay extends WeatherDisplay {
@override
Widget build(BuildContext context, WeatherData data) {
final humidity = data.humidity;
return Text(
'Humidity: ${humidity.toStringAsFixed(1)}%',
style: TextStyle(fontSize: 20),
);
}
}
Finally, we can display the weather data to accept an instance of WeatherDisplay. This way, we can pass in different WeatherDisplay objects to display the weather data in different ways:
class WeatherPage extends StatefulWidget {
final String city;
final WeatherService service;
final WeatherDisplay display;
const WeatherPage(
{required this.city, required this.service, required this.display});
@override
_WeatherPageState createState() => _WeatherPageState();
}
class _WeatherPageState extends State<WeatherPage> {
late Future<WeatherData> _futureWeatherData;
@override
void initState() {
super.initState();
_futureWeatherData = widget.service.getWeatherData(widget.city);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Weather in ${widget.city}'),
),
body: Center(
child: FutureBuilder<WeatherData>(
future: _futureWeatherData,
builder: (context, snapshot) {
if (snapshot.hasData) {
return widget.display.build(context, snapshot.data!);
} else if (snapshot.hasError) {
return Text('${snapshot.error}');
} else {
return CircularProgressIndicator();
}
},
),
),
);
}
}
3.LSP(Liskov Substitution Principle):
Liskov Substitution Principle (LSP) states that any instance of a subtype should be able to be used in place of an instance of its parent type without affecting the correctness of the program.
To follow the Liskov Substitution Principle, we want to make sure that any subtype of WeatherDisplay can be used interchangeably with the WeatherDisplay class.
To do this, we can make sure that any subclass of WeatherDisplay implements the build method exactly as defined in the WeatherDisplay class.
abstract class WeatherDisplay {
Widget build(BuildContext context, WeatherData data);
}
class TemperatureDisplay extends WeatherDisplay {
@override
Widget build(BuildContext context, WeatherData data) {
final temperature = data.temperature;
return Text(
'${temperature.toStringAsFixed(1)}\u00B0C',
style: TextStyle(fontSize: 20),
);
}
}
class HumidityDisplay extends WeatherDisplay {
@override
Widget build(BuildContext context, WeatherData data) {
final humidity = data.humidity;
return Text(
'Humidity: ${humidity.toStringAsFixed(1)}%',
style: TextStyle(fontSize: 20),
);
}
}
4.ISP(Interface Segregation Principle):
Interface Segregation Principle (ISP) states that a class should not be forced to depend on methods it does not use.
To follow the Interface Segregation Principle, we want to make sure that any class that implements an interface only implements the methods that are relevant to its responsibilities.
In our example, we can create an interface WeatherDataSource that defines a single method getWeatherData and have WeatherService implement it:
abstract class WeatherDataSource {
Future<WeatherData> getWeatherData(String city);
}
class WeatherService implements WeatherDataSource {
final String _apiKey;
final http.Client _client;
WeatherService({required String apiKey, http.Client? client})
: _apiKey = apiKey,
_client = client ?? http.Client();
@override
Future<WeatherData> getWeatherData(String city) async {
final url =
'https://api.openweathermap.org/data/2.5/weather?q=$city&appid=$_apiKey';
final response = await _client.get(Uri.parse(url));
if (response.statusCode == 200) {
return WeatherData.fromJson(jsonDecode(response.body));
} else {
throw Exception('Failed to fetch weather data');
}
}
}
5.DIP(Dependency Inversion Principle):
Dependency Inversion Principle (DIP) states that high-level modules should not depend on low-level modules. Both should depend on abstractions.
To follow the Dependency Inversion Principle, we want to make sure that high-level modules depend on abstractions rather than concrete implementations.
In our example, we can create a WeatherRepository class that depends on WeatherDataSource, allowing us to easily switch between different implementations of WeatherDataSource without changing the WeatherRepository code:
class WeatherRepository {
final WeatherDataSource dataSource;
WeatherRepository(this.dataSource);
Future<WeatherData> getWeatherData(String city) =>
dataSource.getWeatherData(city);
}
Finally, we can modify the WeatherPage class to use WeatherRepository instead of WeatherService, which allows us to easily switch between different implementations of WeatherDataSource:
class WeatherPage extends StatefulWidget {
final String city;
final WeatherRepository repository;
final WeatherDisplay display;
const WeatherPage(
{required this.city, required this.repository, required this.display});
@override
_WeatherPageState createState() => _WeatherPageState();
}
class _WeatherPageState extends State<WeatherPage> {
late Future<WeatherData> _futureWeatherData;
@override
void initState() {
super.initState();
_futureWeatherData = widget.repository.getWeatherData(widget.city);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Weather in ${widget.city}'),
),
body: Center(
child: FutureBuilder<WeatherData>(
future: _futureWeatherData,
builder: (context, snapshot) {
if (snapshot.hasData) {
return widget.display.build(context, snapshot.data!);
} else if (snapshot.hasError) {
return Text('${snapshot.error}');
} else {
return CircularProgressIndicator();
}
},
),
),
);
}
}
Conclusion
In conclusion, By applying SOLID principles to Flutter development, we can create mobile applications that are easy to maintain, flexible, and scalable, and that can adapt to changing requirements and user needs over time.
Some key points to remember when applying SOLID principles to Flutter development include:
- SRP (Single Responsibility Principle): A class should have only one reason to change.
- OCP (Open-Closed Principle): Software entities should be open for extension but closed for modification.
- LSP (Liskov Substitution Principle): Subtypes should be substitutable for their base types.
- ISP (Interface Segregation Principle): Clients should not be forced to depend on interfaces they do not use.
- DIP (Dependency Inversion Principle): High-level modules should not depend on low-level modules. Both should depend on abstractions.
To learn more about Flutter and SOLID, readers can explore online resources, take courses, attend workshops, and read books on the subject. Some recommended resources include the official Flutter documentation and the SOLID principles website.