DEV Community

ThankGod Chibugwum Obobo
ThankGod Chibugwum Obobo

Posted on • Originally published at actocodes.hashnode.dev

The SOLID Principles in Dart: Building Robust Flutter Apps

In the fast-paced world of Flutter development, it is tempting to mix business logic with UI code. However, as your app grows, "quick fixes" lead to rigid codebases where a single change breaks three unrelated features.

The SOLID principles are a set of five design guidelines that ensure your Dart code is flexible, testable, and scalable. Let’s look at how they apply specifically to the Flutter ecosystem.

1. Single Responsibility Principle (SRP)

"A class should have one, and only one, reason to change."

In Flutter, the biggest violation of SRP is the "God Widget" a single StatefulWidget that handles API calls, business logic, and complex UI rendering.

  • The Fix: Separate your logic into a Controller or BLoC and your UI into a StatelessWidget.
  • Result: You can test the business logic without rendering any pixels.

BAD: Violation of Single Responsibility Principle

class User {
  String name;
  String email;

  User(this.name, this.email);

  // Responsibility 1: User data validation
  bool isValidEmail() {
    return email.contains('@') && email.contains('.');
  }

  // Responsibility 2: Database operations
  void saveToDatabase() {
    print('Saving user to database...');
    // Database logic here
  }

  // Responsibility 3: Email notifications
  void sendWelcomeEmail() {
    print('Sending welcome email to $email...');
    // Email sending logic here
  }

  // Responsibility 4: Logging
  void logUserActivity(String activity) {
    print('[$name] $activity');
    // Logging logic here
  }
}
Enter fullscreen mode Exit fullscreen mode

GOOD: Following Single Responsibility Principle

// Responsibility: Hold user data
class User {
  final String name;
  final String email;

  User(this.name, this.email);
}

// Responsibility: Validate user data
class UserValidator {
  bool isValidEmail(String email) {
    return email.contains('@') && email.contains('.');
  }

  bool isValidName(String name) {
    return name.isNotEmpty && name.length >= 2;
  }
}

// Responsibility: Handle database operations for users
class UserRepository {
  void save(User user) {
    print('Saving user ${user.name} to database...');
    // Database logic here
  }

  User? findByEmail(String email) {
    print('Finding user by email: $email');
    // Database query logic here
    return null;
  }
}

// Responsibility: Send email notifications
class EmailService {
  void sendWelcomeEmail(User user) {
    print('Sending welcome email to ${user.email}...');
    // Email sending logic here
  }

  void sendPasswordResetEmail(User user) {
    print('Sending password reset email to ${user.email}...');
    // Email sending logic here
  }
}

// Responsibility: Log application activities
class Logger {
  void logUserActivity(String userName, String activity) {
    final timestamp = DateTime.now();
    print('[$timestamp] [$userName] $activity');
    // Logging logic here
  }

  void logError(String error) {
    print('[ERROR] $error');
    // Error logging logic here
  }
}
Enter fullscreen mode Exit fullscreen mode

2. Open/Closed Principle (OCP)

"Software entities should be open for extension, but closed for modification."

If you need to add a new payment method to your app, you shouldn't have to modify your existing PaymentProcessor class.

  • The Fix: Use Interfaces (Abstract Classes in Dart).
  • Example: Create a PaymentMethod abstract class. Whether you add "Stripe" or "Apple Pay" later, the core logic remains untouched.

BAD: Violation of Open/Closed Principle

class PaymentProcessor {
  void processPayment(String paymentType, double amount) {
    if (paymentType == 'creditCard') {
      print('Processing credit card payment of \$$amount');
      // Credit card processing logic
    } else if (paymentType == 'paypal') {
      print('Processing PayPal payment of \$$amount');
      // PayPal processing logic
    } else if (paymentType == 'stripe') {
      print('Processing Stripe payment of \$$amount');
      // Stripe processing logic
    }
    // Every time we add a new payment method, we need to modify this class!
  }
}
Enter fullscreen mode Exit fullscreen mode

GOOD: Following Open/Closed Principle

