Mastering SOLID Principles in Flutter

Jiten Patel
Level Up Coding
Published in
7 min readMay 19, 2024
Image by Pixabay

Hello Folks!

It’s been a long (two years) that I haven’t wrote on Medium. I drafted few of the articles but I never published it (hope will publish it soon). In those past two years, I learned most of the things like setting up CICD for Mobile apps, Fastlane, Microservices, app security, etc. SOLID principles was one of them. In this article will see what SOLID principles is, and how we can apply it in our Flutter projects with example.

What is SOLID Principles?

The SOLID principles are set of five design principles of Object Oriented Programming that helps developers to create maintain and develop a scalable software system. It was first introduced by our famous Uncle Bob (Computer Scientist Robert J. Martin) in 2000. But the SOLID acronym was introduced later by Michael Feathers.

Let’s understand acronym first.

S.O.L.I.D.

S — Single Responsibility Principle

O — Open Closed Principle

L — Liskov Substitution Principle

I — Interface Segregation Principle

D — Dependency Inversion Principle

Single Responsibility Principle (SRP)

The Single Responsibility Principle (SRP) states that a class or module should have one, and only one, reason to change.

Now, how we can apply this to Flutter?

Let's say your company is building an E-commerce app. You’re given a feature to develop to display list of products. You want to ensure that your code follows the SRP by separating concerns into different classes.

First, we’ll create a Product class to represent a single product:

class Product {
final String name;
final double price;
final String description;

Product({required this.name, required this.price, required this.description});
}

Next, we’ll create a ProductRepository class responsible for fetching and managing products:

class ProductRepository {
// methods to fetch, add, and update products
}

Now, let’s create a ProductListScreen widget responsible for displaying the list of products:

class ProductListScreen extends StatelessWidget {
// UI code to display a list of products
}

In this example:

  • Product class represents a single product with its properties.
  • ProductRepository class is responsible for managing and fetching products.
  • ProductListScreen widget is responsible for displaying the list of products

By separating concerns into different classes (Product, ProductRepository, and ProductListScreen), we follow the Single Responsibility Principle. Each class has a clear and distinct responsibility, making the code more modular, maintainable, and easier to understand and test.

Open/Closed Principle (OCP)

The OCP state that Classes should be open for extension but closed for modification.

Let’s apply the above principle to our E-commerce app. After displaying a list of products, now, you’ve given a task to introduce a new feature where a user can filter products by a specific category.

Instead of directly modifying the ProductListScreen widget, which violates the OCP, we'll use the concept of abstraction and inheritance to extend its functionality.

First, we’ll create an abstract class called ProductFilter:

abstract class ProductFilter {
List<Product> applyFilter(List<Product> products);
}

Next, we’ll create a concrete implementation of ProductFilter for filtering products by category:

class CategoryFilter extends ProductFilter {

@override
List<Product> applyFilter(List<Product> products) {
// condition to filter a products
}
}

Now, let’s modify the ProductListScreen widget to accept a ProductFilter parameter:

class ProductListScreen extends StatelessWidget {
final ProductRepository productRepository;
final ProductFilter? productFilter;

const ProductListScreen({Key? key, required this.productRepository, this.productFilter}) : super(key: key);

@override
Widget build(BuildContext context) {
productRepository.fetchProducts();
List<Product> filteredProducts = productFilter != null
? productFilter!.applyFilter(productRepository.products)
: productRepository.products;

// Render the UI
}
}

With these changes, we can now extend the functionality of the product listing without modifying the existing ProductListScreen code. For example, we can create a new filter for products based on their availability

class AvailabilityFilter extends ProductFilter {
final bool available;

AvailabilityFilter(this.available);

@override
List<Product> applyFilter(List<Product> products) {
// Condition for filtering available products
}
}

Then, we can use this new filter in the ProductListScreen without modifying its code:

ProductListScreen(
productRepository: productRepository,
productFilter: AvailabilityFilter(true), // Show only available products
);

This approach adheres to the Open/Closed Principle because we can extend the behavior of the ProductListScreen without modifying its source code. We achieve this by introducing abstraction (ProductFilter), allowing us to add new filters (like CategoryFilter and AvailabilityFilter) without changing the existing implementation of ProductListScreen.

Liskov Substituion Principle (LSP)

The LSP states that objects of a superclass should be replaceable with objects of its subclasses without affecting the correctness of the program.

We can demonstrate the LSP by creating a subclass of Products and ensuring that it can be interchangeably with the Product class.

class DiscountedProduct extends Product {
final double discount;

DiscountedProduct({
required String name,
required double price,
required String description,
required this.discount,
}) : super(name: name, price: price, description: description);
}

In this subclass, we inherit from Product and add an additional property discount.

Next, let’s update the ProductListScreen widget to handle both Product and DiscountedProduct instances:

import 'package:flutter/material.dart';

