Generics and Streams in Dart
Tags: flutter, testing, programming, mobile
Published: 2023-09-25 16:42:54

In my app [Stash Hub](https://stashhubapp.com), 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
```dart
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:
1. Using the [handleError](https://api.dart.dev/stable/3.1.2/dart-async/Stream/handleError.html) method on a `Stream` to conditionally intercept errors and transform them into other errors (or drop them)
2. Using `try-catch` inside the `map` function

These errors are then handled further down the call-stack inside the `onError` callback that’s part of the `listen` method.
### [Stream.map](https://api.dart.dev/stable/3.1.2/dart-async/Stream/map.html)
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](https://api.dart.dev/stable/3.1.2/dart-async/StreamController-class.html) 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](https://pub.dev/packages/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](https://pub.dev/packages/fake_cloud_firestore), [firebase_auth_mocks](https://pub.dev/packages/firebase_auth_mocks) and [mock_exceptions](https://pub.dev/packages/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” of `expect()` 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](https://codewithandrea.com/articles/async-tests-streams-flutter/) from Andrea was a great resource.
- `expect` and `expectLater` must use `listen` under the hood (or similar) because it causes the stream being tested to start emitting events.
```dart
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](https://dart.dev/language/class-modifiers#sealed). 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](https://dart.dev/language/constructors#:~:text=Remember%20that%20constructors%20are%20not,that%20constructor%20in%20the%20subclass.) 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](https://stashhubapp.com) and I hope to continue writing about my journey on a weekly basis.