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.
Previously, in the third post of this series, we discovered how introducing the DAO pattern with the FirestoreDao interface facilitated our interactions with Firebase Firestore. If you have not had the chance to read it yet, you are more than welcome to catch up with the preceding post now.
You can find all the code created for this post in the companion repository.
Battle-testing the design
Let's revisit the UI layer of our Recipe app to see how the introduced architecture can be leveraged! We are going to modify the existing RecipesScreen to source the recipes from Firestore using the DAO, then we are going to create AuthorsScreen to demonstrate the advantages of using generics.
Instead of sourcing the recipes from the dummy array, we can use our Stream<List<Recipe>> provided by FirestoreDao to access the real-time data from Firestore. We render the contents of the stream using a StreamBuilder widget.
This screen is a simple list that displays the data of authors. The remarkable thing here, however, is that without a single modification on the database service layer, we received a stream of a converted list of all the Author model objects, just by calling FirestoreDao.of<Author>().modelStream().
Let's just think about what is really happening in the background, let's do some mental stack tracing from top to bottom!
- Calling FirestoreDao.of<Author>() creates a new instance of FirestoreAuthorDao, by invoking the callback defined in FirestoreDao._firestoreDaoMap
- The returned FirestoreAuthorDao instance invokes its modelStream() implementation
- FirestoreUtils.modelStream<Author, FirestoreAuthor>() is invoked
- FirestoreUtils.firestoreModelStream<FirestoreAuthor>() is invoked
- Using the type parameter FirestoreAuthor we lookup the Firestore CollectionReference<FirestoreAuthor> from FirestoreUtils._collectionReferenceMap based on the the name of the Firestore collection, obtained from FirestoreUtils._collectionPathMap. To get hold of the converted collection, we invoke Converters.mapConverter<FirestoreAuthor>() to access the FirestoreAuthorMapConverter instance.
At this point, any sane programmer would rightfully ask the question: Do I really need this level of complexity?
Evaluating the design
Apparently, in the previous sections, I did not mention the flaws and disadvantages of this design. Furthermore, I omitted to list scenarios when you might NOT need to implement this design.
This architecture could have been greatly simplified by working with descendants of BaseModel only and forgetting about the usage of a FirestoreModel.
And indeed, it would be the right decision to make if you would just need to store a single Firestore-specific property, like modelID in this example. It would have been a no-brainer solution to just add modelID as a field of BaseModel. However, in more complex applications using FirestoreModel might be justified.
The depth of the 'mental stack trace' in the previous section also highlighted the inherent complexity of the design. For smaller apps, it is definitely an overkill to implement the approach that I introduced today.
Yet, from another perspective, the abstract nature of this design can be a great benefit as well! Once you manage to customize the architecture introduced here to fit your needs, you will be able to reuse it in all your upcoming projects with tiny structural modifications. Furthermore, the DAO layer can be easily integrated with a state-management library, such as Provider.
Due to the fact that each of the introduced components (Converters, DAO) are extensible easily and in an isolated manner, it is also a good fit for projects where the requirements are not entirely clear yet or they might be modified from time to time.
Wrapping it all up
While the Cloud Firestore Plugin for Flutter gives a terrific basis for working with Firestore from a Flutter codebase, it does not solve all the architectural challenges you might face when developing a non-trivial app. In this post, I strived to propose a viable solution to a common challenge: separate the UI layer from the business layer while maintaining a codebase that is easy to extend and has an overall low coupling paired with high cohesion.