The SOLID Principles in Dart: Building Robust Flutter Apps
Source: Dev.to
SOLID Principles in Flutter
In the fast‑paced world of Flutter development, it’s tempting to mix business logic with UI code. As your app grows, those “quick fixes” create rigid codebases where a single change can break unrelated features.
The SOLID principles are five design guidelines that keep your Dart code flexible, testable, and scalable. Below is a Flutter‑focused look at each principle.
1. Single Responsibility Principle (SRP)
“A class should have one, and only one, reason to change.”
In Flutter, the biggest SRP violation 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 keep the UI in a StatelessWidget.
Result – You can test the business logic without rendering any pixels.
❌ BAD – SRP Violation
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 – SRP Applied
// 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) =>
email.contains('@') && email.contains('.');
bool isValidName(String name) => 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, you shouldn’t have to edit an existing PaymentProcessor class.
The Fix – Use abstract classes (interfaces) to define contracts that concrete implementations can extend.
❌ BAD – OCP Violation
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 – OCP Applied
// 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 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 logic
}
@override
String getPaymentMethodName() => 'PayPal';
}
// The processor itself is CLOSED for modification
// but OPEN for extension via 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 a Square also changes its height, the subclass no longer behaves like the base class – a classic LSP violation.
❌ BAD – LSP Violation (Square / Rectangle)
class Rectangle {
double width;
double height;
Rectangle(this.width, this.height);
void setWidth(double w) => width = w;
void setHeight(double h) => height = h;
double area() => width * height;
}
// Square inherits from Rectangle but forces width == height
class Square extends Rectangle {
Square(double side) : super(side, side);
@override
void setWidth(double w) {
width = w;
height = w; // Enforces square shape – breaks LSP
}
@override
void setHeight(double h) {
width = h;
height = h; // Enforces square shape – breaks LSP
}
}
✅ GOOD – LSP Respectful Design
// Define a shape contract that does not assume mutable dimensions
abstract class Shape {
double area();
}
// Rectangle implements Shape
class Rectangle implements Shape {
final double width;
final double height;
Rectangle(this.width, this.height);
@override
double area() => width * height;
}
// Square implements Shape independently (no inheritance from Rectangle)
class Square implements Shape {
final double side;
Square(this.side);
@override
double area() => side * side;
}
// Client code can use any Shape without caring about its concrete type
void printArea(Shape shape) {
print('Area: ${shape.area()}');
}
Continue applying the same clean‑up approach for the remaining SOLID principles (Interface Segregation & Dependency Inversion) to keep your Flutter codebase robust and maintainable.
SOLID Principles in Dart
Below are concise examples that illustrate bad and good implementations of three SOLID principles: the Liskov Substitution Principle (LSP), the Interface Segregation Principle (ISP), and the Dependency Inversion Principle (DIP). The code is kept in Dart and the structure of the original content is preserved.
1. Liskov Substitution Principle (LSP)
“Objects of a superclass shall be replaceable with objects of a subclass without affecting the correctness of the program.”
❌ Bad – LSP Violation
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 BadQuadrilateral {
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 rect = BadQuadrilateral(5, 10);
print('Rectangle: ${rect.width} x ${rect.height} = ${rect.area}'); // 5 x 10 = 50
rect.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 behaved like BadQuadrilateral)
// Actual: 20 x 20 = 400 (BROKEN!)
print('After width change: ${square.width} x ${square.height} = ${square.area}');
}
✅ Good – 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);
printShapeInfo(square);
}
2. Interface Segregation Principle (ISP)
“Clients should not be forced to depend upon interfaces they do not use.”
❌ Bad – Fat Interface
abstract class SmartHomeDevice {
void turnOn();
void turnOff();
void setTemperature(double temp); // Not all devices need this
}
✅ Good – Segregated Interfaces
abstract class Switchable {
void turnOn();
void turnOff();
}
abstract class Thermostatic {
void setTemperature(double temp);
}
Now a LightBulb can implement only Switchable, while a Thermostat implements both Switchable and Thermostatic.
3. Dependency Inversion Principle (DIP)
“Depend upon abstractions, not concretions.”
❌ Bad – Direct Dependency on a Concrete Class
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: ViewModel depends directly on the concrete Firebase implementation
class BadUserViewModel {
final FirebaseDatabaseService _dbService = FirebaseDatabaseService();
Future<List<String>> getUsers() async => await _dbService.fetchUsers();
Future<void> addUser(String name) async => await _dbService.saveUser(name);
}
✅ Good – Depend on an Abstraction
// Abstractions (interfaces)
abstract class DatabaseService {
Future<List<String>> fetchUsers();
Future<void> saveUser(String name);
Future<void> deleteUser(String name);
}
// Concrete implementation #1
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));
}
}
// Concrete implementation #2 (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: ViewModel depends on the abstraction, not the concrete class
class GoodUserViewModel {
final DatabaseService _dbService;
// Dependency injection (constructor injection shown here)
GoodUserViewModel(this._dbService);
Future<List<String>> getUsers() async => await _dbService.fetchUsers();
Future<void> addUser(String name) async => await _dbService.saveUser(name);
Future<void> removeUser(String name) async => await _dbService.deleteUser(name);
}
// BAD: ViewModel directly depends on a concrete implementation
class UserViewModel {
final DatabaseService _databaseService = DatabaseService();
Future<List<String>> getUsers() async {
return await _databaseService.fetchUsers();
}
Future<void> addUser(String name) async {
await _databaseService.saveUser(name);
}
void removeUser(String 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.