August 4, 2025 · 3 min read
SOLID Principles in Dart & Flutter
Five principles for code that survives change, each with a practical Dart example — and why 'hard to test' usually means you're breaking one of them.
SOLID is five principles for writing code that survives change. They're not Flutter-specific, but they map cleanly onto Dart, and applying them is what keeps a growing app from turning into a pile of if statements. Here's each one with a Dart example.
S — Single Responsibility
A class should have one reason to change. When a widget fetches data, formats it, and paints it, every requirement touches the same file.
// Too many jobs: HTTP, parsing, and caching in one place.
class UserService {
Future<User> load(String id) async { /* http + json + cache */ }
}
// Split by reason-to-change.
class UserApi { Future<Map<String, dynamic>> fetch(String id) => ...; }
class UserCache { Future<void> write(User u); Future<User?> read(String id); }
class UserRepository {
UserRepository(this._api, this._cache);
final UserApi _api;
final UserCache _cache;
Future<User> load(String id) async =>
await _cache.read(id) ?? User.fromJson(await _api.fetch(id));
}
O — Open/Closed
Open for extension, closed for modification. You should add behavior without editing existing, tested code.
abstract interface class Discount {
double apply(double total);
}
class PercentOff implements Discount {
PercentOff(this.percent);
final double percent;
@override
double apply(double total) => total * (1 - percent / 100);
}
class FlatOff implements Discount {
FlatOff(this.amount);
final double amount;
@override
double apply(double total) => (total - amount).clamp(0, total);
}
double checkout(double total, List<Discount> discounts) =>
discounts.fold(total, (sum, d) => d.apply(sum));
Adding a BuyOneGetOne discount means writing a new class — not touching checkout.
L — Liskov Substitution
A subtype must be usable anywhere its supertype is expected, without surprises.
// Violation: Square silently breaks Rectangle's contract.
class Rectangle {
Rectangle(this.width, this.height);
double width, height;
double area() => width * height;
}
// A Square that forces width == height breaks code that sets them independently.
The fix is usually composition or a shared abstraction (Shape with area()) rather than forcing an "is-a" relationship that doesn't hold.
I — Interface Segregation
Many small interfaces beat one fat one. Clients shouldn't depend on methods they never call.
// Fat: not every data source can do all of these.
abstract interface class Repository<T> {
Future<T> read(String id);
Future<void> write(T item);
Future<void> sync();
}
// Segregated: implement only what a source supports.
abstract interface class Readable<T> { Future<T> read(String id); }
abstract interface class Writable<T> { Future<void> write(T item); }
D — Dependency Inversion
High-level code should depend on abstractions, not concrete details. This is the principle that makes testing and swapping implementations trivial.
// Domain layer defines the abstraction it needs.
abstract interface class AuthRepository {
Future<User> signIn(String email, String password);
}
// Data layer provides the concrete implementation.
class SupabaseAuthRepository implements AuthRepository {
SupabaseAuthRepository(this._client);
final SupabaseClient _client;
@override
Future<User> signIn(String email, String password) async { /* ... */ }
}
// The use case depends on the interface — not on Supabase.
class SignIn {
SignIn(this._repo);
final AuthRepository _repo;
Future<User> call(String email, String password) => _repo.signIn(email, password);
}
Wire the concrete type in at the edges with a service locator like get_it or a provider. In tests, inject a fake AuthRepository and your use case never knows the difference.
The payoff
SOLID isn't about ceremony — it's about isolating change. Get the seams right and new features slot in, bugs stay local, and tests become easy to write. That last point is the tell: if something is hard to test, it's usually violating one of these five.