Skip to main content

Command Palette

Search for a command to run...

How You Use SOLID Principles in Flutter Without Even Knowing It

Updated
12 min read

If you’ve built more than a couple of Flutter screens, chances are you’ve already been applying SOLID principles without even realising it. Flutter naturally pushes us toward clean, modular design: breaking big widgets into smaller ones, separating responsibilities, and reusing components across the app.

But here’s the thing — those habits aren’t just “good practices.” They’re actually the foundation of SOLID, a set of five design principles created to help developers write maintainable, scalable software. Even if you’ve never studied them formally, you’re most likely already applying them in your day-to-day Flutter work. Every time you create a small widget that handles one job well, or design a class that can be extended without rewriting its core logic, you’re practicing SOLID.

Before we dive into the Flutter examples, let’s quickly step back. SOLID is an acronym introduced by Robert C. Martin (Uncle Bob), and it stands for:

  • Single Responsibility

  • Open/Closed

  • Liskov Substitution

  • Interface Segregation

  • Dependency Inversion

On their own, each principle is straightforward. Together, they form a powerful mindset for designing code that’s easier to test, extend, and maintain.

In this post, I want to take a closer look at each of the five principles and show how they quietly shape the way we build Flutter apps. I’m not going into heavy theory here; instead, I’ll use simple examples that most of us have probably written before. My goal is to show that SOLID isn’t some abstract concept you only read about in textbooks — it’s something you’re already using, and once you recognise it, you can apply it more intentionally to make your code even stronger.


Single Responsibility Principle (SRP)

The Single Responsibility Principle says that a class or function should have only one reason to change. In simpler words: do one thing, and do it well.

In Flutter, this often happens naturally. When we start out, it’s tempting to throw everything into one big widget — UI, state, layout, and logic all living in a single file. But as soon as that widget grows too complex, we split it into smaller parts: one widget for displaying an image, another for showing a title, and maybe a separate widget for a list item. Each of these smaller widgets now has a single responsibility, which makes them easier to test, reuse, and maintain.

Problem

When we’re moving fast, it’s common to throw everything into a single widget. It works fine at first, but as that widget grows, it becomes harder to read, harder to test, and more painful to change later. Take this simple example of a ProductTile widget:

class ProductTile extends StatelessWidget {
  const ProductTile({
    super.key,
    required this.title,
    required this.imageUrl,
    required this.price,
  });

  final String title;
  final String imageUrl;
  final double price;

  @override
  Widget build(BuildContext context) {
    return Row(
      children: [
        Image.network(
          imageUrl,
          width: 50,
          height: 50,
        ),
        const SizedBox(
          width: 8,
        ),
        Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              title,
              style: Theme.of(context).textTheme.titleMedium,
            ),
            Text(
              '\$${price.toStringAsFixed(2)}',
            ),
          ],
        ),
      ],
    );
  }
}

Applying SRP by Splitting Responsibilities

To follow the Single Responsibility Principle, we can refactor the widget into smaller building blocks. Each widget now takes care of only one concern:

class ProductImage extends StatelessWidget {
  const ProductImage({
    super.key,
    required this.imageUrl,
  });

  final String imageUrl;

  @override
  Widget build(BuildContext context) {
    return Image.network(
      imageUrl,
      width: 50,
      height: 50,
    );
  }
}

class ProductInfo extends StatelessWidget {
  const ProductInfo({
    super.key,
    required this.title,
    required this.price,
  });

  final String title;
  final double price;

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: <Widget>[
        Text(
          title,
          style: Theme.of(context).textTheme.titleMedium,
        ),
        Text(
          '\$${price.toStringAsFixed(2)}',
        ),
      ],
    );
  }
}

class ProductTile extends StatelessWidget {
  const ProductTile({
    super.key,
    required this.title,
    required this.imageUrl,
    required this.price,
  });

  final String title;
  final String imageUrl;
  final double price;

