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
}
}
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
}
}
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
PaymentMethodabstract 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!
}
}
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');
}
}
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}');
}
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!
}
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);
}
Good: Split them into Switchable and Thermostatic interfaces.
abstract class Switchable {
void turnOn();
void turnOff();
}
abstract class Thermostatic {
void setTemperature(double temp);
}
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);
}
}
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);
}
}
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)