// Abstract base class - CLOSED for modification
abstract class PaymentMethod {
  void processPayment(double amount);
  String getPaymentMethodName();
}

// Concrete implementations - OPEN for extension
class CreditCardPayment implements PaymentMethod {
  final String cardNumber;
  final String cardHolderName;

  CreditCardPayment(this.cardNumber, this.cardHolderName);

  @override
  void processPayment(double amount) {
    print('Processing credit card payment of \$$amount');
    // Credit card specific processing logic
  }

  @override
  String getPaymentMethodName() => 'Credit Card';
}

class PayPalPayment implements PaymentMethod {
  final String email;

  PayPalPayment(this.email);

  @override
  void processPayment(double amount) {
    print('Processing PayPal payment of \$$amount');
    // PayPal specific processing logic
  }

  @override
  String getPaymentMethodName() => 'PayPal';
}

// The payment processor is CLOSED for modification
// but OPEN for extension through new PaymentMethod implementations
class PaymentProcessor {
  void process(PaymentMethod paymentMethod, double amount) {
    print('\n--- Starting ${paymentMethod.getPaymentMethodName()} Transaction ---');
    paymentMethod.processPayment(amount);
    print('--- Transaction Complete ---\n');
  }
}
Enter fullscreen mode Exit fullscreen mode

3. Liskov Substitution Principle (LSP)

"Objects of a superclass should be replaceable with objects of its subclasses without breaking the application."

In Dart, if Square inherits from Rectangle, but setting the width of Square also changes its height, it violates LSP.

BAD: Classic LSP Violation - squares/rectangles Problem

class BadQuadrilateral {
  double _width;
  double _height;

  BadQuadrilateral(this._width, this._height);

  double get width => _width;
  double get height => _height;

  set width(double value) => _width = value;
  set height(double value) => _height = value;

  double get area => _width * _height;
}

// VIOLATION: Square changes the behavior of BadQuadrilateral
class BadSquare extends BadRectangle {
  BadSquare(double side) : super(side, side);

  // VIOLATION: Setting width also changes height!
  @override
  set width(double value) {
    _width = value;
    _height = value; // Side effect that violates LSP
  }

  // VIOLATION: Setting height also changes width!
  @override
  set height(double value) {
    _width = value;  // Side effect that violates LSP
    _height = value;
  }
}

// This breaks when we substitute Square for BadQuadrilateral
void demonstrateBadLSP() {
  BadQuadrilateral quad = BadQuadrilateral(5, 10);
  print('Rectangle: ${rect.width} x ${rect.height} = ${rect.area}'); // 5 x 10 = 50

  quad.width = 20;
  print('After width change: ${rect.width} x ${rect.height} = ${rect.area}'); // 20 x 10 = 200

  // Now substitute with Square - BREAKS!
  BadQuadrilateral square = BadSquare(5);
  print('\nSquare: ${square.width} x ${square.height} = ${square.area}'); // 5 x 5 = 25

  square.width = 20;
  // Expected: 20 x 5 = 100 (if it truly behaves like BadQuadrilateral)
  // Actual: 20 x 20 = 400 (BROKEN!)
  print('After width change: ${square.width} x ${square.height} = ${square.area}');
}
Enter fullscreen mode Exit fullscreen mode

GOOD: Proper LSP-compliant design

abstract class Shape {
  double get area;
  String get description;
}

class GoodRectangle implements Shape {
  final double width;
  final double height;

  GoodRectangle(this.width, this.height);

  @override
  double get area => width * height;

  @override
  String get description => 'Rectangle: $width x $height';
}

class GoodSquare implements Shape {
  final double side;

  GoodSquare(this.side);

  @override
  double get area => side * side;

  @override
  String get description => 'Square: $side x $side';
}

void demonstrateGoodLSP() {
  void printShapeInfo(Shape shape) {
    print('${shape.description} = Area: ${shape.area}');
  }

  // Both work perfectly when treated as Shape
  Shape rect = GoodRectangle(5, 10);
  Shape square = GoodSquare(5);

  printShapeInfo(rect);    // Works!
  printShapeInfo(square);  // Works!
}
Enter fullscreen mode Exit fullscreen mode

