Practical Flutter Architecture


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
Easy to explore and understand
No configuration by convention (violates the first goal)
Avoiding complex code that's hard to understand (violates the first goal)
Flutter code should still look like Flutter code
Clear separation of concerns
Easy to test
Able to scale
Minimal boring boilerplate code
Flexible, not too rigid
Easy to get started with
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 sensorsConvert 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 anAccount
,Profile
, andSettings
object. Often, I would create aUserSettingsManager
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 someValueListenables
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
andriverpod
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-
, orStreamBuilders
to rebuild your Widget as soon as your data changes. Or make your life easier by using watch_it to simply watch any type ofListenable
orStream
. 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 alisten()
method and aCustomValueNotifier
.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.
Recommended project structure
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:
The project is organized by features (there is a branch of the original that is just reorganized and fixes the HttpClients).
Use cases and repositories are combined into Manager objects.
Instead of using immutable business objects in the data layer, it uses the proxy approach explained in detail in my last two articles: Understanding the Problems with Dogmatic Programming Advice and Keeping Widgets in Sync with Your Data.
Instead of the simple command objects from the original, it uses my flutter_command package.
Consistent error handling uses exceptions below the manager layer, which are correctly routed by the commands.
HTTP clients are set up to use the new native clients, which greatly improves performance.
Although this isn’t a perfect measure, it still shows that the refactored version is less complex.
Original | PFA Version | |
Total files analyzed: | 111 | 94 |
Total lines of code: | 9147 | 7804 |
Total classes: | 130 | 110 |
Total functions: | 304 | 267 |
Total enums: | 2 | 2 |
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.
Recommended additional information
On ProxyObjects and an introduction to flutter_command
https://blog.burkharts.net/understanding-the-problems-with-dogmatic-programming-advice
https://blog.burkharts.net/keeping-widgets-in-sync-with-your-data
State Management with get_it and watch_it
https://blog.burkharts.net/one-to-find-them-all-how-to-use-service-locators-with-flutter
https://blog.burkharts.net/lets-get-this-party-started-startup-orchestration-with-getit
https://medium.com/easy-flutter/flutter-the-state-management-with-watch-it-f66e8336e8f3
On RVMS
a bit dated but might give additional insights that I might not have included here
On error handling with commands
Subscribe to my newsletter
Read articles from Thomas Burkhart directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
