SOLID principles applied to Flutter code

By Dele Fashola · 10 December 20242,000 views
SOLID principles applied to Flutter code

SOLID principles guide the design of maintainable code. In Flutter, they apply directly to how you structure Blocs, repositories, widgets, and services.

S — Single Responsibility Principle

Every class should have one reason to change.

// WRONG: AuthBloc handles auth AND profile fetching AND analytics
class AuthBloc extends Bloc<AuthEvent, AuthState> {
  Future<void> _onLogin(...) async {
    // Authenticates
    final token = await _authApi.login(email, password);
    // Fetches profile (separate concern)
    final profile = await _profileApi.getProfile(token);
    // Tracks analytics (separate concern)
    await _analytics.logEvent('login_success');
    emit(AuthAuthenticated(token: token, profile: profile));
  }
}

// RIGHT: Each class has one job
class AuthBloc extends Bloc<AuthEvent, AuthState> {
  Future<void> _onLogin(...) async {
    final token = await _authRepository.login(email, password);
    emit(AuthAuthenticated(token: token));
  }
}

// AuthRepository: knows how to authenticate
// ProfileRepository: knows how to fetch user profile
// AnalyticsService: knows how to track events
// These are called separately, not bundled into one bloc

O — Open/Closed Principle

Open for extension, closed for modification.

// WRONG: Adding a new payment method requires modifying existing code
class PaymentService {
  Future<void> pay(PaymentMethod method, double amount) async {
    if (method == PaymentMethod.card) {
      // card logic
    } else if (method == PaymentMethod.paypal) {
      // paypal logic
    }
    // Adding Apple Pay means modifying this method
  }
}

// RIGHT: Extend by adding new implementations, not modifying existing
abstract interface class PaymentProcessor {
  Future<PaymentResult> process(double amount, Map<String, dynamic> metadata);
}

class StripeProcessor implements PaymentProcessor {
  @override
  Future<PaymentResult> process(double amount, Map<String, dynamic> metadata) {
    // Stripe-specific logic
  }
}

class PayPalProcessor implements PaymentProcessor {
  @override
  Future<PaymentResult> process(double amount, Map<String, dynamic> metadata) {
    // PayPal-specific logic
  }
}

// Adding Apple Pay = new class, no changes to existing code
class ApplePayProcessor implements PaymentProcessor { ... }

L — Liskov Substitution Principle

Subclasses must be usable wherever their base type is expected.

// If you accept a ProductRepository, any implementation must work correctly
abstract interface class ProductRepository {
  Future<List<Product>> getProducts();
  Future<Product> getProduct(String id);
}

// Both implementations must honor the contract
class RemoteProductRepository implements ProductRepository { ... }
class CachedProductRepository implements ProductRepository { ... }
class MockProductRepository implements ProductRepository { ... }

// LSP violation: implementation throws when contract says it shouldn't
class LimitedProductRepository implements ProductRepository {
  @override
  Future<Product> getProduct(String id) {
    // WRONG: Contract says return Product, but this always throws
    throw UnimplementedError('Not supported');
  }
}

I — Interface Segregation Principle

Clients should not be forced to depend on methods they don't use.

// WRONG: A fat interface that forces all implementations to handle everything
abstract class DataRepository {
  Future<List<Product>> getProducts();
  Future<void> saveProduct(Product product); // Read-only clients don't need this
  Future<void> deleteProduct(String id);     // Same
  Future<List<Order>> getOrders();
  Future<void> updateOrderStatus(String id, OrderStatus status);
}

// RIGHT: Small, focused interfaces
abstract interface class ProductReader {
  Future<List<Product>> getProducts();
  Future<Product> getProduct(String id);
}

abstract interface class ProductWriter {
  Future<void> saveProduct(Product product);
  Future<void> deleteProduct(String id);
}

// Admin screen needs both:
class AdminProductRepository implements ProductReader, ProductWriter { ... }

// Catalog screen only needs reads:
class CatalogScreen extends ConsumerWidget {
  // Depends on ProductReader, not the fat repository
  // ref.read(productReaderProvider)
}

D — Dependency Inversion Principle

Depend on abstractions, not concretions.

// WRONG: Bloc creates its own dependency — tightly coupled, hard to test
class OrdersBloc extends Bloc<OrdersEvent, OrdersState> {
  OrdersBloc() : super(OrdersInitial()) {
    _repository = RemoteOrderRepository(Dio()); // Hard dependency
  }
  late RemoteOrderRepository _repository;
}

// RIGHT: Dependency injected via constructor
class OrdersBloc extends Bloc<OrdersEvent, OrdersState> {
  OrdersBloc(this._repository) : super(OrdersInitial());
  final OrderRepository _repository; // Abstraction, not concrete class
}

// In production: inject RemoteOrderRepository
// In tests: inject MockOrderRepository
// Same Bloc works with both

SOLID in practice: a complete example

// 1. Interface (Abstraction)
abstract interface class NotificationService {
  Future<void> sendPushNotification(String userId, String title, String body);
  Future<void> sendEmail(String email, String subject, String body);
}

// 2. Concrete implementation
class FirebaseNotificationService implements NotificationService {
  @override
  Future<void> sendPushNotification(...) async { /* FCM logic */ }

  @override
  Future<void> sendEmail(...) async { /* SendGrid logic */ }
}

// 3. Consumer depends on abstraction
class OrderConfirmationService {
  OrderConfirmationService(this._notifications);
  final NotificationService _notifications; // Abstraction

  Future<void> confirm(Order order) async {
    await _notifications.sendPushNotification(
      order.userId,
      'Order Confirmed',
      'Your order #${order.id} is confirmed!',
    );
  }
}

// 4. Wired up in DI (Riverpod)
@riverpod
NotificationService notificationService(NotificationServiceRef ref) =>
    FirebaseNotificationService();

@riverpod
OrderConfirmationService orderConfirmationService(OrderConfirmationServiceRef ref) =>
    OrderConfirmationService(ref.read(notificationServiceProvider));

Common pitfalls

Applying SOLID dogmatically to small classes. A 30-line helper doesn't need an abstract interface. SOLID pays off at scale — in services, repositories, and BLoCs. Over-engineering small utilities adds ceremony without benefit.

Interface for everything. Not every class needs an interface. Create abstractions at system boundaries: external services, databases, device APIs. Internal domain logic usually doesn't need to be mocked.

Comments

No comments yet. Be the first!

Sign in to leave a comment.