  @override
  Widget build(BuildContext context) {
    return Row(
      children: <Widget>[
        ProductImage(
          imageUrl: imageUrl,
        ),
        const SizedBox(width: 8),
        ProductInfo(
          title: title,
          price: price,
        ),
      ],
    );
  }
}

Now we’ve split the responsibilities:

  • ProductImage → handles only how the product image is displayed

  • ProductInfo → handles only how the title and price are shown

  • ProductTile → simply coordinates the layout

Why this matters?

The functionality hasn’t changed, but the design is far more flexible. If we ever want to change how prices are displayed (e.g., add a discount or localise the currency), we only touch ProductInfo. If we decide to switch from Image.network to a cached image widget, we update only ProductImage.

That’s the power of the Single Responsibility Principle: by giving each class or widget one clear job, our code becomes easier to understand, easier to test, and easier to extend. And while this is just a small UI example, the same idea applies everywhere — from services and repositories to complex business logic.


Open/Closed Principle (OCP)

The Open/Closed Principle says: classes should be open for extension, but closed for modification.

In Flutter, this often shows up when you want to create a reusable widget. You don’t want to keep editing the widget every time you need a small variation — instead, you design it so it can be extended or configured without touching the original code.

Problem

It’s tempting to add flags and conditions inside a widget to cover all cases. This works at first, but over time the widget grows harder to maintain. Every new variant means editing the same class again.

class CustomButton extends StatelessWidget {
  const CustomButton({
    super.key,
    required this.label,
    this.isPrimary = false,
    this.isDanger = false,
  });

  final String label;
  final bool isPrimary;
  final bool isDanger;

  @override
  Widget build(BuildContext context) {
    Color background;
    if (isPrimary) {
      background = Colors.blue;
    } else if (isDanger) {
      background = Colors.red;
    } else {
      background = Colors.grey;
    }

    return ElevatedButton(
      style: ElevatedButton.styleFrom(
        backgroundColor: background,
      ),
      onPressed: () {},
      child: Text(label),
    );
  }
}

This works, but every new button type (success, warning, outline, etc.) forces us to modify the class by adding more flags and conditions.

Instead, we can design the widget so it’s configurable through parameters or by subclassing/extending styles. The base widget stays the same.

class CustomButton extends StatelessWidget {
  const CustomButton({
    super.key,
    required this.label,
    required this.background,
    required this.onPressed,
  });

  final String label;
  final Color background;
  final VoidCallback onPressed;

  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      style: ElevatedButton.styleFrom(
        backgroundColor: background,
      ),
      onPressed: onPressed,
      child: Text(label),
    );
  }
}

Now, you extend behaviour outside the widget:

// Reuse without modifying the widget
final primaryButton = CustomButton(
  label: "Continue",
  background: Colors.blue,
  onPressed: () {},
);

final dangerButton = CustomButton(
  label: "Delete",
  background: Colors.red,
  onPressed: () {},
);

final successButton = CustomButton(
  label: "Saved",
  background: Colors.green,
  onPressed: () {},
);

Why this matters?

The CustomButton widget no longer needs to be modified when requirements change. It’s closed for modification but open for extension — new button styles or behaviours can be added outside of it. This keeps the base widget simple and makes your UI code more scalable as your app grows.


Liskov Substitution Principle (LSP)

The Liskov Substitution Principle says: if you have a base class, you should be able to replace it with any of its subclasses without breaking the app.

In Flutter, this usually shows up when we design base widgets or abstract classes. If one subclass doesn’t fully behave like the parent, it can lead to unexpected bugs.

Problem

Subclasses sometimes introduce hidden restrictions or change the expected behaviour of the base class. This breaks substitutability and causes surprising runtime errors.

Suppose we create a base widget for displaying messages:

abstract class MessageWidget extends StatelessWidget {
  const MessageWidget({
    super.key,
    required this.text,
  });

