SOLID principles are a set of guidelines for writing clean, maintainable, and scalable object-oriented code. By applying these principles to your Flutter development, you can create well-structured applications that are easier to understand, modify, and extend in the future. Here's a breakdown of each principle of Flutter development:
1. Single Responsibility Principle (SRP):
The Single Responsibility Principle (SRP) is one of the fundamental principles of object-oriented programming within the SOLID principles. It states that a class should have one, and only one, reason to change.
In simpler terms, a class should be focused on a single functionality and avoid becoming a cluttered mess trying to do too many things. This promotes better code organization, maintainability, and testability.
Let's consider a scenario in a hospital management system. Traditionally, you might have a class named Patient
that handles various functionalities like:
- Storing patient information (name, ID, etc.)
- Performing medical procedures
- Generating bills
- Managing appointments
Following SRP, this approach becomes problematic because the Patient
class has multiple reasons to change. If you need to add a new functionality related to billing, you'd have to modify the Patient
class, potentially affecting other areas that rely on patient information management.
Here's a better approach using SRP:
class Patient {
final String name;
final int id;
final DateTime dateOfBirth;
// Other attributes related to patient information
Patient(this.name, this.id, this.dateOfBirth);
}
class MedicalProcedure {
final String name;
final DateTime date;
final Patient patient;
// Methods to perform or record medical procedures
MedicalProcedure(this.name, this.date, this.patient);
}
class Billing {
final double amount;
final Patient patient;
final List<MedicalProcedure> procedures;
// Methods to calculate and manage bills
Billing(this.amount, this.patient, this.procedures);
}
class Appointment {
final DateTime dateTime;
final Patient patient;
final String doctorName;
// Methods to manage appointments
Appointment(this.dateTime, this.patient, this.doctorName);
}
We have separate classes for Patient
, MedicalProcedure
, Billing
, and Appointment
.
Each class focuses on a specific responsibility:
Patient
- Stores patient information
MedicalProcedure
- Represents a medical procedure performed on a patient
Billing
- Manages bills associated with a patient and procedures
Appointment
- Schedules appointments for patients
2. Open/Closed Principle (OCP):
The Open/Closed Principle (OCP) is a fundamental principle in object-oriented programming that emphasizes extending functionality without modifying existing code.
Here's how we apply in our App:
Our app has a base widget PatientDetails
that displays core patient information like name
, ID
, and date
of birth. Traditionally, you might add logic for displaying additional details like diagnosis or medication history directly within this widget.
However, this approach violates OCP because adding new details requires modifying PatientDetails
. This can become cumbersome and error-prone as the app grows.
Using OCP:
Abstract Base Class (PatientDetail
):
Create an abstract base class PatientDetail
with a method buildDetailWidget
responsible for displaying a specific detail.
abstract class PatientDetail {
final String title;
PatientDetail(this.title);
Widget buildDetailWidget(BuildContext context, Patient patient);
}
Concrete Detail Widgets:
- Create concrete subclasses like
NameDetail
,IdDetail
,DateOfBirthDetail
, etc., inheriting fromPatientDetail
. * Each subclass implementsbuildDetailWidget
to display its specific information.
class NameDetail extends PatientDetail {
NameDetail() : super("Name");
@override
Widget buildDetailWidget(BuildContext context, Patient patient) {
return Text(patient.name);
}
}
// Similarly, create widgets for ID, DateOfBirth, etc.
PatientDetails Widget:
- The
PatientDetails
widget now holds a list ofPatientDetail
objects. - It iterates through the list and uses each detail object's
buildDetailWidget
method to build the UI.
class PatientDetails extends StatelessWidget {
final Patient patient;
final List<PatientDetail> details;
PatientDetails(this.patient, this.details);
@override
Widget build(BuildContext context) {
return Column(
children: details.map((detail) => detail.buildDetailWidget(context, patient)).toList(),
);
}
}
Benefits of OCP approach:
Easy Extension: Adding new details like diagnosis or medication history involves creating a new concrete subclass of PatientDetail with its specific widget logic. No changes are required to PatientDetails.
Flexibility: You can control the order of displayed details by adjusting the order in the details list of PatientDetails
.
Maintainability: Code is cleaner and easier to understand as each detail has a dedicated widget responsible for its presentation.
This is a simplified example. In a real app, you might use a state management solution (like Provider or BLoC) to manage patient data and dynamically update the list of details displayed.
2.Liskov Substitution Principle (LSP)
The Liskov Substitution Principle (LSP) is another cornerstone of SOLID principles. It states that subtypes should be substitutable for their base types without altering the program's correctness. In simpler terms, if you have a base class (or widget in Flutter) and derived classes (subclasses or child widgets), using a derived class wherever you expect the base class should work seamlessly without causing unexpected behavior.
LSP in our App:
Imagine we have a base widget MedicalHistoryTile that displays a patient's medical history entry with generic details like date and title. You might then create a subclass AllergyHistoryTile that inherits from MedicalHistoryTile but also displays information specific to allergies.
We have a base widget MedicalHistoryTile
that displays basic information about a medical history entry. We then create subclasses for specific types of entries, like AllergyHistoryTile
and MedicationHistoryTile
.
Base Class - medical_history_tile.dart:
abstract class MedicalHistoryTile extends StatelessWidget {
final String title;
final DateTime date;
MedicalHistoryTile(this.title, this.date);
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(title),
subtitle: Text(DateFormat.yMMMd().format(date)),
);
}
}
This base class defines the core structure of a medical history tile with title and date. It's abstract because it doesn't implement the build method completely, allowing subclasses to customize the content.
Subclass - allergy_history_tile.dart:
class AllergyHistoryTile extends MedicalHistoryTile {
final String allergen;
AllergyHistoryTile(super.title, super.date, this.allergen);
@override
Widget build(BuildContext context) {
return super.build(context); // Reuse base tile structure
}
Widget getTrailingWidget(BuildContext context) {
return Text(
"Allergen: $allergen",
style: TextStyle(color: Colors.red),
);
}
}
AllergyHistoryTile
inherits from MedicalHistoryTile
and adds an allergen property. It reuses the base class's build
method for the core structure and introduces a new method getTrailingWidget
to display allergy-specific information (red color for emphasis).
Subclass - medication_history_tile.dart:
class MedicationHistoryTile extends MedicalHistoryTile {
final String medication;
final int dosage;
MedicationHistoryTile(super.title, super.date, this.medication, this.dosage);
@override
Widget build(BuildContext context) {
return ListTile(
title: Text(title),
subtitle: Text(DateFormat.yMMMd().format(date)),
trailing: Text(
"Medication: $medication ($dosage mg)",
),
);
}
}
MedicationHistoryTile
inherits from the base class and adds medication
and dosage
properties. It overrides the entire build
method to include medication details in the trailing widget, demonstrating a different approach compared to AllergyHistoryTile
.
Usage and LSP Compliance:
Now, consider using these widgets in your app:
List<MedicalHistoryTile> medicalHistory = [
AllergyHistoryTile("Allergy to Penicillin", DateTime.now(), "Penicillin"),
MedicationHistoryTile("Blood Pressure Medication", DateTime.now().subtract(Duration(days: 30)), "Atenolol", 50),
MedicalHistoryTile("General Checkup", DateTime.now().subtract(Duration(days: 100))),
];
ListView.builder(
itemCount: medicalHistory.length,
itemBuilder: (context, index) {
final entry = medicalHistory[index];
return entry.build(context); // Polymorphism in action
},
);
This code iterates through a list of MedicalHistoryTile
objects (including subclasses). Because both subclasses adhere to the LSP principle, you can use build(context)
on any of them, and it will work as expected. The AllergyHistoryTile
will display its red trailing widget, while MedicationHistoryTile
will use its custom ListTile
with medication
details.
Benefits of LSP Approach:
Consistent Base Behavior: Both subclasses reuse the base class functionality for the core tile structure.
Subclasses Add Specialization: Each subclass adds its specific information (allergen or medication details) without modifying the base class behavior.
Polymorphic Usage: You can treat all instances as MedicalHistoryTile
objects for building the list view, and the appropriate build
method is called based on the actual subclass type.
LSP is not just about methods. It also applies to properties and overall functionality. Subclasses should extend or specialize the behavior of the base class, not deviate from its core purpose.
4.The Interface Segregation Principle (ISP)
The Interface Segregation Principle (ISP) states that clients (widgets in Flutter) should not be forced to depend on methods they do not use. This principle promotes creating smaller, more specific interfaces instead of large, general-purpose ones.
One such scenario in our app where we have a single interface HospitalService
with methods for:
- Getting patient information
- Performing medical procedures
- Scheduling appointments
- Generating bills
This approach violates ISP because a widget responsible for displaying patient information might not need functionalities like scheduling appointments or generating bills. Yet, it would still be forced to depend on the entire HospitalService
interface.
Solution using ISP:
Define Specific Interfaces:
1. Create separate interfaces for each functionality:
-
PatientInfoService
for patient information retrieval. -
MedicalProcedureService
for performing procedures. -
AppointmentService
for scheduling appointments. -
BillingService
for generating bills.
abstract class PatientInfoService {
Future<Patient> getPatient(int patientId);
}
abstract class MedicalProcedureService {
Future<void> performProcedure(String procedureName, Patient patient);
}
abstract class AppointmentService {
Future<Appointment> scheduleAppointment(DateTime dateTime, Patient patient, String doctorName);
}
abstract class BillingService {
Future<Bill> generateBill(Patient patient, List<MedicalProcedure> procedures);
}
2. Implementations and Usage:
- Implement these interfaces in concrete classes that handle the actual functionalities.
- Widgets can then depend on the specific interface they need.
class PatientInfoServiceImpl implements PatientInfoService {
// Implementation for fetching patient information
}
class DisplayPatientInfo extends StatelessWidget {
final int patientId;
DisplayPatientInfo(this.patientId);
@override
Widget build(BuildContext context) {
return FutureBuilder<Patient>(
future: PatientInfoServiceImpl().getPatient(patientId),
builder: (context, snapshot) {
// Display patient information
},
);
}
}
Benefits of LSP approach
Improved Code Maintainability: Smaller interfaces are easier to understand and modify.
Reduced Coupling: Widgets only depend on the functionalities they need.
Enhanced Flexibility: Easier to add new functionalities with new interfaces.
Testability: Easier to unit test specific functionalities through their interfaces.
5.The Dependency Inversion Principle (DIP)
The Dependency Inversion Principle (DIP) states that high-level modules (widgets in Flutter) should not depend on low-level modules (concrete implementations). Both should depend on abstractions (interfaces or abstract classes). This principle encourages loose coupling and improves the testability and maintainability of your code.
Understanding DIP:
The hospital app has a PatientDetails
widget that directly fetches patient information from a PatientRepository
class using network calls. This creates tight coupling between the widget and the repository implementation.
Challenges with Tight Coupling:
Testing: Testing the PatientDetails widget becomes difficult because you need to mock the network calls or the entire PatientRepository.
Changes: If you need to change the data source (e.g., from a local database to a remote API), you would need to modify the PatientDetails widget logic.
Solution using DIP:
Introduce Abstraction:
- Create an interface
PatientDataProvider
that defines methods for fetching patient information.
abstract class PatientDataProvider {
Future<Patient> getPatient(int patientId);
}
2. Concrete Implementations:
- Create concrete classes like
NetworkPatientRepository
andLocalPatientRepository
implementingPatientDataProvider
. These handle fetching data from the network or local storage.
class NetworkPatientRepository implements PatientDataProvider {
// Implementation for fetching patient information from network
}
class LocalPatientRepository implements PatientDataProvider {
// Implementation for fetching patient information from local storage (optional)
}
3.Dependency Injection:
- Inject the
PatientDataProvider
dependency into thePatientDetails
widget constructor.
Use a dependency injection framework (like Provider or BLoC) for managing dependencies in your app.
class PatientDetails extends StatelessWidget {
final int patientId;
final PatientDataProvider patientDataProvider;
PatientDetails(this.patientId, this.patientDataProvider);
@override
Widget build(BuildContext context) {
return FutureBuilder<Patient>(
future: patientDataProvider.getPatient(patientId),
builder: (context, snapshot) {
// Display patient information
},
);
}
}
Benefits of DIP approach
Loose Coupling: Widgets are not tied to specific implementations. You can easily swap out the data source (e.g., use NetworkPatientRepository
or LocalPatientRepository
) without modifying the widget logic.
Improved Testability: You can easily mock the PatientDataProvider interface during unit tests for the PatientDetails widget.
Increased Maintainability: The code becomes more modular and easier to modify in the future.
While we explored the SOLID Principle, there's always more to learn 🧑🏻💻.
What are your biggest challenges with SOLID? Share your thoughts in the comments!
Top comments (2)
Great overview on SOLID principles! Applying these in Flutter is essential for building organized and maintainable apps. Looking forward to implementing these strategies in my projects. Thanks for sharing! 🚀
Hey, Thanks! I'm glad you found it helpful! 🙏