4. Interface Segregation Principle (ISP)

"Clients should not be forced to depend upon interfaces they do not use."

Avoid creating "fat" abstract classes that force a class to implement methods it doesn't need.

Bad: An interface SmartHomeDevice that forces a LightBulb to implement adjustTemperature().

abstract class SmartHomeDevice {
  void turnOn();
  void turnOff();
  void setTemperature(double temp);
}
Enter fullscreen mode Exit fullscreen mode

Good: Split them into Switchable and Thermostatic interfaces.

abstract class Switchable {
  void turnOn();
  void turnOff();
}

abstract class Thermostatic {
  void setTemperature(double temp);
}
Enter fullscreen mode Exit fullscreen mode

5. Dependency Inversion Principle (DIP)

"Depend upon abstractions, not concretions."

This is the secret to a testable Flutter app. Your UI shouldn't depend on a specific FirebaseService, it should depend on a DatabaseInterface.

  • The Fix: Use Dependency Injection (DI) with packages like get_it or Provider.* The Benefit: During testing, you can easily swap the real ApiService for a MockApiService.

BAD: Violation of Dependency Inversion Principle

class FirebaseDatabaseService {
  Future<List<String>> fetchUsers() async {
    print('Firebase: Fetching users');
    await Future.delayed(const Duration(seconds: 1));
    return ['User1', 'User2', 'User3'];
  }

  Future<void> saveUser(String name) async {
    print('Firebase: Saving user $name');
    await Future.delayed(const Duration(milliseconds: 500));
  }
}

// BAD: UserViewModel depends directly on concrete Firebase implementation
class BadUserViewModel {
  final FirebaseDatabaseService _dbService = FirebaseDatabaseService();

  Future<List<String>> getUsers() async {
    return await _dbService.fetchUsers();
  }

  Future<void> addUser(String name) async {
    await _dbService.saveUser(name);
  }
}
Enter fullscreen mode Exit fullscreen mode

GOOD: Following Dependency Inversion Principle

// Depend on abstractions, not concretions
// Abstractions (interfaces)
abstract class DatabaseService {
  Future<List<String>> fetchUsers();
  Future<void> saveUser(String name);
  Future<void> deleteUser(String name);
}

// Concrete implementations
class FirebaseDatabase implements DatabaseService {
  @override
  Future<List<String>> fetchUsers() async {
    print('Firebase: Fetching users');
    await Future.delayed(const Duration(seconds: 1));
    return ['Alice', 'Bob', 'Charlie'];
  }

  @override
  Future<void> saveUser(String name) async {
    print('Firebase: Saving user $name');
    await Future.delayed(const Duration(milliseconds: 500));
  }

  @override
  Future<void> deleteUser(String name) async {
    print('Firebase: Deleting user $name');
    await Future.delayed(const Duration(milliseconds: 500));
  }
}

// Alternative implementation (easy to swap!)
class LocalDatabase implements DatabaseService {
  final List<String> _users = ['User1', 'User2'];

  @override
  Future<List<String>> fetchUsers() async {
    print('Local: Fetching users');
    return List.from(_users);
  }

  @override
  Future<void> saveUser(String name) async {
    print('Local: Saving user $name');
    _users.add(name);
  }

  @override
  Future<void> deleteUser(String name) async {
    print('Local: Deleting user $name');
    _users.remove(name);
  }
}

// GOOD: ViewModels depend on abstractions
class UserViewModel {
  final DatabaseService _databaseService;

  UserViewModel(this._databaseService); // Dependency injection

  Future<List<String>> getUsers() async {
    return await _databaseService.fetchUsers();
  }

  Future<void> addUser(String name) async {
    await _databaseService.saveUser(name);
  }
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

Applying SOLID in Dart isn't about following dogmatic rules, it's about reducing the cost of change. By ensuring your Flutter components are decoupled and specialized, you build an app that can evolve as fast as the market demands.

Top comments (0)