The SOLID Principles in Mobile App Development: Building Robust and Maintainable Apps (Flutter)
Mobile app development is a rapidly evolving field, with new technologies and frameworks emerging frequently. Amidst this dynamic landscape, software developers face the challenge of building robust and maintainable apps that can adapt to changing requirements. To address these challenges, the SOLID principles provide a set of guiding principles for writing clean, modular, and extensible code. In this article, we will explore the SOLID principles and their significance in mobile app development.
Single Responsibility Principle (SRP)
The SRP states that a class or module should have only one reason to change. In mobile app development, this principle emphasizes breaking down complex functionalities into smaller, cohesive units. By assigning each class a single responsibility, it becomes easier to understand, test, and maintain the codebase. For example, separating user authentication and data persistence into distinct classes ensures clear separation of concerns.
Here’s an example that demonstrates the Single Responsibility Principle (SRP) in a Flutter to-do app. We’ll separate the code into two classes, each with a single responsibility: one for managing the to-do items and another for handling the UI.
// to_do_item.dart
class ToDoItem {
String title;
bool isCompleted;
ToDoItem(this.title, {this.isCompleted = false});
}
// to_do_manager.dart
class ToDoManager {
List<ToDoItem> _toDoItems = [];
void addNewItem(String title) {
final newItem = ToDoItem(title);
_toDoItems.add(newItem);
}
void markItemAsCompleted(int index) {
if (index >= 0 && index < _toDoItems.length) {
_toDoItems[index].isCompleted = true;
}
}
void deleteItem(int index) {
if (index >= 0 && index < _toDoItems.length) {
_toDoItems.removeAt(index);
}
}
List<ToDoItem> get toDoItems => _toDoItems;
}
// main.dart
import 'package:flutter/material.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
final ToDoManager toDoManager = ToDoManager();
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'To-Do App',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: ToDoScreen(toDoManager: toDoManager),
);
}
}
class ToDoScreen extends StatelessWidget {
final ToDoManager toDoManager;
ToDoScreen({required this.toDoManager});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('To-Do List'),
),
body: ListView.builder(
itemCount: toDoManager.toDoItems.length,
itemBuilder: (context, index) {
final item = toDoManager.toDoItems[index];
return ListTile(
title: Text(item.title),
leading: Checkbox(
value: item.isCompleted,
onChanged: (_) => toDoManager.markItemAsCompleted(index),
),
trailing: IconButton(
icon: Icon(Icons.delete),
onPressed: () => toDoManager.deleteItem(index),
),
);
},
),
floatingActionButton: FloatingActionButton(
child: Icon(Icons.add),
onPressed: () {
showDialog(
context: context,
builder: (context) {
String newTitle = '';
return AlertDialog(
title: Text('New To-Do Item'),
content: TextField(
onChanged: (value) => newTitle = value,
),
actions: [
TextButton(
onPressed: () {
toDoManager.addNewItem(newTitle);
Navigator.pop(context);
},
child: Text('Add'),
),
],
);
},
);
},
),
);
}
}
In this example, the ToDoItem
class represents a single to-do item with its properties and the ToDoManager
class handles the management of the to-do items by providing methods to add, mark as completed, and delete items. The ToDoScreen
class is responsible for rendering the UI and interacting with the ToDoManager
to display the list of to-do items and handle user actions like adding and deleting items.
By separating the responsibilities of managing the to-do items and handling the UI, we adhere to the Single Responsibility Principle. The ToDoManager
class focuses solely on managing the to-do items, while the ToDoScreen
class concentrates on rendering the UI and interacting with the user.
Open-Closed Principle (OCP)
The OCP promotes the idea that software entities (classes, modules, etc.) should be open for extension but closed for modification. In mobile app development, this principle emphasizes designing code that can be easily extended without modifying existing code. By using abstractions, interfaces, and dependency injection, developers can add new features or functionalities without altering the existing implementation. For instance, implementing a plugin architecture for adding new functionalities to a mobile app without modifying the core codebase follows the OCP.
Here’s an example that demonstrates the Open-Closed Principle (OCP) in a Flutter to-do app. We’ll design the code to be open for extension but closed for modification by using abstractions and inheritance.
// to_do_item.dart
abstract class ToDoItem {
String title;
bool isCompleted;
ToDoItem(this.title, {this.isCompleted = false});
void toggleCompletion() {
isCompleted = !isCompleted;
}
}
class BasicToDoItem extends ToDoItem {
BasicToDoItem(String title) : super(title);
}
// to_do_manager.dart
class ToDoManager {
List<ToDoItem> _toDoItems = [];
void addNewItem(ToDoItem item) {
_toDoItems.add(item);
}
void deleteItem(ToDoItem item) {
_toDoItems.remove(item);
}
List<ToDoItem> get toDoItems => _toDoItems;
}
// main.dart
import 'package:flutter/material.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
final ToDoManager toDoManager = ToDoManager();
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'To-Do App',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: ToDoScreen(toDoManager: toDoManager),
);
}
}
class ToDoScreen extends StatelessWidget {
final ToDoManager toDoManager;
ToDoScreen({required this.toDoManager});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('To-Do List'),
),
body: ListView.builder(
itemCount: toDoManager.toDoItems.length,
itemBuilder: (context, index) {
final item = toDoManager.toDoItems[index];
return ListTile(
title: Text(item.title),
leading: Checkbox(
value: item.isCompleted,
onChanged: (_) => item.toggleCompletion(),
),
trailing: IconButton(
icon: Icon(Icons.delete),
onPressed: () => toDoManager.deleteItem(item),
),
);
},
),
floatingActionButton: FloatingActionButton(
child: Icon(Icons.add),
onPressed: () {
showDialog(
context: context,
builder: (context) {
String newTitle = '';
return AlertDialog(
title: Text('New To-Do Item'),
content: TextField(
onChanged: (value) => newTitle = value,
),
actions: [
TextButton(
onPressed: () {
final newItem = BasicToDoItem(newTitle);
toDoManager.addNewItem(newItem);
Navigator.pop(context);
},
child: Text('Add'),
),
],
);
},
);
},
),
);
}
}
In this example, we introduce the ToDoItem
abstract class that defines the common properties and behavior for a to-do item. We then create a concrete implementation called BasicToDoItem
. The ToDoManager
class manages a list of ToDoItem
instances and provides methods for adding and deleting items.
By using an abstract class and inheritance, we follow the Open-Closed Principle. The ToDoManager
class is open for extension, as it can work with any subclass of ToDoItem
without modifying its implementation. In the ToDoScreen
class, we interact with the ToDoManager
through the abstraction provided by ToDoItem
. If we want to introduce new types of to-do items, we can simply create new subclasses of ToDoItem
without modifying the existing classes.
This adherence to the Open-Closed Principle allows for easy extension of the codebase. Adding new types of to-do items, such as prioritized or recurring items, can be achieved by creating new subclasses of ToDoItem
and implementing the required behavior. The ToDoManager
class and other parts of the codebase remain unchanged, demonstrating the power of open for extension, closed for modification design principles.
Liskov Substitution Principle (LSP)
The LSP states that objects of a superclass should be replaceable with objects of their subclasses without affecting the correctness of the program. In mobile app development, adhering to the LSP ensures that derived classes can be used interchangeably with their base classes, preserving the expected behavior. This principle allows for easy extensibility and supports the development of modular, reusable code. For example, if a base class represents a generic database handler, any derived class (e.g., SQLite, Firebase) should be able to replace it without causing any issues.
Here’s an example that demonstrates the Liskov Substitution Principle (LSP) in a Flutter to-do app. We’ll create a base class ToDoItem
and its derived classes BasicToDoItem
and PriorityToDoItem
. The derived classes should be substitutable for the base class without affecting the correctness of the program.
// to_do_item.dart
class ToDoItem {
String title;
bool isCompleted;
ToDoItem(this.title, {this.isCompleted = false});
void toggleCompletion() {
isCompleted = !isCompleted;
}
}
class BasicToDoItem extends ToDoItem {
BasicToDoItem(String title) : super(title);
}
class PriorityToDoItem extends ToDoItem {
int priority;
PriorityToDoItem(String title, this.priority) : super(title);
}
// to_do_manager.dart
class ToDoManager {
List<ToDoItem> _toDoItems = [];
void addNewItem(ToDoItem item) {
_toDoItems.add(item);
}
void deleteItem(ToDoItem item) {
_toDoItems.remove(item);
}
List<ToDoItem> get toDoItems => _toDoItems;
}
// main.dart
import 'package:flutter/material.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
final ToDoManager toDoManager = ToDoManager();
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'To-Do App',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: ToDoScreen(toDoManager: toDoManager),
);
}
}
class ToDoScreen extends StatelessWidget {
final ToDoManager toDoManager;
ToDoScreen({required this.toDoManager});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('To-Do List'),
),
body: ListView.builder(
itemCount: toDoManager.toDoItems.length,
itemBuilder: (context, index) {
final item = toDoManager.toDoItems[index];
return ListTile(
title: Text(item.title),
leading: Checkbox(
value: item.isCompleted,
onChanged: (_) => item.toggleCompletion(),
),
trailing: IconButton(
icon: Icon(Icons.delete),
onPressed: () => toDoManager.deleteItem(item),
),
);
},
),
floatingActionButton: FloatingActionButton(
child: Icon(Icons.add),
onPressed: () {
showDialog(
context: context,
builder: (context) {
String newTitle = '';
return AlertDialog(
title: Text('New To-Do Item'),
content: TextField(
onChanged: (value) => newTitle = value,
),
actions: [
TextButton(
onPressed: () {
final newItem = BasicToDoItem(newTitle);
toDoManager.addNewItem(newItem);
Navigator.pop(context);
},
child: Text('Add'),
),
],
);
},
);
},
),
);
}
}
By adhering to the Liskov Substitution Principle, the derived classes BasicToDoItem
and PriorityToDoItem
can be used interchangeably with the base class ToDoItem
. The ToDoManager
class can add, delete, and work with objects of any of these derived classes without affecting the correctness of the program.
Interface Segregation Principle (ISP)
The ISP suggests that client-specific interfaces are better than one general-purpose interface. In mobile app development, this principle encourages the creation of specific interfaces that cater to the needs of individual clients or components. By having smaller, focused interfaces, apps can avoid unnecessary dependencies and coupling between different modules. This allows for better flexibility, maintainability, and testability. For instance, separating the interfaces for user authentication and data retrieval ensures that each client/component only depends on the relevant interface.
Here’s an example that demonstrates the Interface Segregation Principle (ISP) in a Flutter to-do app. We’ll design interfaces to provide specific sets of functionality and implement them in separate classes.
// to_do_item.dart
class ToDoItem {
String title;
bool isCompleted;
ToDoItem(this.title, {this.isCompleted = false});
void toggleCompletion() {
isCompleted = !isCompleted;
}
}
// to_do_storage.dart
abstract class ToDoStorage {
void saveToDoItem(ToDoItem item);
}
class LocalStorage implements ToDoStorage {
@override
void saveToDoItem(ToDoItem item) {
// Save the item to local storage
}
}
class CloudStorage implements ToDoStorage {
@override
void saveToDoItem(ToDoItem item) {
// Save the item to cloud storage
}
}
// to_do_manager.dart
class ToDoManager {
List<ToDoItem> _toDoItems = [];
ToDoStorage _storage;
ToDoManager(this._storage);
void addNewItem(String title) {
final newItem = ToDoItem(title);
_toDoItems.add(newItem);
_storage.saveToDoItem(newItem);
}
void deleteItem(ToDoItem item) {
_toDoItems.remove(item);
}
List<ToDoItem> get toDoItems => _toDoItems;
}
// main.dart
import 'package:flutter/material.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
final ToDoManager toDoManager = ToDoManager(LocalStorage());
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'To-Do App',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: ToDoScreen(toDoManager: toDoManager),
);
}
}
class ToDoScreen extends StatelessWidget {
final ToDoManager toDoManager;
ToDoScreen({required this.toDoManager});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('To-Do List'),
),
body: ListView.builder(
itemCount: toDoManager.toDoItems.length,
itemBuilder: (context, index) {
final item = toDoManager.toDoItems[index];
return ListTile(
title: Text(item.title),
leading: Checkbox(
value: item.isCompleted,
onChanged: (_) => item.toggleCompletion(),
),
trailing: IconButton(
icon: Icon(Icons.delete),
onPressed: () => toDoManager.deleteItem(item),
),
);
},
),
floatingActionButton: FloatingActionButton(
child: Icon(Icons.add),
onPressed: () {
showDialog(
context: context,
builder: (context) {
String newTitle = '';
return AlertDialog(
title: Text('New To-Do Item'),
content: TextField(
onChanged: (value) => newTitle = value,
),
actions: [
TextButton(
onPressed: () {
toDoManager.addNewItem(newTitle);
Navigator.pop(context);
},
child: Text('Add'),
),
],
);
},
);
},
),
);
}
}
Certainly! Here’s an example that demonstrates the Interface Segregation Principle (ISP) in a Flutter to-do app. We’ll design interfaces to provide specific sets of functionality and implement them in separate classes.
// to_do_item.dart
class ToDoItem {
String title;
bool isCompleted;
ToDoItem(this.title, {this.isCompleted = false});
void toggleCompletion() {
isCompleted = !isCompleted;
}
}
// to_do_storage.dart
abstract class ToDoStorage {
void saveToDoItem(ToDoItem item);
}
class LocalStorage implements ToDoStorage {
@override
void saveToDoItem(ToDoItem item) {
// Save the item to local storage
}
}
class CloudStorage implements ToDoStorage {
@override
void saveToDoItem(ToDoItem item) {
// Save the item to cloud storage
}
}
// to_do_manager.dart
class ToDoManager {
List<ToDoItem> _toDoItems = [];
ToDoStorage _storage;
ToDoManager(this._storage);
void addNewItem(String title) {
final newItem = ToDoItem(title);
_toDoItems.add(newItem);
_storage.saveToDoItem(newItem);
}
void deleteItem(ToDoItem item) {
_toDoItems.remove(item);
}
List<ToDoItem> get toDoItems => _toDoItems;
}
// main.dart
import 'package:flutter/material.dart';
void main() {
final ToDoStorage storage = LocalStorage();
final ToDoManager toDoManager = ToDoManager(storage);
runApp(MyApp(toDoManager: toDoManager));
}
class MyApp extends StatelessWidget {
final ToDoManager toDoManager;
MyApp({required this.toDoManager});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'To-Do App',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: ToDoScreen(toDoManager: toDoManager),
);
}
}
class ToDoScreen extends StatelessWidget {
final ToDoManager toDoManager;
ToDoScreen({required this.toDoManager});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('To-Do List'),
),
body: ListView.builder(
itemCount: toDoManager.toDoItems.length,
itemBuilder: (context, index) {
final item = toDoManager.toDoItems[index];
return ListTile(
title: Text(item.title),
leading: Checkbox(
value: item.isCompleted,
onChanged: (_) => item.toggleCompletion(),
),
trailing: IconButton(
icon: Icon(Icons.delete),
onPressed: () => toDoManager.deleteItem(item),
),
);
},
),
floatingActionButton: FloatingActionButton(
child: Icon(Icons.add),
onPressed: () {
showDialog(
context: context,
builder: (context) {
String newTitle = '';
return AlertDialog(
title: Text('New To-Do Item'),
content: TextField(
onChanged: (value) => newTitle = value,
),
actions: [
TextButton(
onPressed: () {
toDoManager.addNewItem(newTitle);
Navigator.pop(context);
},
child: Text('Add'),
),
],
);
},
);
},
),
);
}
}
In this example, we have separate interfaces and classes for different responsibilities. The ToDoStorage
interface defines the contract for saving to-do items, and we have two classes implementing it: LocalStorage
and CloudStorage
. The ToDoManager
class handles the management of to-do items and uses the appropriate storage implementation based on its interface.
By segregating the interfaces, we adhere to the Interface Segregation Principle. The ToDoManager
class is only concerned with adding, deleting, and retrieving to-do items, while the ToDoStorage
interface provides a specific set of methods related to storage. This allows the ToDoManager
class to be independent of the storage implementation and promotes a more modular and maintainable codebase.
Dependency Inversion Principle (DIP)
The DIP emphasizes that high-level modules/classes should not depend on low-level modules/classes directly but instead rely on abstractions. In mobile app development, this principle promotes loose coupling and enables easy replacement of implementations. By relying on interfaces or abstract classes, rather than concrete implementations, changes in low-level modules do not affect high-level modules. This principle facilitates modular development, testability, and extensibility. For example, using dependency injection frameworks in mobile app development allows for easy swapping of implementations without modifying dependent classes.
Certainly! Here’s an example that demonstrates the Dependency Inversion Principle (DIP) in a Flutter to-do app. We’ll use dependency injection to invert the dependencies and decouple the higher-level classes from the lower-level implementations.
// to_do_item.dart
class ToDoItem {
String title;
bool isCompleted;
ToDoItem(this.title, {this.isCompleted = false});
void toggleCompletion() {
isCompleted = !isCompleted;
}
}
// to_do_storage.dart
abstract class ToDoStorage {
void saveToDoItem(ToDoItem item);
}
class LocalStorage implements ToDoStorage {
@override
void saveToDoItem(ToDoItem item) {
// Save the item to local storage
}
}
class CloudStorage implements ToDoStorage {
@override
void saveToDoItem(ToDoItem item) {
// Save the item to cloud storage
}
}
// to_do_manager.dart
class ToDoManager {
List<ToDoItem> _toDoItems = [];
ToDoStorage _storage;
ToDoManager(this._storage);
void addNewItem(String title) {
final newItem = ToDoItem(title);
_toDoItems.add(newItem);
_storage.saveToDoItem(newItem);
}
void deleteItem(ToDoItem item) {
_toDoItems.remove(item);
}
List<ToDoItem> get toDoItems => _toDoItems;
}
// main.dart
import 'package:flutter/material.dart';
void main() {
final ToDoStorage storage = LocalStorage();
final ToDoManager toDoManager = ToDoManager(storage);
runApp(MyApp(toDoManager: toDoManager));
}
class MyApp extends StatelessWidget {
final ToDoManager toDoManager;
MyApp({required this.toDoManager});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'To-Do App',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: ToDoScreen(toDoManager: toDoManager),
);
}
}
class ToDoScreen extends StatelessWidget {
final ToDoManager toDoManager;
ToDoScreen({required this.toDoManager});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('To-Do List'),
),
body: ListView.builder(
itemCount: toDoManager.toDoItems.length,
itemBuilder: (context, index) {
final item = toDoManager.toDoItems[index];
return ListTile(
title: Text(item.title),
leading: Checkbox(
value: item.isCompleted,
onChanged: (_) => item.toggleCompletion(),
),
trailing: IconButton(
icon: Icon(Icons.delete),
onPressed: () => toDoManager.deleteItem(item),
),
);
},
),
floatingActionButton: FloatingActionButton(
child: Icon(Icons.add),
onPressed: () {
showDialog(
context: context,
builder: (context) {
String newTitle = '';
return AlertDialog(
title: Text('New To-Do Item'),
content: TextField(
onChanged: (value) => newTitle = value,
),
actions: [
TextButton(
onPressed: () {
toDoManager.addNewItem(newTitle);
Navigator.pop(context);
},
child: Text('Add'),
),
],
);
},
);
},
),
);
}
}
In this example, we have decoupled the ToDoManager
class from the specific storage implementations using dependency injection. The ToDoManager
class now relies on the ToDoStorage
abstraction instead of concrete implementations like LocalStorage
or CloudStorage
.
In the main()
function, we instantiate the LocalStorage
implementation of ToDoStorage
and pass it to the ToDoManager
constructor. This way, the ToDoManager
class depends on the abstraction, not on the implementation details.
By applying the Dependency Inversion Principle, we promote loose coupling and easier extensibility. It allows us to switch storage implementations or introduce new ones without modifying the higher-level ToDoManager
class.
Conclusion
The SOLID principles provide invaluable guidelines for mobile app developers to write clean, maintainable, and extensible code. By adhering to these principles, developers can create apps that are resilient to change, easy to understand, and straightforward to maintain. Implementing the SOLID principles in mobile app development not only improves code quality but also enhances collaboration among team members, simplifies debugging and testing, and enables scalability. By embracing the SOLID principles, developers can elevate their mobile app development skills and build high-quality apps that stand the test of time.
👏🏽 Give this story a CLAP
👉🏽 Subscribe for upcoming articles
💰 Access Free Mobile Development tutorials
🔔 Follow for more
See you on next article 👋