← Articles

SOLID principles applied to Flutter code

By John · 10 December 2024

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.

Sign in to like, dislike, or report.

SOLID principles applied to Flutter code — ANN Tech