In my app Stash Hub, there are a number of related types of “Records”. Each type of Record
needs to be fetched from the database, updated and deleted (i.e. CRUD). At the beginning of the app’s life, they all existed as their own distinct type and this was manageable because there were only 3 or 4 different ones. However, as the app has grown, this has increased to 8 distinct types (and there’s a good chance it could become more).
To streamline the process, I did what any well-behaved developer would do and reduced the amount of code duplication by creating a super-type (BaseRecord
) that each distinct type extends. I then created a single “service” class that could perform CRUD operations on any of the sub-types. This has worked amazingly well so I wanted to share the code and my thoughts below.
The code
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:stash_hub/record_utils/firebase_notion_exception_extension.dart';
import 'package:stash_hub/records/record.dart';
import 'package:stash_hub/records/record_exceptions.dart';
import 'package:stash_hub/records/record_service.abs.dart';
typedef FromJson<T> = T Function(Map<String, dynamic>);
/// Implementation of the [RecordService] interface for a specific type of record.
class RecordServiceImpl<T extends BaseRecord> implements RecordService<T> {
const RecordServiceImpl({
required this.firebaseAuth,
required this.firestore,
required this.collectionName,
required this.fromJson,
});
final FirebaseAuth firebaseAuth;
final FirebaseFirestore firestore;
final String collectionName;
final FromJson<T> fromJson;
/// Add a new Record.
@override
Future<void> addRecord(T record) async {
try {
return await firestore
.collection('users')
.doc(firebaseAuth.currentUser!.uid)
.collection(collectionName)
.doc(record.id)
.set(record.toJson());
} on FirebaseException catch (e) {
throw e.convert<T>(record.id);
} catch (e, st) {
throw UnknownRecordException<T>(record.id, e.toString(), st);
}
}
/// Handles Firebase stream errors by converting them to a custom exception type.
///
/// If the error is a [FirebaseException], it is converted to the custom exception type using the `convert` method.
/// Otherwise, the error is thrown as is.
///
/// The method takes in an [error] object and a [st] stack trace.
void _handleFirebaseStreamError(Object error, StackTrace st, String? recordId) {
// Don't want to handle raw Firebase exceptions in the app so converting to own type
if (error is FirebaseException) {
throw error.convert<T>(recordId ?? '');
} else {
throw error;
}
}
/// Get a list of all Records in the system returned as a `Stream`.
/// Returns `null` where there are no records yet available.
/// Choice: `null` over Exception because there may be no records yet available and this is fine.
@override
Stream<List<T>?> getAllRecordsStream() {
return firestore.collection('users').doc(firebaseAuth.currentUser!.uid).collection(collectionName).snapshots().map((snapshot) {
if (snapshot.docs.isNotEmpty) {
return snapshot.docs.map<T>((doc) {
try {
return fromJson(doc.data());
} catch (e, st) {
throw UnknownRecordException<T>(doc.id, e.toString(), st);
}
}).toList();
} else {
return null;
}
}).handleError(
(error, st) => _handleFirebaseStreamError(error, st, null),
test: (error) => error is FirebaseException,
);
}
/// Given a specific [recordId] returns a `Stream` of that Record.
/// Throws `RecordNotFoundException` if no record exists with the given [recordId].
/// Choice: Exception over null because there should always be a record with the given ID.
@override
Stream<T> getRecordStream(String recordId) {
return firestore.collection('users').doc(firebaseAuth.currentUser!.uid).collection(collectionName).doc(recordId).snapshots().map<T>(
(snapshot) {
if (snapshot.exists && snapshot.data() != null) {
return fromJson(snapshot.data()!);
} else {
throw RecordNotFoundException<T>(recordId, 'No ${T.runtimeType.toString()} found', StackTrace.current);
}
},
).handleError(
(error, st) => _handleFirebaseStreamError(error, st, recordId),
test: (error) => error is FirebaseException,
);
}
/// Remove the given `T` [record].
@override
Future<void> removeRecord(T record) async {...}
/// Update a given `T` [record].
@override
Future<void> updateRecord(T record) async {...}
}
Key points
Handling errors
The main objective of the error handling in the service class is to convert any implementation specific exceptions (i.e. FirebaseExceptions
) to app specific ones. This is done in two places:
- Using the handleError method on a
Stream
to conditionally intercept errors and transform them into other errors (or drop them) - Using
try-catch
inside themap
function
These errors are then handled further down the call-stack inside the onError
callback that’s part of the listen
method.
Stream.map
I prefer using this over transform
as it’s a simpler API and conceptually is the same as the map
method on lists. I find this is perfect for turning the JSON/Map data returned from Firestore to my own types.
Firestore emits previous events on listen
Normally, a Stream
will not emit previous events when listened to. This includes both broadcast and non-broadcast streams (although, when creating a stream yourself using a StreamController events are buffered until there is at least 1 listener, but this feature shouldn’t generally be used. If you do want previous events to be emitted when there is a new listener, check out the RxDart package).
However, I have found that Firebase streams obtained using a doc
or collection
’s snapshots()
method will emit a new Stream
event when listened to, which is pretty handy to be honest, but worth keeping in mind.
null
vs throwing an Exception
There seems to be a bit of divide as to whether using Exceptions as a control flow mechanism is an anti-pattern or not. I’m leaning towards the opinion that it is an anti-pattern, especially in a language like Dart that has null-safety.
With this in mind, I decided that when trying to fetch all records, it should not throw an exception if there is nothing, because that is a reasonable state for the user to be in (e.g. a brand new user). However, when fetching a single record by an id and it doesn’t return anything, that does throw as it is situation that shouldn’t occur.
I’m going to try and take this mentality forward with the rest of the app, as there are some other places where I use exceptions as a control flow mechanism.
Testing
To make sure exceptions were being handled correctly, I of course wrote some tests. I used the fake_cloud_firestore, firebase_auth_mocks and mock_exceptions packages to make mocking/faking the external dependencies easier.
The mock_exceptions
package is pretty cool as it offers a way to easily trigger exceptions in functions (including the fake_cloud_firestore
package because it is written by the same author).
A few key things I learnt during this process:
-
expectLater()
is the “future-version” ofexpect()
and can be used when interacting with a stream after setting out the expectations. I didn’t need to use it in my case. This article from Andrea was a great resource. -
expect
andexpectLater
must uselisten
under the hood (or similar) because it causes the stream being tested to start emitting events.
import 'package:firebase_auth_mocks/firebase_auth_mocks.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:fake_cloud_firestore/fake_cloud_firestore.dart';
import 'package:mock_exceptions/mock_exceptions.dart';
import 'package:mocktail/mocktail.dart';
import 'package:stash_hub/records/record.dart';
import 'package:stash_hub/records/record_exceptions.dart';
import 'package:stash_hub/records/record_service.impl.dart';
const mockUserId = '1234';
const mockCollection = 'mockCollection';
const recordId = 'abcd';
class MockRecord extends Mock implements BaseRecord {...}
void main() {
late FakeFirebaseFirestore fakeFirestore;
late MockFirebaseAuth mockAuth;
late MockRecord mockRecord;
setUp(() {
fakeFirestore = FakeFirebaseFirestore();
mockAuth = MockFirebaseAuth(
signedIn: true,
mockUser: MockUser(uid: mockUserId),
);
mockRecord = MockRecord(id: recordId);
});
RecordServiceImpl<MockRecord> createSubject() {
return RecordServiceImpl<MockRecord>(
firebaseAuth: mockAuth,
firestore: fakeFirestore,
collectionName: mockCollection,
fromJson: MockRecord.fromJson,
);
}
group('Firebase Record service implementation -', () {
group('getRecordStream() -', () {
test('Stream emits a [RecordException] when [FirebaseException] is thrown internally', () {
// Arrange
final subject = createSubject();
final doc = fakeFirestore.collection('users').doc(mockUserId).collection(mockCollection).doc(recordId);
whenCalling(Invocation.method(#snapshots, null)).on(doc).thenThrow(
FirebaseException(
plugin: 'firestore',
code: 'unknown',
message: 'Test exception',
),
);
// Act
final stream = subject.getRecordStream(recordId);
// Assert
expect(stream, emitsError(isA<RecordException>()));
});
});
});
// Lots more tests...
}
Potential Improvements
Use sealed classes
I wrote the majority of the code before Dart 3.0, so there were no sealed classes. When I have the time, I should go back and have BaseRecord
be a sealed class. This would make it possible to use switch
statements against <T extends BaseRecord>
.
That way I could have the collectionName
logic exist inside the Firebase implementation instead of having to be passed in (to be fair, I could do that now, but the compiler would not help me out were I to add a new type).
There are also a number of other places around the app it would help me out so maybe when I do get round to it, I’ll make another post highlighting the benefits.
fromJson
function
In Dart, constructors are not inherited which means they are not part of the API for BaseRecord
. This means that I cannot do something like T.fromJson(jsonData)
. To get around this, I pass in the fromJson()
factory method that each subtype has, as an argument to the service class constructor. This seems okay but I feel like there must be a better way, so if you can think of one, please let me know.
Thanks for reading this far and please remember to share if you found it interesting or learnt something new.
I am currently spending most of my time working on Stash Hub and I hope to continue writing about my journey on a weekly basis.
Top comments (0)