SOLID Principles in Flutter: Writing Clean and Maintainable Dart Code
Learn how to apply the five SOLID principles in Flutter with practical Dart examples. Write cleaner, more testable, and maintainable mobile applications.
May 26, 2026 (1d ago)
7 min read
SOLID is an acronym for five object-oriented design principles introduced by Robert C. Martin (Uncle Bob). These principles guide developers toward writing code that is easy to maintain, extend, and test. While SOLID originated in the context of enterprise software, it maps naturally to Flutter development — and applying it will make your widgets, services, and state management code significantly cleaner.
In this post, we'll walk through each SOLID principle with Flutter-specific Dart examples: Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, and Dependency Inversion.
S — Single Responsibility Principle
A class should have one, and only one, reason to change.
The Single Responsibility Principle (SRP) states that a class or widget should do one thing and do it well. In Flutter, a common violation is a widget that handles both UI and business logic simultaneously.
Violating SRP:
class UserProfileWidget extends StatefulWidget { final String userId; const UserProfileWidget({required this.userId, super.key}); @override State<UserProfileWidget> createState() => _UserProfileWidgetState(); } class _UserProfileWidgetState extends State<UserProfileWidget> { User? _user; @override void initState() { super.initState(); // Fetching data directly in the widget — two reasons to change _fetchUser(); } Future<void> _fetchUser() async { final response = await http.get(Uri.parse('/api/users/${widget.userId}')); setState(() { _user = User.fromJson(jsonDecode(response.body)); }); } @override Widget build(BuildContext context) { if (_user == null) return const CircularProgressIndicator(); return Text(_user!.name); } }
This widget has two reasons to change: the UI changes, or the data-fetching logic changes. Let's separate the concerns.
Applying SRP:
// Responsibility 1: fetch the user class UserRepository { Future<User> fetchUser(String userId) async { final response = await http.get(Uri.parse('/api/users/$userId')); return User.fromJson(jsonDecode(response.body)); } } // Responsibility 2: manage state class UserProfileNotifier extends StateNotifier<AsyncValue<User>> { final UserRepository _repository; UserProfileNotifier(this._repository) : super(const AsyncValue.loading()) { // load on construction } Future<void> load(String userId) async { state = const AsyncValue.loading(); state = await AsyncValue.guard(() => _repository.fetchUser(userId)); } } // Responsibility 3: display the UI class UserProfileWidget extends ConsumerWidget { final String userId; const UserProfileWidget({required this.userId, super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final userAsync = ref.watch(userProfileProvider(userId)); return userAsync.when( loading: () => const CircularProgressIndicator(), error: (e, _) => Text('Error: $e'), data: (user) => Text(user.name), ); } }
Each class now has exactly one reason to change.
O — Open/Closed Principle
Software entities should be open for extension, but closed for modification.
The Open/Closed Principle (OCP) encourages you to design classes so that new behavior can be added through extension rather than editing existing code. In Flutter, this is commonly applied to widgets and services that need to support multiple variants.
Violating OCP:
class PaymentService { Future<void> processPayment(String method, double amount) async { if (method == 'credit_card') { // process credit card } else if (method == 'paypal') { // process paypal } // Adding a new method requires modifying this class } }
Every time a new payment method is added, this class must change.
Applying OCP:
abstract class PaymentProcessor { Future<void> process(double amount); } class CreditCardProcessor implements PaymentProcessor { @override Future<void> process(double amount) async { // credit card logic } } class PayPalProcessor implements PaymentProcessor { @override Future<void> process(double amount) async { // PayPal logic } } // Adding GoPay requires no modification to existing classes class GoPayProcessor implements PaymentProcessor { @override Future<void> process(double amount) async { // GoPay logic } } class PaymentService { final PaymentProcessor _processor; PaymentService(this._processor); Future<void> processPayment(double amount) => _processor.process(amount); }
Now you can extend payment methods without touching PaymentService.
L — Liskov Substitution Principle
Objects of a superclass should be replaceable with objects of a subclass without breaking the application.
The Liskov Substitution Principle (LSP) ensures that a subclass can stand in for its parent class without breaking behavior. In Flutter and Dart, this typically surfaces when working with abstract classes or interfaces.
Violating LSP:
abstract class Shape { double area(); void resize(double factor); } class Circle extends Shape { double radius; Circle(this.radius); @override double area() => 3.14 * radius * radius; @override void resize(double factor) => radius *= factor; } class ImmutableSquare extends Shape { final double side; ImmutableSquare(this.side); @override double area() => side * side; @override void resize(double factor) { // Cannot resize — throws or does nothing, violating the contract throw UnsupportedError('ImmutableSquare cannot be resized'); } }
ImmutableSquare breaks the substitution because callers expecting Shape to support resize will get a runtime error.
Applying LSP:
abstract class Shape { double area(); } abstract class ResizableShape extends Shape { void resize(double factor); } class Circle extends ResizableShape { double radius; Circle(this.radius); @override double area() => 3.14 * radius * radius; @override void resize(double factor) => radius *= factor; } class ImmutableSquare extends Shape { final double side; ImmutableSquare(this.side); @override double area() => side * side; }
By separating resizable behavior into its own abstraction, both classes are honest about their capabilities.
I — Interface Segregation Principle
Clients should not be forced to depend on interfaces they do not use.
The Interface Segregation Principle (ISP) states that large, fat interfaces should be broken into smaller, focused ones. In Dart, an abstract class acts as an interface, so this principle directly applies.
Violating ISP:
abstract class MediaPlayer { void play(); void pause(); void stop(); void record(); // not all players need this void livestream(); // not all players need this } class AudioPlayer implements MediaPlayer { @override void play() { /* ... */ } @override void pause() { /* ... */ } @override void stop() { /* ... */ } @override void record() => throw UnimplementedError(); // forced to implement @override void livestream() => throw UnimplementedError(); // forced to implement }
Applying ISP:
abstract class Playable { void play(); void pause(); void stop(); } abstract class Recordable { void record(); } abstract class Streamable { void livestream(); } // AudioPlayer only implements what it actually supports class AudioPlayer implements Playable { @override void play() { /* ... */ } @override void pause() { /* ... */ } @override void stop() { /* ... */ } } // A professional studio app can compose all three class StudioRecorder implements Playable, Recordable, Streamable { @override void play() { /* ... */ } @override void pause() { /* ... */ } @override void stop() { /* ... */ } @override void record() { /* ... */ } @override void livestream() { /* ... */ } }
Each interface is focused and clients only depend on what they actually use.
D — Dependency Inversion Principle
High-level modules should not depend on low-level modules. Both should depend on abstractions.
The Dependency Inversion Principle (DIP) is one of the most powerful in Flutter architecture. Instead of high-level classes (like your UI or business logic) directly instantiating concrete implementations, they should depend on abstractions. This makes your code testable and swappable.
Violating DIP:
class OrderBloc { // Directly depends on a concrete implementation final FirebaseOrderRepository _repository = FirebaseOrderRepository(); Future<void> submitOrder(Order order) async { await _repository.save(order); } }
This is hard to test and tightly coupled to Firebase.
Applying DIP:
// The abstraction abstract class OrderRepository { Future<void> save(Order order); Future<List<Order>> fetchAll(); } // A concrete implementation class FirebaseOrderRepository implements OrderRepository { @override Future<void> save(Order order) async { // Firebase logic } @override Future<List<Order>> fetchAll() async { // Firebase logic return []; } } // A mock for testing class MockOrderRepository implements OrderRepository { final List<Order> _store = []; @override Future<void> save(Order order) async => _store.add(order); @override Future<List<Order>> fetchAll() async => List.unmodifiable(_store); } // High-level module depends on the abstraction class OrderBloc { final OrderRepository _repository; OrderBloc(this._repository); // injected, not instantiated Future<void> submitOrder(Order order) => _repository.save(order); } // In production final bloc = OrderBloc(FirebaseOrderRepository()); // In tests final bloc = OrderBloc(MockOrderRepository());
With dependency injection, swapping implementations (or mocking them in tests) requires zero changes to OrderBloc.
Putting It All Together
Applied consistently, SOLID principles lead to Flutter apps that are easier to test, extend, and reason about. Here's a quick reference:
| Principle | Key Question | Flutter Context |
|---|---|---|
| Single Responsibility | Does this class do one thing? | Separate widgets, repositories, and blocs |
| Open/Closed | Can I extend without modifying? | Use abstract classes and polymorphism |
| Liskov Substitution | Can subclasses substitute safely? | Don't override with broken contracts |
| Interface Segregation | Are interfaces small and focused? | Split large abstract classes |
| Dependency Inversion | Do I depend on abstractions? | Inject dependencies via constructors |
SOLID is not a rigid checklist — it's a set of guiding heuristics. Start by applying the Single Responsibility and Dependency Inversion principles, as they deliver the most immediate benefits in testability and maintainability. The rest will follow naturally as your codebase grows.
Here are some other articles you might find interesting.
Subscribe to my newsletter
A periodic update about my life, recent blog posts, how-tos, and discoveries.
NO SPAM. I never send spam. You can unsubscribe at any time!
