Using Firebase Firestore for a Flutter application is an excellent choice - but it bears some challenges. In this series, we introduce some best practices to help you ship your flutter app.
Preface
Last time we left off discussing the challenges that a developer might face when working on a Flutter app using Firestore. We also took a first look at the toy project of this series: the Recipe App. If you missed it, here is the perfect opportunity to take a look at the previous post.
You can find all the code created for this post in the companion repository.
Firestore Model
Follow along (optional)
If you would like to follow along, you will need to create your own Firebase project with an empty Firestore instance. You can read about all the necessary setup steps here.
You can populate your database with recipe data by running the minimal Node script that I created especially for this purpose.
NoSQL schema
The document schema in Firestore is simple: we have a single collection, named recipes, in which each document has the following form:
{
"recipeID": "recipe-1",
"title": "Example Recipe",
"author": {
"name": "Anna Doe",
"gender": "female"
},
"total_time": 35,
"yields": "8 slices",
"image": "http://some.url/img.png",
"ingredients": ["salt", "pepper"],
"instructions": "Just do it.",
"ratings": 5.0
}
Note that the author attribute of a recipe could have been moved into a dedicated authors collection, however, to keep this example simple, I decided to go with the less complex solution here.
Linking Firestore-specific attributes to our model
It is often useful to store Firestore-specific metadata with the model classes. To solve this without modifying the existing model classes, we can use composition, by introducing a FirestoreModel class with the sole responsibility of attaching metadata to models.
Then we can create concrete implementations for each in-app model class.
At this point we have everything in our app to introduce the database layer along with the necessary conversions.
Integrating Firestore
All we need to access the data stored in our Firestore database is the name of the collection which we want to use as the data source.
The following code would access our recipes collection as a CollectionReference<Map<String, dynamic>>.
This means, that we would be able to query documents stored in the collection, however, we would need to work with Map<String, dynamic> objects, instead of our in-app models. This is of course not optimal since we would lose all the type-safety and all the other benefits that Dart provides us when working with model classes.
To remedy this, cloud_firestore lets us convert a CollectionReference into a converted collection reference, as follows:
(You can peek into the withConverter function's implementation here)
Apparently, we must introduce a conversion logic to our model classes to obtain CollectionReference objects from which we get direct access to our model classes, instead of Map objects.
Declaring custom converters
Our goal here is to define the toMap and the fromMap functions for each of our model classes in a way such that they can be accessed through a common interface.
It would be tempting to write a Map toMap(BaseModel model) and a BaseModel fromMap(Map json) function signature contract in our abstract base class BaseModel, however, fromMap should be a static method and Dart has no support for abstract static methods. It would also violate the Single-responsibility principle, since model classes should serve only for modeling data.
Hence, we can define a MapConverter interface to define the signature of the functions that we need, then we can create concrete implemenations of the interface for the corresponding classes.
Throughout the app's lifecycle, we will need only one instance of each of these converters. Instead of transforming all the implementing classes to singletons, we can use the following neat trick that takes advantage of using generics:
This way, we can access any of the converters as MapConverters.mapConverter<MODEL_TYPE>(). For example, to access the RecipeMapConverter, we call MapConverters.mapConverter<Recipe>().
In preparation for future converter interfaces, we can introduce an additional class to serve as a facade to access all the supported converters in such a way:
For the first sight this might seem to be redundant, but this approach has quite a few advantages:
- All the converters can be managed inside a single package (lib/models/converters) in our case
- Users of the converter functions need to know only the absolute minimum about the implementation of the called functions:
- What sort of converter to access? (Specified by the static call Converters.mapConverter)
- For which model type should the converter be accessed? (Specified by the generic type parameter <Recipe>)
- Adding MapConverter to a new model class is simple:
- Create a MapConverter implementation for the new model
- Associate the new model's type with its converter instance in MapConverters._mapConverterMap
- Adding support to a new converter is simple and does not need the modification of existing code. (Only add a new function to the Converters facade)
- Converter instances are instantiated only once and the same const singleton instance is used throughout the app
Using our custom converters
Now, as we have a way to convert model objects, we can use them to access converted Firestore CollectionReference objects.
We can access a CollectionReference<FirestoreRecipe> as follows:
Then, we can use the CollectionReference to stream the list of FirestoreRecipe objects, retrieved from the cloud:
But of course, in most cases we would like to work with in-app models, that is, it would be ideal if we got access to a Stream<List<Recipe>> and not only to a Stream<List<FirestoreRecipe>>. We can achieve that as follows:
Note that in this code fragment we are taking advantage of the fact that FirestoreRecipe is an instance of FirestoreModel<Recipe> because of the fact that it was declared as class FirestoreRecipe extends FirestoreModel<Recipe>. This way, the database layer does not need to know anything about the implementation of FirestoreModel<Recipe> (not even the name of the implementing class).
If you closely inspect the code fragments I introduced in this section, you might spot that the only 'hard-coded' bits are:
- The name of the collection (recipes)
- The type of the FirestoreModel (FirestoreModel<Recipe>)
- The type of the in-app BaseModel (Recipe)
Thanks to the language features of Dart, we can easily parameterize these using generics, and with the Type-mapping trick introduced in the previous section with the MapConverters.
Our goal here is to make it possible to access the converted CollectionReference as well as the Stream<List<FirestoreModel>> and the Stream<List<BaseModel>> returned from the collection using purely the model class types.
We can determine the path to the enclosing Firestore collection of a model by setting up the following map:
This way, we can easily access the converted CollectionReference as follows:
This way, a collection can be easily accessed via the call FirestoreUtils.collection<T>(). For example, the collection with FirestoreRecipe objects would be accessed by the call FirestoreUtils.collection<FirestoreRecipe>().
Note that the private _collectionReferenceMap serves to create the CollectionReference objects lazily and to cache them. This way, on the first call of FirestoreUtils.collection<FirestoreRecipe>(), a converted CollectionReference<FirestoreRecipe> gets created, cached into _collectionReferenceMap and returned, and for all other calls it gets returned immediately from _collectionReferenceMap.
After getting handles to the collections, we can return the desired streams as follows:
Using the functions above, we can access a Stream<List<FirestoreRecipe>> with the call FirestoreUtils.firestoreModelStream<FirestoreRecipe>() and we can access a Stream<List<Recipe>> with the call FirestoreUtils.modelStream<Recipe, FirestoreRecipe>().
Next Steps
What we have introduced so far is already nice, since the most daunting implementation details are hidden behind these functions, however, we can make these calls a lot less verbose for the outside world. To do so, we need to introduce a final abstraction: a DAO interface.
Please tune in for our next post to see how introducing the DAO pattern will enhance the architecture.