  final String text;
}

Now we make this implementation:

class InfoMessage extends MessageWidget {
  const InfoMessage({
    super.key,
    required super.text,
  });

  @override
  Widget build(BuildContext context) {
    return Text(
      text,
      style: const TextStyle(
        color: Colors.blue,
      ),
    );
  }
}

This respects the contract: any string you pass will be displayed.

Now here’s a problematic subclass:

class ShortMessage extends MessageWidget {
  const ShortMessage({
    super.key,
    required super.text,
  }) : assert(text.length <= 20, 'Text too long for ShortMessage');

  @override
  Widget build(BuildContext context) {
    return Text(
      text,
      style: const TextStyle(
        color: Colors.orange,
      ),
    );
  }
}

At first glance this seems fine, but notice the hidden rule: the base class allowed any text, while this subclass suddenly refuses longer strings.

That’s an LSP violation. Code that worked with InfoMessage will now fail with ShortMessage.

Correct approach

If you need a different presentation, do it without changing the contract:

class WarningMessage extends MessageWidget {
  const WarningMessage({
    super.key,
    required super.text,
  });

  @override
  Widget build(BuildContext context) {
    return Row(
      children: <Widget>[
        const Icon(Icons.warning_amber_rounded),
        const SizedBox(width: 6),
        Flexible(
          child: Text(
            text,
            style: const TextStyle(
              color: Colors.orange,
              fontWeight: FontWeight.bold,
            ),
            overflow: TextOverflow.ellipsis,
          ),
        ),
      ],
    );
  }
}

This widget still accepts any string (no hidden assertions), but adds extra visuals (icon, style, ellipsis). It extends behaviour without breaking the contract.

Why this matters?

With LSP, consistency is the key: subclasses must not add extra restrictions or silently change what the base promised. In Flutter, that often means avoiding hidden asserts or silent logic changes in subclasses. Stick to the contract, and your widgets will remain interchangeable and predictable.


Interface Segregation Principle (ISP)

The Interface Segregation Principle says: don’t force classes to depend on methods they don’t use.

In other words: instead of one big “god” interface with too many responsibilities, break it into smaller, focused contracts. That way, each implementation only cares about what it actually needs.

Problem

When an interface defines too many responsibilities, some classes end up implementing methods they don’t need. This leads to unnecessary boilerplate and code that feels “forced.”

abstract class LoadableWidget {
  void loadData();
  void refresh();
  void cancel();
  void saveToCache();
}

Now, if we implement this in a simple widget like an Image widget, we’re stuck:

class ImageLoaderWidget implements LoadableWidget {
  @override
  void loadData() {
    // ✅ loads an image
  }

  @override
  void refresh() {
    // ✅ maybe reload the image
  }

  @override
  void cancel() {
    // ✅ cancel a network request
  }

  @override
  void saveToCache() {
    // ❌ but what if this widget doesn’t care about caching?
    throw UnimplementedError();
  }
}

This breaks ISP — the widget is forced to implement methods (saveToCache) it doesn’t actually need.

Smaller, focused interfaces

Instead, we break the big interface into smaller, segregated ones:

abstract class Loadable {
  void loadData();
}

abstract class Refreshable {
  void refresh();
}

abstract class Cancellable {
  void cancel();
}

abstract class Cacheable {
  void saveToCache();
}

Now each widget can pick and choose:

class ImageLoaderWidget implements Loadable, Refreshable, Cancellable {
  @override
  void loadData() {
    // load image
  }

  @override
  void refresh() {
    // reload image
  }

  @override
  void cancel() {
    // cancel image request
  }
}

And if we later add a widget that actually cares about caching (like a data-heavy API widget), it can also implement Cacheable without affecting the others.

Why this matters?

With ISP, your code stays flexible and clean:

  • Widgets (or services) only depend on what they actually use.

  • No more dummy methods or throw UnimplementedError.

  • Easier to test, because each contract is small and focused.