class ProductListScreen extends StatelessWidget {
final ProductRepository productRepository;
final ProductFilter? productFilter;

const ProductListScreen({Key? key, required this.productRepository, this.productFilter}) : super(key: key);

@override
Widget build(BuildContext context) {
productRepository.fetchProducts();
List<Product> filteredProducts = productFilter != null
? productFilter!.applyFilter(productRepository.products)
: productRepository.products;

return Scaffold(
appBar: AppBar(
title: Text('Product List'),
),
body: ListView.builder(
itemCount: filteredProducts.length,
itemBuilder: (context, index) {
final product = filteredProducts[index];
return ListTile(
title: Text(product.name),
subtitle: Text('${product.price.toStringAsFixed(2)}\n${product.description}'),
trailing: Text(
product is DiscountedProduct
? 'Discounted'
: 'Regular', // Display 'Discounted' for DiscountedProduct, 'Regular' otherwise
),
);
},
),
);
}
}

In this updated ProductListScreen, we've added a check to determine if a product is an instance of DiscountedProduct and display additional information accordingly (e.g., "Discounted" in the UI if it's a DiscountedProduct). This demonstrates that DiscountedProduct can be used interchangeably with Product in the ProductListScreen, fulfilling the Liskov Substitution Principle.

Interface Segregation Principle (ISP)

The ISP states that Clients should not be forced to depend on interfaces they do not use.

Let’s consider a music player as an example. It has two features: playing music and stopping music. Additionally, it offers offline support for playing music, similar to what Spotify does.

Here’s how it will look in code:

// Define the MusicPlayer interface
abstract class MusicPlayer {
void playMusic();
void stopMusic();
}

// Implement OnlineMusicPlayer that plays music from the internet
class OnlineMusicPlayer implements MusicPlayer {
@override
void playMusic() {
print("Playing music from the internet");
}

@override
void stopMusic() {
print("Stopping music from the internet");
}
}

// Implement OfflineMusicPlayer that plays music from local storage
class OfflineMusicPlayer implements MusicPlayer {
@override
void playMusic() {
print("Playing music from local storage");
}

@override
void stopMusic() {
print("Stopping music from local storage");
}
}

Now, let’s create a Flutter widget that uses these music player classes without depending on their specific implementations:

import 'package:flutter/material.dart';

class MusicPlayerWidget extends StatelessWidget {
final MusicPlayer musicPlayer;

MusicPlayerWidget(this.musicPlayer);

@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
onPressed: () {
musicPlayer.playMusic();
},
child: Text('Play Music'),
),
ElevatedButton(
onPressed: () {
musicPlayer.stopMusic();
},
child: Text('Stop Music'),
),
],
);
}
}

Now, you can use MusicPlayerWidget with instances of OnlineMusicPlayer and OfflineMusicPlayer:

void main() {
runApp(MaterialApp(
home: Scaffold(
appBar: AppBar(title: Text('Music Player Example')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Online Music Player'),
MusicPlayerWidget(OnlineMusicPlayer()),
SizedBox(height: 20),
Text('Offline Music Player'),
MusicPlayerWidget(OfflineMusicPlayer()),
],
),
),
),
));
}

This example follows the Interface Segregation Principle by allowing MusicPlayerWidget to interact with both OnlineMusicPlayer and OfflineMusicPlayer through a common interface (MusicPlayer) without depending on their specific implementations.

Dependency Inversion Principle (DIP)

DIP states that high-level modules should not depend on low-level modules; both should depend on abstractions. Here’s how you can apply DIP in Flutter:

First, let’s create an abstract class called DatabaseService that represents a database service. It will have a method fetchData for fetching data from a database.

abstract class DatabaseService {
Future<String> fetchData();
}

Next, let’s create two concrete implementations of this interface: LocalDatabaseService and RemoteDatabaseService.

class LocalDatabaseService implements DatabaseService {
@override
Future<String> fetchData() async {
// Fetching data from a local database
}
}

class RemoteDatabaseService implements DatabaseService {
@override
Future<String> fetchData() async {
// Fetching data from a remote database
}
}

Now, let’s create a class called DataManager that depends on DatabaseService abstraction rather than concrete implementations.

class DataManager {
final DatabaseService databaseService;

DataManager(this.databaseService);

Future<void> fetchData() async {
final data = await databaseService.fetchData();
}
}

Finally, we can use DataManager with different implementations of DatabaseService:

void main() {
final localService = LocalDatabaseService();
final remoteService = RemoteDatabaseService();

final localDataManager = DataManager(localService);
final remoteDataManager = DataManager(remoteService);

localDataManager.fetchData();
remoteDataManager.fetchData();
}

In this example, DataManager doesn't depend on the concrete implementations (LocalDatabaseService and RemoteDatabaseService). Instead, it depends on the abstraction DatabaseService, following the Dependency Inversion Principle. This makes the code more flexible and allows for easier swapping of implementations without modifying the high-level DataManager class.

Let me write on the topic what you want to learn.

Summary

By adhering to these SOLID principles, you can write Flutter apps that are more maintainable, flexible, and scalable, making them easier to understand and extend. These principles help create clean and efficient code, making it a valuable skill for students who are learning Flutter development.

Before you go

Thanks for reading my article and I hope the concepts of SOLID are clear and now you can apply it on your next Flutter project.

And, oh, did you know you can click the clap button a whopping 50 times? If you happen to fancy the article, feel free to give it a round of applause. Your claps are wholeheartedly appreciated!

Read more articles like this on wherejitenblogs.

Have any questions? Let’s get connected on LinkedIn, Twitter, & Instagram

Happy Coding :)

--

--

Full Stack Engineer specializing in Mobile App Development and Machine Learning. Expertise in end-to-end processes from development to deployment.