SOLID Principles in Flutter: Writing Maintainable Dart Code
As Flutter developers, we often focus on building beautiful UIs and smooth animations. But what separates a good Flutter app from a great one? The answer lies in the architecture and maintainability of your codebase. This is where SOLID principles come in.
SOLID is an acronym for five design principles that make software designs more understandable, flexible, and maintainable. While these principles originated in object-oriented programming, they're incredibly relevant to Flutter development. Let's explore each principle with practical Dart examples.
S - Single Responsibility Principle (SRP)
"A class should have one, and only one, reason to change."
In Flutter, this means each class should do one thing well. When a class has multiple responsibilities, changes to one responsibility can affect the others, making your code fragile.
Bad Example
class UserProfile extends StatefulWidget {
@override
_UserProfileState createState() => _UserProfileState();
}
class _UserProfileState extends State<UserProfile> {
User? user;
bool isLoading = false;
@override
void initState() {
super.initState();
loadUser();
}
// This class is doing too much:
// 1. Managing UI state
// 2. Making API calls
// 3. Data validation
// 4. Building UI
Future<void> loadUser() async {
setState(() => isLoading = true);
final response = await http.get(
Uri.parse('https://api.example.com/user/123'),
);
if (response.statusCode == 200) {
final json = jsonDecode(response.body);
if (validateUserData(json)) {
setState(() {
user = User.fromJson(json);
isLoading = false;
});
}
}
}
bool validateUserData(Map<String, dynamic> json) {
return json.containsKey('name') &&
json.containsKey('email') &&
json['email'].contains('@');
}
@override
Widget build(BuildContext context) {
if (isLoading) return CircularProgressIndicator();
if (user == null) return Text('No user found');
return Column(
children: [
Text(user!.name),
Text(user!.email),
],
);
}
}Good Example
// Separate concerns into different classes
// 1. API Service - handles network requests
class UserApiService {
Future<Map<String, dynamic>> fetchUser(String userId) async {
final response = await http.get(
Uri.parse('https://api.example.com/user/$userId'),
);
if (response.statusCode == 200) {
return jsonDecode(response.body);
}
throw Exception('Failed to load user');
}
}
// 2. Validator - handles data validation
class UserValidator {
static bool isValid(Map<String, dynamic> json) {
return json.containsKey('name') &&
json.containsKey('email') &&
json['email'].contains('@');
}
}
// 3. Repository - coordinates data operations
class UserRepository {
final UserApiService _apiService;
final UserValidator _validator;
UserRepository(this._apiService, this._validator);
Future<User> getUser(String userId) async {
final json = await _apiService.fetchUser(userId);
if (!UserValidator.isValid(json)) {
throw Exception('Invalid user data');
}
return User.fromJson(json);
}
}
// 4. Widget - only handles UI
class UserProfile extends StatelessWidget {
final User user;
const UserProfile({required this.user});
@override
Widget build(BuildContext context) {
return Column(
children: [
Text(user.name),
Text(user.email),
],
);
}
}O - Open/Closed Principle (OCP)
"Software entities should be open for extension, but closed for modification."
You should be able to add new functionality without changing existing code. In Flutter, this often means using inheritance, composition, or strategy patterns.
Bad Example
class PaymentProcessor {
void processPayment(String type, double amount) {
if (type == 'credit_card') {
// Process credit card
print('Processing credit card payment: \$${amount}');
} else if (type == 'paypal') {
// Process PayPal
print('Processing PayPal payment: \$${amount}');
} else if (type == 'crypto') {
// Process crypto
print('Processing crypto payment: \$${amount}');
}
// Every time we add a new payment method, we modify this class!
}
}Good Example
// Abstract base class
abstract class PaymentMethod {
void process(double amount);
}
// Concrete implementations
class CreditCardPayment implements PaymentMethod {
@override
void process(double amount) {
print('Processing credit card payment: \$${amount}');
}
}
class PayPalPayment implements PaymentMethod {
@override
void process(double amount) {
print('Processing PayPal payment: \$${amount}');
}
}
class CryptoPayment implements PaymentMethod {
@override
void process(double amount) {
print('Processing crypto payment: \$${amount}');
}
}
// Processor is closed for modification, open for extension
class PaymentProcessor {
void processPayment(PaymentMethod method, double amount) {
method.process(amount);
}
}
// Usage
final processor = PaymentProcessor();
processor.processPayment(CreditCardPayment(), 100.0);
processor.processPayment(PayPalPayment(), 50.0);
// Adding a new payment method doesn't require changing PaymentProcessor
class ApplePayPayment implements PaymentMethod {
@override
void process(double amount) {
print('Processing Apple Pay payment: \$${amount}');
}
}L - Liskov Substitution Principle (LSP)
"Objects of a superclass should be replaceable with objects of its subclasses without breaking the application."
Subclasses should enhance, not replace, the behavior of the parent class. This ensures that your code remains reliable when using polymorphism.
Bad Example
class Bird {
void fly() {
print('Flying...');
}
}
class Penguin extends Bird {
@override
void fly() {
throw Exception('Penguins cannot fly!');
// This violates LSP - replacing Bird with Penguin breaks the contract
}
}
// This will crash!
void makeBirdFly(Bird bird) {
bird.fly(); // Expects all birds to fly
}
makeBirdFly(Penguin()); // Exception!Good Example
// Better abstraction
abstract class Bird {
void move();
}
class FlyingBird extends Bird {
@override
void move() {
fly();
}
void fly() {
print('Flying...');
}
}
class Penguin extends Bird {
@override
void move() {
swim();
}
void swim() {
print('Swimming...');
}
}
// Now this works for all birds
void makeBirdMove(Bird bird) {
bird.move(); // All birds can move, but in different ways
}
makeBirdMove(FlyingBird()); // Flying...
makeBirdMove(Penguin()); // Swimming...Flutter Widget Example
// Bad: Violates LSP
class CustomButton extends ElevatedButton {
CustomButton({required VoidCallback onPressed, required Widget child})
: super(
onPressed: null, // Ignoring the parameter!
child: child,
);
}
// Good: Maintains LSP
class CustomButton extends StatelessWidget {
final VoidCallback? onPressed;
final Widget child;
const CustomButton({
required this.onPressed,
required this.child,
});
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: onPressed,
style: ElevatedButton.styleFrom(
// Custom styling
),
child: child,
);
}
}I - Interface Segregation Principle (ISP)
"Clients should not be forced to depend on interfaces they don't use."
Create specific, focused interfaces rather than one large, general-purpose interface. In Dart, this often means creating smaller abstract classes or using composition.
Bad Example
abstract class Worker {
void code();
void design();
void test();
void deploy();
}
// A developer who only codes is forced to implement all methods
class Developer implements Worker {
@override
void code() {
print('Writing code...');
}
@override
void design() {
throw UnimplementedError('Developers don\'t design');
}
@override
void test() {
throw UnimplementedError('Developers don\'t test');
}
@override
void deploy() {
throw UnimplementedError('Developers don\'t deploy');
}
}Good Example
// Segregated interfaces
abstract class Coder {
void code();
}
abstract class Designer {
void design();
}
abstract class Tester {
void test();
}
abstract class Deployer {
void deploy();
}
// Now implement only what you need
class Developer implements Coder {
@override
void code() {
print('Writing code...');
}
}
class UiDesigner implements Designer {
@override
void design() {
print('Creating designs...');
}
}
// Full-stack developer implements multiple interfaces
class FullStackDeveloper implements Coder, Tester, Deployer {
@override
void code() => print('Writing code...');
@override
void test() => print('Testing code...');
@override
void deploy() => print('Deploying app...');
}Flutter Example with Data Sources
// Bad: Large interface
abstract class DataSource {
Future<List<User>> getUsers();
Future<User> getUserById(String id);
Future<void> saveUser(User user);
Future<void> deleteUser(String id);
Future<List<Post>> getPosts();
Future<Post> getPostById(String id);
Future<void> savePost(Post post);
Future<void> deletePost(String id);
}
// Good: Segregated interfaces
abstract class UserDataSource {
Future<List<User>> getUsers();
Future<User> getUserById(String id);
}
abstract class UserWriteDataSource {
Future<void> saveUser(User user);
Future<void> deleteUser(String id);
}
abstract class PostDataSource {
Future<List<Post>> getPosts();
Future<Post> getPostById(String id);
}
// Implement only what you need
class RemoteUserDataSource implements UserDataSource {
@override
Future<List<User>> getUsers() async {
// API call
}
@override
Future<User> getUserById(String id) async {
// API call
}
}
class LocalUserCache implements UserDataSource, UserWriteDataSource {
@override
Future<List<User>> getUsers() async {
// Read from local storage
}
@override
Future<User> getUserById(String id) async {
// Read from local storage
}
@override
Future<void> saveUser(User user) async {
// Write to local storage
}
@override
Future<void> deleteUser(String id) async {
// Delete from local storage
}
}D - Dependency Inversion Principle (DIP)
"Depend on abstractions, not concretions."
High-level modules shouldn't depend on low-level modules. Both should depend on abstractions. This makes your code testable and flexible.
Bad Example
// High-level module depends on low-level concrete class
class UserRepository {
final FirebaseService _firebaseService = FirebaseService();
Future<User> getUser(String id) async {
return await _firebaseService.fetchUser(id);
// Tightly coupled to Firebase - hard to test or switch providers
}
}
class FirebaseService {
Future<User> fetchUser(String id) async {
// Firebase-specific code
}
}Good Example
// Abstraction
abstract class UserDataSource {
Future<User> fetchUser(String id);
}
// Low-level implementation
class FirebaseUserDataSource implements UserDataSource {
@override
Future<User> fetchUser(String id) async {
// Firebase-specific code
}
}
class AppwriteUserDataSource implements UserDataSource {
@override
Future<User> fetchUser(String id) async {
// Appwrite-specific code
}
}
// High-level module depends on abstraction
class UserRepository {
final UserDataSource _dataSource;
UserRepository(this._dataSource); // Dependency injection
Future<User> getUser(String id) async {
return await _dataSource.fetchUser(id);
// Can work with any data source implementation
}
}
// Usage - easily switch between implementations
final firebaseRepo = UserRepository(FirebaseUserDataSource());
final appwriteRepo = UserRepository(AppwriteUserDataSource());
// For testing, inject a mock
class MockUserDataSource implements UserDataSource {
@override
Future<User> fetchUser(String id) async {
return User(id: id, name: 'Test User');
}
}
final testRepo = UserRepository(MockUserDataSource());Flutter Widget Example
// Bad: Widget depends on concrete implementation
class UserListScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final repository = UserRepository(FirebaseUserDataSource());
// Hardcoded dependency
return FutureBuilder<List<User>>(
future: repository.getUsers(),
builder: (context, snapshot) {
// Build UI
},
);
}
}
// Good: Inject dependency
class UserListScreen extends StatelessWidget {
final UserRepository repository;
const UserListScreen({required this.repository});
@override
Widget build(BuildContext context) {
return FutureBuilder<List<User>>(
future: repository.getUsers(),
builder: (context, snapshot) {
// Build UI
},
);
}
}
// Usage with different implementations
void main() {
runApp(
MyApp(
userRepository: UserRepository(
AppwriteUserDataSource(), // Easy to switch!
),
),
);
}Practical Flutter Architecture with SOLID
Here's how these principles come together in a real Flutter app:
// Domain Layer - Business logic (abstractions)
abstract class AuthRepository {
Future<User> login(String email, String password);
Future<void> logout();
Stream<User?> get authStateChanges;
}
// Data Layer - Implementation details
class AppwriteAuthRepository implements AuthRepository {
final Client _client;
AppwriteAuthRepository(this._client);
@override
Future<User> login(String email, String password) async {
// Appwrite-specific implementation
}
@override
Future<void> logout() async {
// Appwrite-specific implementation
}
@override
Stream<User?> get authStateChanges {
// Appwrite-specific implementation
}
}
// Presentation Layer - State management
class AuthNotifier extends ChangeNotifier {
final AuthRepository _repository;
User? _user;
AuthNotifier(this._repository) {
_repository.authStateChanges.listen((user) {
_user = user;
notifyListeners();
});
}
User? get user => _user;
Future<void> login(String email, String password) async {
try {
_user = await _repository.login(email, password);
notifyListeners();
} catch (e) {
rethrow;
}
}
Future<void> logout() async {
await _repository.logout();
_user = null;
notifyListeners();
}
}
// UI Layer - Widgets
class LoginScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final authNotifier = context.watch<AuthNotifier>();
return Scaffold(
body: LoginForm(
onLogin: (email, password) {
authNotifier.login(email, password);
},
),
);
}
}
// Main - Dependency injection
void main() {
final client = Client();
final authRepository = AppwriteAuthRepository(client);
runApp(
ChangeNotifierProvider(
create: (_) => AuthNotifier(authRepository),
child: MyApp(),
),
);
}Benefits of SOLID in Flutter
- Testability: With dependency injection and abstractions, you can easily mock dependencies for testing
- Maintainability: Each class has a clear, single purpose making the code easier to understand
- Flexibility: Easy to switch implementations (e.g., Firebase to Appwrite) without changing business logic
- Scalability: Adding new features doesn't require modifying existing code
- Team Collaboration: Clear separations make it easier for team members to work on different parts
Common Mistakes to Avoid
- Over-engineering: Don't apply SOLID to every tiny class. Use judgment for when it adds value
- Too many abstractions: Create abstractions when you actually need flexibility, not "just in case"
- Ignoring Flutter patterns: SOLID principles should complement Flutter's reactive patterns, not fight them
- Premature abstraction: Start simple, refactor to SOLID as your app grows and needs become clear
Conclusion
SOLID principles aren't just academic concepts—they're practical tools that make your Flutter apps more maintainable and professional. As your app grows from a simple MVP to a complex production application, these principles become invaluable.
Start by applying them to new code, and gradually refactor existing code when you're making changes anyway. Your future self (and your team) will thank you.
Looking for an experienced Flutter development team that writes clean, maintainable code? Panara Studios specializes in building scalable Flutter applications with 5+ years of expertise. Let's talk about your project.