Practical Flutter Architecture

Thomas BurkhartThomas Burkhart
16 min read

Okay, I'm just teasing. There are many ways to structure an app and choose state management. Some methods are not great, some are okay, and some have clear benefits. As always, this is my opinion based on many years in this field.

Some theorie

State Management

Before I started using Flutter, I had never heard the term "state management," and I still think it's often mentioned without a clear explanation. To me, state management is about making sure our UI updates whenever the data changes and figuring out how to update data from the UI. That's all. Often, people confuse it with how we access our data from the UI layer, but that's not really state management.

Layered Architecture

Over the years, we discovered that dividing your app into different layers has some advantages. The most common structure you'll find is a three-layered architecture, organized from top to bottom as follows:

  • UI

  • Business logic (all logic that transforms or controls your data)

  • Services (connects to the outside world, OS, or network)

The reasons for splitting them like this are:

  • Separating the UI from the business logic allows for easy automated testing of your business logic.

  • Splitting the code below the UI lets you replace the real service with mocked versions, so you can test your app's logic without relying on a working backend or even test the business layer on another platform that doesn’t offer the needed APIs.

  • Another reason for such layers is that in big projects, some teams choose to divide the team along these layers, which can make sense if people are more experienced in certain layers or enjoy them more.

You will encounter other proposals with many more layers or not even horizontal layers. These can make sense for some IT systems, but we rarely need them for mobile apps.

Clean Architecture
I’m still frustrated with “Uncle Bob” for using the term ‘clean’ for both his book ‘Clean Code’ and especially for ‘Clean Architecture,’ because it makes it seem like the only way to write software. He does provide some good advice in his book (though there are different viewpoints), but ‘Clean Architecture’ is an excessive example of overengineering and overabstraction. For mobile apps, it's total overkill.

MVVM, MVC, MVU etc…

You will come across these acronyms that describe how the UI and business logic are connected. They were mainly created to simplify working with certain frameworks, but they can be problematic if used with a different framework where they don't fit. They also don't provide guidance on how to structure the entire app. They aren't true architectures, which makes them even more confusing.

MVVM

MVVM is often used and recommended for mobile apps. Here are the layers:

  • Model - contains all business logic (possibly including a service layer)

  • View - the UI part

  • ViewModel - acts as a glue layer between the UI and your business logic.

Why ViewModels? They became popular with XAML-based UI frameworks like Microsoft's WPF or Xamarin Forms, as well as past native Android development. In all of these, you define the UI not in code but declaratively in some XML or JSON-based markup language. To connect the UI that these frameworks produced from the markup, you needed to define a ViewModel for every page/widget, which provided the data to display and functions to bind to buttons in the UI. The actual connection of these parts happened automatically via naming conventions, known as automatic binding.

Why explain this in such detail? Because we don’t have automatic bindings in Flutter.

MVC

  • Model

  • View

  • Controller

There are several variations, but the original definition was that all data always passes through the controller as it moves from the UI to the Model and back. Today, there doesn't seem to be a clear understanding of what it truly means, which makes this pattern almost useless for describing anything.

MVU

If any of these patterns come close, it is MVU, which stands for Model - View - Update. It first appeared with the Elm programming language. It treats your app as a mathematical function where View = appLogic(Model). This means every time your Model/Data changes, the UI is recreated based on the new data. This approach somewhat aligns with Flutter, although Flutter has multiple ways to ensure that only the necessary parts of the UI are rebuilt when the data changes.

Where does this leave us?

In my opinion, all these patterns that are often referenced do not really help in describing what we need to write a Flutter application. So maybe we should discard them and try to come up with an architecture that truly fits Flutter?

Pragmatic Flutter Architecture (PFA)

In the past, I used the term RVMS (Reactive View Manager Services), but after several years of working with it, I realized that you need more than just the first letters of the architecture's components. If you have a better idea for what I describe here, please let me know.

I chose Pragmatic because what I suggest might break some rules often cited as the “correct” way to write code. Some people might enjoy academic debates about topics like ‘Are service locators an anti-pattern?’ but ultimately, we need to deliver apps, so I prefer to focus on that. See my previous post.