In Flutter, this principle often appears in repositories, services, or widget contracts. By splitting them into smaller interfaces, you avoid coupling features that don’t belong together.


Dependency Inversion Principle (DIP)

The Dependency Inversion Principle says: high-level modules shouldn’t depend on low-level modules; both should depend on abstractions.

In Flutter, this can apply to widgets too. If a widget directly depends on a specific concrete class, it becomes rigid. But if it depends on an abstraction (like a callback, a builder, or an interface), you can swap implementations without rewriting the widget.

Problem

When a widget creates or uses concrete classes directly, it gets tightly coupled to that implementation. This makes the widget harder to test and less reusable.

class ProfileWidget extends StatelessWidget {
  const ProfileWidget({super.key});

  @override
  Widget build(BuildContext context) {
    // ❌ tightly coupled: ProfileWidget directly depends on ApiService
    final api = ApiService();
    final user = api.getUser();

    return Text(user.name);
  }
}

This widget can’t be tested easily and is locked to ApiService.

A widget depending on an abstraction

Instead, we invert the dependency: the widget just expects something that can provide a user. That “something” could be an interface, a function, or a provider.

abstract class UserProvider {
  String getUserName();
}

class ApiUserProvider implements UserProvider {
  @override
  String getUserName() => 'User from API';
}

class MockUserProvider implements UserProvider {
  @override
  String getUserName() => 'Test User';
}

class ProfileWidget extends StatelessWidget {
  const ProfileWidget({
    super.key,
    required this.userProvider,
  });

  final UserProvider userProvider;

  @override
  Widget build(BuildContext context) {
    return Text(userProvider.getUserName());
  }
}

And this is how you would use it:

// In production
ProfileWidget(ApiUserProvider());

// In tests
ProfileWidget(MockUserProvider());

Now the widget doesn’t care where the user data comes from — it depends on the abstraction UserProvider, not on the concrete ApiService.

Even simpler: callback-based DIP

Sometimes you don’t even need an interface — a function parameter works too:

class ProfileWidget extends StatelessWidget {
  const ProfileWidget({
    super.key,
    required this.getUserName,
  });

  final String Function() getUserName;

  @override
  Widget build(BuildContext context) {
    return Text(getUserName());
  }
}

Now you can inject any behaviour from the outside:

ProfileWidget(getUserName: () => 'User from API');
ProfileWidget(getUserName: () => 'Test User');

Why this matters?

By applying DIP to widgets:

  • You make them reusable (UI doesn’t lock into a specific data source).

  • You make them testable (inject mocks or fakes easily).

  • You follow the same principle used in services and repositories, but at the UI level.


Wrapping Up: SOLID in Flutter

The SOLID principles might sound abstract when you first read about them, but in practice, they’re already part of how we build Flutter apps every day.

  • Single Responsibility (SRP) → Avoid “god widgets.” Break them down so each one has a single purpose. ✅ Clearer, smaller, easier to test.

  • Open/Closed (OCP) → Don’t keep editing widgets for every new variant. Design them to be extended instead. ✅ More reusable, less fragile.

  • Liskov Substitution (LSP) → Subclasses should behave consistently with their base class. ✅ Predictable, no hidden surprises.

  • Interface Segregation (ISP) → Don’t force widgets (or services) to implement methods they don’t need. ✅ Cleaner contracts, simpler tests.

  • Dependency Inversion (DIP) → Depend on abstractions, not concrete classes. ✅ More flexible, easier to mock, future-proof.

Individually, each principle improves code clarity. Together, they form a mindset: design your widgets and classes so they’re easy to understand, easy to extend, and hard to break.

And that’s the real value of SOLID — it’s not just theory, it’s a practical way to keep your Flutter codebase scalable as your app (and your team) grows.


Which of these principles do you realise you’ve already been applying in your Flutter code without even knowing? 🤔