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.