Core goals

  1. Easy to explore and understand

  2. No configuration by convention (violates the first goal)

  3. Avoiding complex code that's hard to understand (violates the first goal)

  4. Flutter code should still look like Flutter code

  5. Clear separation of concerns

  6. Easy to test

  7. Able to scale

  8. Minimal boring boilerplate code

  9. Flexible, not too rigid

  10. Easy to get started with

  11. Fun to work with

Structure of a PFA App

.

Services

  • Encapsulate functionality beyond the app's boundaries, such as:

    - REST APIs
    - Databases
    - OS services like Contacts/Calendar
    - Hardware features like location and acceleration sensors

  • Convert data from/to the external data format (e.g., JSON) to domain objects

  • Do not change any app state on their own

  • Each service should only wrap one external aspect.

/// Example for a service from the refactored Compass app
class SharedPreferencesService {
  static const _tokenKey = 'TOKEN';
  final _log = Logger('SharedPreferencesService');

  Future<String?> fetchToken() async {
    try {
      final sharedPreferences = await SharedPreferences.getInstance();
      _log.finer('Got token from SharedPreferences');
      return sharedPreferences.getString(_tokenKey);
    } on Exception {
      throw Exception('Failed to get auth token');
    }
  }

  Future<void> saveToken(String? token) async {
    ....
  }
}

all code samples here are just to ilustrate the concepts, they are not necesary to understand the concepts

Managers

  • Wrap semantically related business logic, such as:

    • User Management/Authentication

    • Notification Management

    • Invoices

  • Do not directly map to a specific View (Managers ≠ ViewModel)

  • Often provide CRUD functionality for domain objects

  • Implement any business logic that changes the app state

  • Use Services or other Managers

  • Provide Functions/Commands/ValueListenables for the UI

  • Accessed through ServiceLocator or DI

  • Sometimes, it makes sense to move logic from a Manager into a Proxy object, especially when dealing with Lists of Proxies connected to Item-Widgets in a ListView.

/// Part of a Manager of the refactored compass app.
/// if you wonder why there is no error handling, that is done by the commands internally
/// any exception thrown inside a command will be handled depending you your settings.
/// Because here we have to error routing defined they will be reported at the global 
/// command error handler
abstract class BookingManager {
  /// combines the busy state of the booking commands
  late final ValueListenable<bool> isBusy =
      createBookingCommand.isExecuting.mergeWith(
    [loadBookingCommand.isExecuting, deleteBookingCommand.isExecuting],
  );

  final _log = Logger('BookingViewModel');

  late final Command<void, void> createBookingCommand =
      Command.createAsyncNoParamNoResult(
    () async {
      final itineraryConfig =
          await di<ItineraryConfigManager>().getItineraryConfig();
      _booking.value = await createFrom(itineraryConfig);
    },
    debugName: cmdCreateBooking,
  );

  /// Loads booking by id
  late final Command<int, void> loadBookingCommand =
      Command.createAsyncNoResult(
    (id) async {
      _booking.value = await getBooking(id);
    },
    debugName: cmdLoadBooking,
  );

Views

  • Full Page or high-level Widget that fully implements a specific feature

  • Are self-responsible, meaning they know what data they need and can operate independently of a specific location in the widget tree

  • Select the data they need from Managers or directly from Services (read-only), typically by listening to ValueListenables or Streams

  • Modify data by using functions or Commands in Managers

  • Don’t modify data/state by using Services

  • Don’t change any state that isn’t related to the task the View is designed for; that is the responsibility of the business logic inside Managers

/// Example from the refactored Compass app showing how watch_it is used to 
/// obeserve the state of a Manager and its commands
/// Commands itself are ValueListenables and offer additional state properties as 
/// ValueListenables like errors or busy states
class BookingBody extends WatchingWidget {
  const BookingBody({super.key});

