DEV Community

Cover image for Mastering SOLID principles in Flutter
Harsh Bangari Rawat
Harsh Bangari Rawat

Posted on

Mastering SOLID principles in Flutter

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);
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

Concrete Detail Widgets:

  • Create concrete subclasses like NameDetail, IdDetail, DateOfBirthDetail, etc., inheriting from PatientDetail. * Each subclass implements buildDetailWidget 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.
Enter fullscreen mode Exit fullscreen mode

PatientDetails Widget:

  • The PatientDetails widget now holds a list of PatientDetail 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(),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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)),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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)",
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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
  },
);
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
      },
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

2. Concrete Implementations:

  • Create concrete classes like NetworkPatientRepository and LocalPatientRepository implementing PatientDataProvider. 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)
}
Enter fullscreen mode Exit fullscreen mode

3.Dependency Injection:

  • Inject the PatientDataProvider dependency into the PatientDetails 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
      },
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
youngfra profile image
Fraser Young

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! 🚀

Collapse
 
harsh8088 profile image
Harsh Bangari Rawat

Hey, Thanks! I'm glad you found it helpful! 🙏