In this tutorial I give a detailed overview of a production-ready architecture that I've fine-tuned over the last two years. You can use the included starter project as the foundation for your Flutter & Firebase apps.
Update August 2021: the starter project was originally written using Provider, and later updated to use Riverpod. This tutorial is now up to date and also covers Riverpod.
Motivation
Flutter & Firebase are a great combo for getting apps to market in record time.
Without a sound architecture, codebases can quickly become hard to test, maintain, and reason about. This severely impacts the development speed, results in buggy products, and leads to unhappy users.
I have already witnessed this first-hand with various client projects, where the lack of a formal architecture led to days, weeks - even months of extra work.
Is "architecture" hard? How can one find the "right" or "correct" architecture in the ever-changing landscape of front-end development?
Every app has different requirements, so does the "right" architecture even exist in the first place?
While I don't claim to have a silver bullet, I have refined and fine-tuned a production-ready architecture that I have already used in multiple Flutter & Firebase apps.
We will explore this and see how it's used in practice in the time tracker application included with the starter project:
So grab a drink & sit comfortably. And let's dive in!
Overview
We will start with an overview:
- what is architecture and why we need it.
- the importance of composition in good architecture.
- good things that happen when you do have a good architecture.
- bad things that happen when you don't have a good architecture.
Then we will focus on good architecture for Flutter & Firebase apps using Riverpod, and talk about:
- application layers
- unidirectional data flow
- mutable and immutable state
- stream-based architecture
I will explain some important principles, and desirable properties that we want in our code.
And we will see how everything fits together with some practical examples.
What you read here is the result of over two years of my own work, learning concepts, writing code, and refining it across multiple personal and client projects.
Ready? Let's go! 🚀
What is app architecture?
I like to think of app architecture as the foundation that holds everything together, and supports your codebase as it grows.
If you have a good foundation, it becomes easier to make changes and add new things.
Architecture uses design patterns to solve problems efficiently.
And you have to choose the design patterns that are most appropriate for the problem that you're trying to solve.
For example, an e-commerce application and a chat app will have very different requirements.
Composition
Regardless of what you're trying to build, it's likely that you'll have a set of problems, and you need to break them up into smaller, more manageable ones.
You can create basic building blocks for each problem, and you can build your app by composing blocks together. In fact:
Composition is a fundamental principle that is used extensively in Flutter, and more widely in software development.
Since we're here to build Flutter apps, what kind of building blocks do we need?
Example: Sign-in page
Let's say that you're building a page for email & password sign-in.
You will need some input fields and a button, and you need to compose these inputs together to make a form.
But the form by itself doesn't really do much.
You will also need to talk to an authentication service. The code for that is very different from your UI code.
To build this feature, you'll need code for UI, input validation, and authentication:
Good architecture
The sign-in page above has a good architecture if it's made with well-defined building blocks (or components) that we can compose together.
We can take this same approach and scale it up to the entire application. This has some very clear benefits:
- Adding new features becomes easier, because you can build upon the foundation that you already have.
- The codebase becomes easier to understand, and you're likely to spot some recurring patterns and conventions as you read the code.
- Components have clear responsibilities and don't do too many things. This happens by design if your architecture is highly composable.
- Entire classes of problems go away (more on this later).
- You can have different kinds of components, by defining separate application layers for different areas of concern (UI, logic, services).
Not-so-good architecture 😅
If we fail to define a good architecture, we don't have clear conventions for how to structure our app.
The lack of composable components leads to code that has a lot of dependencies.
This kind of code is hard to understand. Adding new features becomes problematic, and it's not even clear where new code should go.
Some other potential issues are also common:
- the app has a lot of mutable state, making it hard to know which widgets rebuild and when.
- it's not clear when certain variables can or cannot be
null
, as they are passed across multiple widgets.
All these issues can significantly slow down development, and negate the productivity advantages that are common in Flutter.
Bottom line: good architecture is important.
Flutter application layers
Here's a diagram that shows my architecture for Flutter & Firebase apps:
The dotted horizontal lines define some clear application layers.
I think it's a good idea to always think about them. When you write new code, you should ask yourself: where does this belong?
Example: if you're writing some UI code for a new feature, you're likely to be inside a widget class. Maybe you need to call some external web-service API when a button is pressed. In this case, you need to stop and think: where does my API code go?
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('New Job'),
actions: [
FlatButton(
child: Text('Save'),
onPressed: () {
// web API call here. Where should this code go?
},
),
],
),
body: _buildContents(),
);
}
Thinking in terms of application layers is really helpful here.
This boils down to the single responsibility principle: each component in your app should do one thing only.
UI and networking code are two completely different things. They should not belong together, and they live in very different places.
Unidirectional data flow
In the diagram above, data flows from the outside world, into the services, view models, and all the way down to the widgets.
The call flow goes into the opposite direction. Widgets may call methods inside the view models and services. In turn, these may call APIs inside external Dart packages.
Very important: components that live on a certain application layer do not know about the existence of components in the layers below.
View models do not reference any widgets objects (or import any UI code for that matter). Instead:
Widgets subscribe themselves as listeners, while view models publish updates when something changes.
This is known as the publish/subscribe pattern, and it has various implementations in Flutter. You have already encountered this if you used ChangeNotifiers or BLoCs in your apps.
Riverpod
To connect everything together, the app was originally built using the Provider package. Provider works in a similar way to InheritedWidget, making it easier to insert dependencies in the widget tree, and accessing them by type.
But Provider has some weaknesses and can lead to undesired boilerplate code and runtime errors in some cases. For this reason, I migrated the project to the Riverpod package. Just like Provider, Riverpod can be used to enforce an unidirectional data flow with immutable model classes, but doesn't share the same weaknesses.
With Riverpod, we create global providers that be accessed by reference. In this sense, Riverpod does not depend on the widget tree, and works more like a service locator.
You can check the official documentation or my essential guide to Riverpod if you're new to this package.
Creating Providers with Riverpod
For example, here are some providers created using Riverpod:
// 1
final firebaseAuthProvider =
Provider<FirebaseAuth>((ref) => FirebaseAuth.instance);
// 2
final authStateChangesProvider = StreamProvider<User>(
(ref) => ref.watch(firebaseAuthProvider).authStateChanges());
// 3
final databaseProvider = Provider<FirestoreDatabase?>((ref) {
final auth = ref.watch(authStateChangesProvider);
// we only have a valid DB if the user is signed in
if (auth.data?.value?.uid != null) {
return FirestoreDatabase(uid: auth.data!.value!.uid);
}
// else we return null
return null;
});
As we can see, authStateChangesProvider
depends on firebaseAuthProvider
, and can get access to it using ref.watch()
.
Similarly, databaseProvider
depends on authStateChangesProvider
.
One powerful feature of Riverpod is that we can watch a provider's value and rebuild all dependent providers and widgets when the value changes.
An example of this is the databaseProvider
above. This provider's value is rebuilt every time the authStateChangesProvider
's value changes. This is used to return either a FirestoreDatabase
object or null
depending on the authentication state.
Using Riverpod inside widgets
Widgets can access these providers with a WidgetRef
, either via Consumer
or ConsumerWidget
.
For example, here is some sample code demonstrating how to use StreamProvider
to read some data from a stream:
final jobStreamProvider =
StreamProvider.autoDispose.family<Job, String>((ref, jobId) {
final database = ref.watch(databaseProvider)!;
return database.jobStream(jobId: jobId);
});
There is a lot to unpack here:
- the
StreamProvider
can auto-dispose itself when all its listeners unsubscribe - we're using
.family
to read ajobId
parameter that is only known at runtime - we access the database via
ref.watch()
and use the assertion operator (!
), as long as we only ever read this stream when the database is notnull
.
Here's a widget that watches this StreamProvider
and uses it to show some UI based on the stream's latest state (data available / loading / error):
class JobEntriesAppBarTitle extends ConsumerWidget {
const JobEntriesAppBarTitle({required this.job});
final Job job;
@override
Widget build(BuildContext context, WidgetRef ref) {
// 1: watch changes in the stream
final jobAsyncValue = ref.watch(jobStreamProvider(job.id));
// 2: return the correct widget depending on the value
return jobAsyncValue.when(
data: (job) => Text(job.name),
loading: () => Container(),
error: (_, __) => Container(),
);
}
}
This widget class is as simple as it can be, as it only needs to watch for changes in the stream (step 1), and return the correct widget depending on the value (step 2).
This is great because all the logic for setting up the StreamProvider
lives inside the provider itself, and is completely separate from the UI code.
Mutable and immutable state
One important aspect of this architecture lies in the differences between services and view models. In particular:
- View models can hold and modify state.
- Services can't.
In other words, we can think of services as pure, functional components.
Services can transform the data they receive from the outside world, and make it available to the rest of the app via domain-specific APIs.
For example, when working with Firestore we can use a wrapper service to do serialization:
- Data in (read): This transforms streams of key-value pairs from Firestore documents into strongly-typed immutable data models.
- Data out (write): This converts data models back to key-value pairs for writing to Firestore.
On the other hand, view models contain the business logic for your application, and are likely to hold mutable state.
This is ok, because widgets can be notified of state changes and rebuild themselves, according to the publish/subscribe pattern described above.
By combining the uni-directional data flow with the publish/subscribe pattern, we can minimise mutable application state, along with the problems that often come with it.
Stream-based architecture
Unlike traditional REST APIs, with Firebase we can build realtime apps.
That's because Firebase can push updates directly to subscribed clients when something changes.
For example, widgets can rebuild themselves when certain Firestore documents or collections are updated.
Many Firebase APIs are inherently stream-based. As a result, the simplest way of making our widgets reactive is to use Riverpod's StreamProvider
class.
StreamProvider
takes an input stream and converts its snapshots into AsyncValue
objects, which are safer to use.
Once again, here's the code for watching changes and rebuilding the UI when the stream changes:
class JobEntriesAppBarTitle extends ConsumerWidget {
const JobEntriesAppBarTitle({required this.job});
final Job job;
@override
Widget build(BuildContext context, WidgetRef ref) {
// 1: watch changes in the stream
final jobAsyncValue = ref.watch(jobStreamProvider(job.id));
// 2: return the correct widget depending on the value
return jobAsyncValue.when(
data: (job) => Text(job.name),
loading: () => Container(),
error: (_, __) => Container(),
);
}
}
Note how in step 2 we use .when
to convert the jobAsyncValue
into a Text
widget, a CircularProgressIndicator
, or an error widget. This is a lot easier and safer than working with the AsyncSnapshot
values provided by Flutter's StreamBuilder
widget.
Note: streams are the default way of pushing changes not only with Firebase, but with many other services as well. For example, you can get location updates with the
onLocationChanged()
stream of the location package. Whether you use Firestore, or want to get data from your device's input sensors, streams are the most convenient way of delivering asynchronous data over time.
In summary, this architecture defines separate application layers with an unidirectional data flow. Data is read from Firebase via streams, then converted to AsyncValue
objects, and widgets are rebuilt according to the publish/subscribe pattern using Riverpod.
Desirable code properties
When used correctly, this architecture leads to code that is:
- clear
- reusable
- scalable
- testable
- performant
- maintainable
Let's look at each point with some examples:
Clear
Suppose we want to build a page that shows a list of jobs.
Here is how I have implemented this in my project (explanation below):
// 1
final jobsStreamProvider = StreamProvider.autoDispose<List<Job>>((ref) {
final database = ref.watch(databaseProvider)!;
return database.jobsStream();
});
// 2
class JobsPage extends ConsumerWidget {
// 3
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(
title: const Text(Strings.jobs),
actions: <Widget>[
IconButton(
icon: const Icon(Icons.add, color: Colors.white),
onPressed: () => EditJobPage.show(context),
),
],
),
body: _buildContents(context, ref),
);
}
// 4
Widget _buildContents(BuildContext context, WidgetRef ref) {
final jobsAsyncValue = ref.watch(jobsStreamProvider);
return ListItemsBuilder<Job>(
data: jobsAsyncValue,
itemBuilder: (context, job) => Dismissible(
key: Key('job-${job.id}'),
background: Container(color: Colors.red),
direction: DismissDirection.endToStart,
onDismissed: (direction) => _delete(context, ref, job),
child: JobListTile(
job: job,
onTap: () => JobEntriesPage.show(context, job),
),
),
);
}
Future<void> _delete(BuildContext context, WidgetRef ref, Job job) async {
try {
final database = ref.read<FirestoreDatabase?>(databaseProvider)!;
await database.deleteJob(job);
} catch (e) {
unawaited(showExceptionAlertDialog(
context: context,
title: 'Operation failed',
exception: e,
));
}
}
}
- Step 1: we create a
jobsStreamProvider
. - Step 2: we create the
JobsPage
by extendingConsumerWidget
. This makes it easy to watch for changes in the stream, as it gives us an additionalWidgetRef
argument in thebuild()
method. - Step 3: the
build()
method returns aScaffold
with anAppBar
. - Step 4: the
_buildContents()
method watches thejobsStreamProvider
and passes the resulting async values to aListItemsBuilder
widget (this a generic widget that I created for showing a list of items).
In just 50 lines, this widget shows a list of items, and handles three different callbacks for:
- creating a new job
- deleting an existing job
- routing to a job details page
Each of these operations only requires one line of code, because it delegates the actual work to external classes.
As a result this code is clear and readable.
It would be much harder to make sense of everything if we had database code, serialization, routing and UI all in one class. And our code would also be less reusable as a result.
Reusable
Here's the code for a different page, which shows a daily breakdown of all the jobs along with the pay:
final entriesTileModelStreamProvider =
StreamProvider.autoDispose<List<EntriesListTileModel>>(
(ref) {
final database = ref.watch(databaseProvider)!;
final vm = EntriesViewModel(database: database);
return vm.entriesTileModelStream;
},
);
class EntriesPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final entriesTileModelStream = ref.watch(entriesTileModelStreamProvider);
return Scaffold(
appBar: AppBar(
title: const Text(Strings.entries),
elevation: 2.0,
),
body: ListItemsBuilder<EntriesListTileModel>(
data: entriesTileModelStream,
itemBuilder: (context, model) => EntriesListTile(model: model),
),
);
}
}
Note how this code reuses the same ListItemsBuilder
widget we had in the previous page - this time with a different model type (EntriesListTileModel
).
But the data flows into the UI in the same way as before.
Bottom line: it pays off to build reusable components that can be used in multiple places.
Scalable
Let's talk about scalable code. If you have implemented Firestore CRUD operations before, you're probably familiar with this kind of syntax:
Widget build(BuildContext context) {
final user = FirebaseAuth.instance.currentUser;
final ref = FirebaseFirestore.instance.collection('users')
.doc(user.uid).collection('jobs').doc(job.id);
return StreamBuilder<DocumentSnapshot>(
stream: ref.snapshots(),
builder: (_, snapshot) {
// TODO: check for connectionState, hasData, errors etc
final data = snapshot.data.data();
return Text(data['name']);
},
);
}
This code has two major problems:
- it's highly coupled with the
FirebaseAuth
andFirebaseFirestore
singleton variables, making it untestable. - it accesses the key-value pairs directly from the
DocumentSnapshot
object, which is error prone and not-type safe.
This can get quite unwieldly, especially if your documents have a lot of key-value pairs.
You don't want to have code like this inside your widgets.
Rather, you can define a domain-specific Firestore API using some service classes, and keep things tidy.
Here is a FirestorePath
class that I have created to list all possible read/write locations in my Firestore database:
class FirestorePath {
static String job(String uid, String jobId) => 'users/$uid/jobs/$jobId';
static String jobs(String uid) => 'users/$uid/jobs';
static String entry(String uid, String entryId) =>
'users/$uid/entries/$entryId';
static String entries(String uid) => 'users/$uid/entries';
}
Alongside this I have a FirestoreDatabase
class that I use to provide access to the various documents and collections:
class FirestoreDatabase {
FirestoreDatabase({@required this.uid}) : assert(uid != null);
final String uid;
// CRUD operations - implementations omitted for simplicity
Future<void> setJob(Job job) { ... }
Future<void> deleteJob(Job job) { ... }
Stream<Job> jobStream({@required String jobId}) { ... }
Stream<List<Job>> jobsStream() { ... }
Future<void> setEntry(Entry entry) { ... }
Future<void> deleteEntry(Entry entry) { ... }
Stream<List<Entry>> entriesStream({Job job}) { ... }
}
This class exposes all the various CRUD operations to the rest of the app, behind a nice API that uses type-safe model classes.
With this setup, adding a new type of document or collection in Firestore becomes a repeatable process:
- add some additional paths to
FirestorePath
. - add the corresponding
Future
andStream
based APIs toFirestoreDatabase
to support the various operations. - create type-safe model classes as needed. These include the serialization code for the new kind of documents that we need to use.
All of this code remains confined inside a services
folder in the project.
Widgets can access specific streams from the Database API by creating a corresponding StreamProvider
, and watching it to rebuild the UI:
// 1: create StreamProvider
final jobsStreamProvider = StreamProvider.autoDispose<List<Job>>((ref) {
final database = ref.watch(databaseProvider)!;
return database.jobsStream();
});
class JobsPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// 2: watch the stream data and rebuild the UI
final jobsAsyncValue = ref.watch(jobsStreamProvider);
return jobsAsyncValue.when(
data: (value) => /* data widget */,
loading: () => /* loading widget */,
error: (_, __) => /* error widget */,
);
}
}
The code above is easily scalable. We can add new functionality by following repeatable steps, and ensure that the code is consistent. This is very valuable if you work in a team.
Testable
This architecture leads to testable code.
This is true for our unit tests, as long as our classes are small and have few dependencies.
But it also applies to widget tests as well, as we can use dependency overrides with Riverpod.
For example, consider the global firebaseAuthProvider
that is used in this project:
final firebaseAuthProvider =
Provider<FirebaseAuth>((ref) => FirebaseAuth.instance);
In our widget tests we can create a mock object that will replace FirebaseAuth
, and configure it as needed:
import 'package:mocktail/mocktail.dart';
class MockFirebaseAuth extends Mock implements FirebaseAuth {}
void main() {
late MockFirebaseAuth mockFirebaseAuth;
setUp(() {
mockFirebaseAuth = MockFirebaseAuth();
// stub methods here, or in the tests
when(() => mockFirebaseAuth.authStateChanges())
.thenAnswer((_) => /* some stream */);
});
/// tests here
}
Then we can override firebaseAuthProvider
like so when we pump the widget:
await tester.pumpWidget(
ProviderScope(
overrides: [
firebaseAuthProvider
.overrideWithProvider(Provider((ref) => mockFirebaseAuth)),
],
child: MaterialApp(...),
),
);
As a result, widgets will access mockFirebaseAuth
every time they read the value of firebaseAuthProvider
.
This leads to widget tests are fast and predictable, because they don't call any networking code.
Dependency overrides are very powerful. We can use them to override any provider we want, and this is easy to do because Riverpod providers are global and accessible by name.
This is particularly useful when running integration tests, that can be used to test entire user flows in the app.
For more info on testing with Riverpod, read the official documentation.
Performant
One great thing about this architecture is that it minimises widget rebuids, as long as we use Riverpod correctly.
The key here is to know when to use ref.read()
and when to use ref.watch()
. The Riverpod documentation has a detailed page explaining how to correctly consume a provider.
There is a bit of a learning curve here, as Riverpod gives us various kinds of providers and consumers, as well as multiple ways of using them.
I plan to create some tutorials about Riverpod in the future, explaining things more in detail.
For now, I recommend to check out my essential guide to Riverpod, take the time to read the documentation, and try all these concepts in practice:
Maintainable
This architecture leads to maintainable code, and the examples above should serve as evidence.
Maintainable code will save you (and your team) days, weeks and months of extra effort.
Beyond that, your code will be much nicer to work with, and you'll sleep better at night. 😴
Conclusion
I hope that this overview has inspired you to invest in good architecture.
If you're starting a new project, consider planning out your architecture upfront, based on your requirements.
If you're struggling with a codebase that doesn't follow good software design principles, start refactoring in small iterations. You don't have to fix everything at once, but it helps to move slowly towards your desired architecture.
And if you are building a project with Flutter and Firebase or any other kind of streaming APIs, do check out my starter project on GitHub. This is a complete time tracking application:
The README is a good place to get more familiar with all the concepts that we covered.
After that, you can take a look at the source code, run the project (note: Firebase configuration needed), and get a good understanding of how everything fits together.
Thank you very much for following this tutorial. If you end up using this architecture, I would love to hear your feedback, either on Twitter or by email.
Happy coding!