  @override
  Widget build(BuildContext context) {
    final isBusy =
        watchValue((BookingManager bookingManager) => bookingManager.isBusy);
    final creationError = watchValue((BookingManager bookingManager) =>
        bookingManager.createBookingCommand.errors);
    final loadError = watchValue((BookingManager bookingManager) =>
        bookingManager.loadBookingCommand.errors);
    if (isBusy) {
      const Center(
        child: CircularProgressIndicator(),
      );
    }
    final booking =
        watchValue((BookingManager bookingManager) => bookingManager.booking);

    // If fails to create booking, tap to try again
    if (creationError != null) {
      return Center(
        child: ErrorIndicator(
          title: AppLocalization.of(context).errorWhileLoadingBooking,
          label: AppLocalization.of(context).tryAgain,
          onPressed: di<BookingManager>().createBookingCommand.execute,
        ),
      );
    }
    // If existing booking fails to load, tap to go /home
    if (loadError != null) {
      return Center(
        child: ErrorIndicator(
          title: AppLocalization.of(context).errorWhileLoadingBooking,
          label: AppLocalization.of(context).close,
          onPressed: () => context.go(Routes.home),
        ),
      );
    }
    if (booking == null) return const SizedBox();
    return CustomScrollView(
      slivers: [
        SliverToBoxAdapter(child: BookingHeader(booking: booking)),
        SliverList(
          delegate: SliverChildBuilderDelegate(
            (context, index) {
              final activity = booking.activity[index];
              return _Activity(activity: activity);
            },
            childCount: booking.activity.length,
          ),
        ),
        const SliverToBoxAdapter(child: SizedBox(height: 200)),
      ],
    );
  }
}
...

Data Objects

Domain/Business Objects

  • These objects represent real-world items or abstract concepts from the application domain, such as users, invoices, and tickets.

  • If they have methods, they ONLY change their own internal state and never affect other objects.

  • They often provide factory functions like to/from JSON that Services can use.

  • They can be Proxy Objects that wrap a DTO object, as explained in my previous post. Proxy objects can also handle necessary data transformation if your DTOs contain data in a form that can’t be directly displayed in a Widget, for instance.

Data Transfer Objects (DTOs)

Depending on your backend and how you generate the code that interacts with your server API, you might need a separate version of your Domain Objects that only contains immutable data plus fromJson/toJson methods to send them over the network. Especially if they are generated from an API specification, you might not be able to add any logic on your own. Wrapping them inside a proxy object can make your life much easier.Wrapper Classes

  • Wrap multiple Domain Objects so they can be passed over a ValueListenable as a single entity, used in an item-builder in a ListView, or to make it easier to observe changes in the wrapped objects. These can include Lists, Tuples, or custom Objects.

  • An example could be a UserSettings class that doesn't exist on your Backend object but combines an Account, Profile, and Settings object. Often, I would create a UserSettingsManager object for this, as it allows all the necessary logic and permission handling to be in one place.

  • These can also be implemented as Proxy Objects that reference multiple DTOs.

/// Example for a Proxy object wrapping a DTO object supporting UI updates every time 
/// the target gets updated
class UserProxy extends ChangeNotifier {
  UserProxy(
    UserApiModel target,
  ) : _target = target;

  UserApiModel _target;

  UserApiModel get target => _target;

  /// if we ever want to update the user from the backend
  set target(UserApiModel target) {
    _target = target;
    notifyListeners();
  }

  /// The user's name.
  String get name => target.name;

  /// The user's picture URL.
  String get picture => target.picture;
}

Interactions inside a PFA App

Despite that fact that I think that using Commands will be an advantage, you can replace every mentioned Command with a function plus some ValueListenables compare the two branches of my proy demo project without Commands vs version with commands

Do I need to use a specific package for PFA?

To create an app with the structure described above, you don't necessarily need any packages besides Flutter, but using certain packages can make your life much easier. I prefer using my own packages, which were developed with this type of architecture in mind.

Let's explore which components we need and where using a package might be beneficial:

Making Managers and Services Accessible

This means making them accessible from the UI, as well as from other Managers or Proxies.

  • Singletons in global variables: I know you might be skeptical, and it's generally against recommended best practices, but for a very simple app, this can be sufficient.

  • Service locators: These offer more flexibility to switch out implementations and provide other useful features. In my examples, we will use get_it, which is one of the oldest packages on pub and very easy to use. However, there are others that offer similar functionality. provider and riverpod both have locator functionality as well.

  • InheritedWidget: This works if you only need to access objects within the Widget tree. If you want to access a service from a place without a BuildContext, you'll need to do some extra work.

Making the UI Refresh When Your Data Changes

  • Make Your Data Observable: Let your Proxies and Domain Objects extend ChangeNotifier. Use ValueNotifiers or Streams as properties in Managers and Proxies.

  • Make Your Widgets Observe Your Data: Use ValueListenable-, Listenable-, or StreamBuilders to rebuild your Widget as soon as your data changes. Or make your life easier by using watch_it to simply watch any type of Listenable or Stream. Every state management package offers its own way to achieve the same result.

Packages That Might Be Helpful

