SOLID Principles Explained with Flutter & Dart 🚀
Embark on a journey into the realm of software development excellence as we explore how SOLID principles revolutionize programming with Flutter and Dart. These principles serve as guiding lights, fostering maintainable, scalable, and Agile software practices amidst project evolution.
By embracing SOLID’s Single-responsibility, Open-closed, Liskov Substitution, Interface Segregation, and Dependency Inversion principles, developers can sidestep code smells, streamline refactoring, and cultivate adaptable software solutions.
Single-Responsibility Principle
SRP dictates that a class should have only one reason to change. In other words, it should have only one responsibility or job. This principle promotes code clarity, maintainability, and reusability by ensuring that each class is focused on a specific task. By adhering to SRP, developers can easily understand and modify code, reducing the risk of unintended side effects during changes.
For example, in a Flutter application, a Widget class should be responsible for rendering UI elements and handling user interactions. Mixing data processing or business logic within the Widget violates SRP, leading to bloated and hard-to-maintain code. Instead, segregating responsibilities allows for cleaner and more modular code, enhancing readability and facilitating future updates.
Below is a code sample demonstrating a SRP violation, where a widget is responsible for handling data fetching and validation directly within the UI.
import 'package:flutter/material.dart';
// ...
class _UserProfileState extends State<UserProfile> {
String userName = '';
String userEmail = '';
void fetchUserData() {
// API call to fetch user data
// This method should not be here, violates SRP
}
bool validateUserData() {
// Validate user data
// This method should not be here, violates SRP
}
@override
Widget build(BuildContext context) {
// UI rendering
return Scaffold(
body: Column(
children: [
// Rest of the UI widgets
RaisedButton(
onPressed: () {
fetchUserData(); // Code smell: UI widget should not fetch data
if (validateUserData()) {
// Code smell: UI widget should not validate data
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('User data is valid!'),
),
);
}
},
child: Text('Save'),
),
],
),
);
}
}
After segregating responsibilities, each component focuses on a specific task, enhancing code clarity and maintainability.
// ...
class _UserProfileState extends State<UserProfile> {
String userName = '';
String userEmail = '';
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
// ...
RaisedButton(
onPressed: () {
UserDataRepository.saveUserData(userName, userEmail); // Data saving responsibility
// Data validation logic can be here if necessary
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('User data saved!'),
),
);
},
child: Text('Save'),
),
],
),
);
}
}
class UserDataRepository {
static void saveUserData(String userName, String userEmail) {
// API call to save user data
}
}
Open/Closed Principle
OCP posits that software entities should be open for extension but closed for modification. This means that the behavior of a module or class should be extendable without altering its source code. This principle encourages the use of abstraction and polymorphism to achieve modularity and scalability in software design.
Consider a scenario in a software system where we have a Logger class responsible for logging messages to different destinations such as a file, database, or console. By adhering to the Open/Closed Principle, we can extend the functionality of the Logger class to support new logging destinations like email or cloud storage without modifying its existing implementation. This allows for easy integration of new features while preserving the stability and integrity of the existing codebase, promoting code reusability and maintainability.
Below is a code sample demonstrating a violation of the Open/Closed Principle, where adding new shapes requires modification of the existing code.
class Shape {
void draw() {
print('Drawing a shape');
}
}
class Circle extends Shape {
@override
void draw() {
print('Drawing a circle');
}
}
class Square extends Shape {
@override
void draw() {
print('Drawing a square');
}
}
void main() {
List<Shape> shapes = [Circle(), Square()];
for (var shape in shapes) {
shape.draw(); // Code smell: Modification required for adding new shapes
}
}
By introducing a ShapeDrawer class and abstract Shape interface, new shapes can be seamlessly integrated without altering the existing codebase.
abstract class Shape {
void draw();
}
class Circle implements Shape {
@override
void draw() {
print('Drawing a circle');
}
}
class Square implements Shape {
@override
void draw() {
print('Drawing a square');
}
}
class ShapeDrawer {
void drawShape(Shape shape) {
shape.draw();
}
}
void main() {
ShapeDrawer shapeDrawer = ShapeDrawer();
List<Shape> shapes = [Circle(), Square()];
for (var shape in shapes) {
shapeDrawer.drawShape(shape); // No modification required for adding new shapes
}
}
Liskov Substitution Principle
LSP asserts that objects of a superclass should be replaceable with objects of its subclasses without altering the program’s behavior. This principle ensures that derived classes maintain the same functionality as their base classes, facilitating seamless substitution and promoting code extensibility and maintainability.
Imagine a Flutter application with a base class Shape representing geometric shapes. Subclasses such as Circle, Square and Triangle inherit from Shape. According to LSP, any function expecting a Shape object should seamlessly work with instances of Circle, Square or Triangle without requiring modifications. For instance, a method to render shapes should be able to render any subclass of Shape without needing to alter its implementation.
Below is a code sample for LSP violation, because ElectricCar, as a subclass of Vehicle, is not truly substitutable for Vehicle in all situations.
abstract class Vehicle {
void refuel();
void move();
}
class ElectricCar extends Vehicle {
@override
void refuel() {
print('Charging the battery...');
}
@override
void move() {
print('Moving...');
}
}
class PetrolCar extends Vehicle {
@override
void refuel() {
print('Refilling the petrol...');
}
@override
void move() {
print('Moving...');
}
}
void serviceVehicle(Vehicle vehicle) {
vehicle.refuel();
// Some more servicing activities
}
In the refactored solution, we separated FuelVehicle and ElectricVehicle as two different abstractions, both extending Vehicle. This allows us to create separate service methods for each type of vehicle, ensuring that we don't attempt to perform an action that doesn't make sense for a particular type of vehicle.
abstract class Vehicle {
void move();
}
abstract class FuelVehicle extends Vehicle {
void refuel();
}
abstract class ElectricVehicle extends Vehicle {
void charge();
}
class ElectricCar extends ElectricVehicle {
@override
void charge() {
print('Charging the battery...');
}
@override
void move() {
print('Moving...');
}
}
class PetrolCar extends FuelVehicle {
@override
void refuel() {
print('Refilling the petrol...');
}
@override
void move() {
print('Moving...');
}
}
void serviceFuelVehicle(FuelVehicle vehicle) {
vehicle.refuel();
// Some more servicing activities
}
void serviceElectricVehicle(ElectricVehicle
vehicle) {
vehicle.charge();
// Some more servicing activities
}
Interface Segregation Principle
ISP suggests that clients should not be compelled to depend on interfaces they do not use. In other words, interfaces should be specific to the needs of the clients, avoiding the imposition of unnecessary methods or behaviors.
Consider a flutter application where we have an interface Worker representing different types of workers, such as Designer and Developer. According to ISP, each worker type should have its own interface tailored to its specific responsibilities. For instance, the Designer interface might include methods related to design tasks, while the Developer interface might include methods related to coding tasks. This adherence to ISP ensures that clients, such as project management widgets, only depend on the interfaces they require, preventing unnecessary coupling and facilitating easier extension and modification of the codebase.
Below is a code sample for ISP violation, because it forced the SmartWatch class to depend on methods that it didn't use.
abstract class SmartDevice {
void makeCall();
void sendEmail();
void browseInternet();
void takePicture();
}
class Smartphone implements SmartDevice {
@override
void makeCall() {
print('Making a call...');
}
@override
void sendEmail() {
print('Sending an email...');
}
@override
void browseInternet() {
print('Browsing the Internet...');
}
@override
void takePicture() {
print('Taking a picture...');
}
}
class SmartWatch implements SmartDevice {
@override
void makeCall() {
print('Making a call...');
}
@override
void sendEmail() {
throw UnimplementedError('This device cannot
send emails');
}
@override
void browseInternet() {
throw UnimplementedError('This device cannot
browse the Internet');
}
@override
void takePicture() {
throw UnimplementedError('This device cannot
take pictures');
}
}
In the refactored solution, the SmartDevice interface is segregated into four interfaces: Phone, EmailDevice, WebBrowser, and Camera. The Smartphone class implements all four interfaces, while the SmartWatch class implements only the Phone interface. This way, the SmartWatch class is not forced to implement the sendEmail, browseInternet, and takePicture methods, which it doesn’t need.
abstract class Phone {
void makeCall();
}
abstract class EmailDevice {
void sendEmail();
}
abstract class WebBrowser {
void browseInternet();
}
abstract class Camera {
void takePicture();
}
class SmartWatch implements Phone {
@override
void makeCall() {
print('Making a call...');
}
}
class Smartphone implements Phone, EmailDevice,
WebBrowser, Camera {
@override
void makeCall() {
print('Making a call...');
}
@override
void sendEmail() {
print('Sending an email...');
}
@override
void browseInternet() {
print('Browsing the Internet...');
}
@override
void takePicture() {
print('Taking a picture...');
}
}
Dependency Inversion Principle
DIP suggests that high-level modules should not depend on low-level modules, but rather both should depend on abstractions. Furthermore, it advocates that abstractions should not depend on details, but rather details should depend on abstractions.
In a Flutter application, consider a scenario where we have a DatabaseManager class responsible for interacting with a database. According to DIP, instead of having high-level modules directly depend on DatabaseManager, we can introduce an abstraction such as a DatabaseService interface. High-level modules can then depend on this interface, while the DatabaseManager class implements it. This decouples the high-level modules from the specific implementation details of the database, allowing for easier testing, modification, and maintenance of the codebase.
In this code snippet, the WeatherWidget directly depends on the WeatherApiService, violating DIP. Any changes to the WeatherApiService would require modifications to the WeatherWidget, resulting in tightly coupled code.
class WeatherWidget extends StatelessWidget {
final WeatherApiService weatherApiService = WeatherApiService();
@override
Widget build(BuildContext context) {
// Fetch weather data directly within the widget
var weatherData = weatherApiService.fetchWeatherData();
return Text('Current weather: $weatherData');
}
}
class WeatherApiService {
String fetchWeatherData() {
// Fetch weather data from an external API
return 'Sunny'; // Example data for demonstration
}
}
In this refactored code, the WeatherWidget no longer directly depends on the WeatherApiService, but instead relies on the WeatherService interface. This abstraction layer decouples the WeatherWidget from specific implementation details. Now, any changes to the weather fetching mechanism can be made in the WeatherApiService without affecting the WeatherWidget.
// Abstraction: WeatherService interface
abstract class WeatherService {
String fetchWeatherData();
}
// Low-level module: WeatherApiService
class WeatherApiService implements WeatherService {
@override
String fetchWeatherData() {
// Fetch weather data from an external API
return 'Sunny'; // Example data for demonstration
}
}
// High-level module: WeatherWidget
class WeatherWidget extends StatelessWidget {
final WeatherService weatherService;
WeatherWidget(this.weatherService);
@override
Widget build(BuildContext context) {
// Fetch weather data using the provided WeatherService abstraction
var weatherData = weatherService.fetchWeatherData();
return Text('Current weather: $weatherData');
}
}
In conclusion, the SOLID principles provide invaluable guidance for creating maintainable, scalable, and flexible software. Embracing these principles, such as the Single Responsibility Principle (SRP), Open/Closed Principle (OCP), Liskov Substitution Principle (LSP), Interface Segregation Principle (ISP), and Dependency Inversion Principle (DIP), empowers developers to craft code that is easier to comprehend, modify, and expand.
In the realm of Flutter development, integrating these principles results in cleaner architecture, enhanced code reusability, and refined application design. Through a comprehensive understanding and consistent application of SOLID principles, developers can elevate their Flutter development proficiency and deliver robust and efficient applications.