SOLID Principles in Flutter: Writing Clean and Maintainable Dart Code

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:

PrincipleKey QuestionFlutter Context
Single ResponsibilityDoes this class do one thing?Separate widgets, repositories, and blocs
Open/ClosedCan I extend without modifying?Use abstract classes and polymorphism
Liskov SubstitutionCan subclasses substitute safely?Don't override with broken contracts
Interface SegregationAre interfaces small and focused?Split large abstract classes
Dependency InversionDo 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.

Loading reactions...
Similar Posts

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!

Rafsan's Logo

I'm Rafsan — a Flutter-focused Software Engineer, Assistant Trainer, and workshop speaker. Thanks for visiting!

© 2026 Rafsan