  • rxdart: If you enjoy using Streams, this package allows you to transform and combine your data within your managers, limited only by your creativity.

  • function_listener: Provides similar functions for ValueListenable, along with a listen() method and a CustomValueNotifier.

  • flutter_command: This package wraps a function and provides several ValueListenables to monitor the execution of that function, along with advanced error handling. You can find a short introduction here.

Due to the separation into layers, many new developers try to organize their files based on which layer they belong to. So, they create one folder for services, one for managers, and one for widgets. This might seem like a reasonable approach, but as your app grows, you'll spend a lot of time searching for the widget or manager you need.

Organize your files by features:

  • Main folders are organized by features or larger function blocks.

  • A feature folder contains all the pages, widgets, managers, models, and services needed for that feature.

  • Widgets include all widgets used on more than one page.

  • Shared contains services, utilities, and other classes used by multiple features.

  • locator.dart contains the global instance of the ServiceLocator and all object registrations.

  • Use _ to sort folders you need most to the top in your IDE.

  • If a component is mainly used by one feature but another feature needs it too, only move it to the shared folder if you expect more features to use it. Otherwise, keep it close to the feature it is most related to.

Should we always use interface classes?

If you're not familiar with the concept, using interface classes allows you to register different implementations of a class without changing anything else in the app. For example, you can switch between a mock implementation and the real one for a service.

I recently changed my mind on this. I used to always register my Managers and Services with an abstract interface. Unfortunately, the downside of using interfaces is that navigating through your code with go to definition becomes much more cumbersome, and any change in a method signature has to be made in two places.

Now, I only recommend using interfaces if you already know you will have more than one implementation of a class. Otherwise, don't use them. If you need it later, you can always refactor your code.

Still waiting for the refactoring function: Extract interface, which many other languages offer in their editors.

So, should I use the PFA for all my apps in future?

You can, and you'll probably enjoy building apps with it. However, my real goal is to help you understand that being a successful app developer isn't just about following common patterns or recipes. Instead, focus on learning why we choose a certain architecture. When you decide to use a specific pattern or package, don't do it just because you read a tutorial or because everyone says it's the right way on Youtube. Have your own understanding of why you use this approach and not another one.

Learn to think in terms of objects. Imagine how the objects your app creates float in memory, referencing each other. Unless you can do that, you'll unnecessarily limit your possibilities. It's also much more fun to design an app this way because it's a creative process rather than just following recipes.

Before deciding to use any proposed architectures or state management approaches, compare them. Clone a reference app and inspect its code:

  • How easy is it for you to understand the project's structure?

  • Can you easily navigate between the UI and the data layer using the IDE?

  • Starting from pressing a button inside a widget, try to follow through what code gets executed.

  • Are you comfortable with the number of procedures necessary to wire everything up?

  • Write a very simple app using the approach that seemed most promising to you.

The Official Flutter Reference App for Architecture: Compass in PFA

Recently, the Flutter Team published a section on architecting a Flutter app along with a reference implementation. If you've read this article so far, you might understand why I'm not entirely satisfied with that guide.

  • In my opinion, using MVVM to describe that architecture is misleading because it doesn't align with the original concept of MVVM.

  • The project isn't organized by features, which makes navigation difficult.

  • The HTTP clients aren't set up to easily use the new native HTTP clients.

  • There are too many layers.

  • Error handling avoids exceptions and returns result objects everywhere (this could be a topic for a separate article).

I took the time to completely restructure and rebuild the project following the patterns I describe in the PVA. You can find the refactored project here.

How does it differ from the original version:

Although this isn’t a perfect measure, it still shows that the refactored version is less complex.

OriginalPFA Version
Total files analyzed:11194
Total lines of code:91477804
Total classes:130110
Total functions:304267
Total enums:22

I recommend cloning both the original and the refactored versions and trying to navigate through the code to decide for yourself if you like the PFA approach.

On ProxyObjects and an introduction to flutter_command

State Management with get_it and watch_it

On RVMS

a bit dated but might give additional insights that I might not have included here

On error handling with commands

2
Subscribe to my newsletter

Read articles from Thomas Burkhart directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Thomas Burkhart
Thomas Burkhart