Skip to content
writing

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.

New writing, now and then

Essays on Flutter, Dart and the decisions behind real systems. No schedule, no filler.

One click to unsubscribe, in every email.