Flutter Repository Pattern
The Repository pattern is a simple one, you use a Repository to abstract away the implementation details of how to store and retrieve data. With Flutter, it can be complicated (especially when using Firestore) to implement the Repository pattern in a repeatable, testable way. I have heard from friends and other budding Flutter developers that testing with Firebase is a problem. Not so if you follow some well-known design patterns out there.
Define your model
First, you should define what the shape of your data looks like. For me, this usually starts with a plain Dart class. The members being the data that I would like to store.
Also, adding a static collection getter can be convenient.
class ModelClass {
final String myString;
final Timestamp createdAt;
final DocumentReference otherDoc;
// More on why I'm important later.
late DocumentReference ref;
ModelClass({required this.myString, required this.createdAt, required this.otherDoc});
static CollectionReference getCollection(FirebaseFirestore db) {
return db.collection('myModel');
}
}
Here is where we get into the actual details of this implementation and why this pattern works. The collection getter is using a pattern called Dependency Injection. This means that we can pass anything that conforms to the FirebaseFirestore interface into our collection getter. More on why this is important later.
Now, within our class, we can start defining static methods that create/retrieve instances of our model.
Querying and Adding Data
Add some functions to the class, that will create and retrieve ModelClass instances.
class ModelClass {
//...
/// Serialize a ModelClass from the actual Firestore data.
static ModelClass fromMap(Map<String, dynamic> data) {
return ModelClass(
myString: data['myString'],
createdAt: data['createdAt'],
otherDoc: data['otherDoc']);
}
/// Serialize the result from the database into a ModelClass object.
static ModelClass fromDocumentSnapshot(DocumentSnapshot ds) {
final modelClass = fromMap(ds.data() as Map<String, dynamic>);
modelClass.ref = ds.reference;
return modelClass;
}
static Future<ModelClass> add(FirebaseFirestore db, String myString, DocumentReference otherDoc) async {
final documentSnapshot = await getCollection(db).add(
{'myString': myString, 'createdAt': Timestamp.now(), 'otherDoc': otherDoc});
return fromDocumentSnapshot(documentSnapshot);
}
//...
}
I usually start with the actual functionality that I need, and work my way down with further abstractions if necessary. So, I need to add a model class. First I’ll write the method that does the adding. I’ll inject into this method my FirebaseFirestore dependency, and take my String/DocumentReference params I’ll need for creating my object. The `createdAt` field can be filled automatically with the static method `Timestamp.now()`, which constructs a Timestamp matching the current time.
The return value of .add in Flutter is a Future<DocumentSnapshot>, so I’ll have to await it to get the actual value. Now, I could pass the DocumentSnapshot value around in the code, but that would leave my tightly coupled to Firestore. Instead, I’d like to convert this DocumentSnapshot into my nice to use Dart object. To do that, I’ll need to write some serialization methods.
fromDocumentSnapshot will do just that, turn a documentSnapshot into a ModelClass instance. However, DocumentSnapshots objecsts have both data and a reference associated with them. The data is self-explanatory, but the reference is Firestore-specific and refers to a specific location in the database. In Firestore, you can use references like foreign keys, to create relationships between documents. I choose to give all of my model classes references, because it makes it easier when coding later to introduce relationships if necessary.
So fromDocumentSnapshot will delegate the object creation to fromMap. fromMap will convert the Firestore document data into a ModelClass instance. Then with that instance, we set the ref attribute with ds.reference.
This is essentially the Repository pattern!
Where Does Riverpod Fit?
Riverpod will allow us to seamlessly inject our FirebaseFirestore dependency, and allow us to easily swap out FirebaseFirestore instances at test time. Here’s how I typically will set this up.
In some file somewhere, I’ll define a provider for FirebaseFirestore.
/// Provide firestore to various widgets.
final firestoreProvider = Provider((ref) => FirebaseFirestore.instance);
Now we begin our implementation of the repository pattern.
class ModelClassRepository {
final FirebaseFirestore db;
ModelClassRepository({required this.db});
}
Creating a class that takes as input a FirebaseFirestore instance is again dependency injection. This allows the ModelClassRepository to not care about the database that is passed in. Instead, we use this db to call static methods in the ModelClass
class ModelClassRepository {
//...
Future<ModelClass> add(String myString, DocumentReference otherDoc) async {
return await ModelClass.add(this.db, myString, otherDoc);
}
}
This ties everything together. The ModelClassRepository is responsible for hooking together the ModelClass and the database. The ModelClass is responsible for creating and maintaining ModelClass instances within Firestore and in the code.
Now, to make this available within your widgets:
final modelClassRepositoryProvider = Provider((ref) {
final firestore = ref.read(firestoreProvider); // Our provider ref from earlier! Cool right?
return ModelClassRepository(db: firestore);
});
In this declaration with Riverpod, we have stated that we want to read the firestoreProvider, then use that value when constructing a modelClassRepositoryProvider. This logically ties the two providers together, and now the ModelClassRespository can be used in widgets. Here’s an example.
class ModelClassScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final modelClassRepository = ref.read(modelClassProvider)
// Now you can use the add method in your widget... bind via onPressed
// ...
}
}
ConsumerWidget is a class defined by Riverpod, that gives you the build method which includes WidgetRef. WidgetRef is how we obtain the reference to our modelClassRepository, which we can then bind in various event handlers in the code.
Testing
The real strength of this pattern comes from testing. With Riverpod, it is quite easy to inject test values into your providers, ensuring that you run your tests in a repeatable way. We do this with FirebaseFirestore in particular, and leave the rest of the code untouched when testing widgets that include our modelClassRepository. If you inject the FirebaseFirestore class via Riverpod, then this approach should always work for including a mock database.
First, you’ll need the https://pub.dev/packages/fake_cloud_firestore package. Follow the instructions there to get everything setup.
Now, in your tests, you can set up your test providers like this.
void main() {
testWidgets('test provider', (tester) async {
final fakeFirebase = FakeFirebaseFirestore();
await tester.pumpWidget(ProviderScope(
overrides: [
firestoreProvider.overrideWith((ref) { // Custom provider function
return fakeFirebase;
});
],
child: MaterialApp(home: ModelClassScreen())
));
// Now you do things with the tester that ensure modelClassRepository.add() is called.
// Then you can assert on the fake firebase results!
final modelClassResults = fakeFirebase.collection('myModel') // same as before.
.get();
expect(modelClassResults.docs.length, greaterThan(0));
});
}
And there you have it!
I had a lot of success using this approach. I’ve loved it so far. It’s based on the work of great Flutter developers out there already, I’ve just added my own flair. Thanks for the read!