DEV Community

Cover image for Generics and Streams in Dart
Doug Todd
Doug Todd

Posted on

Generics and Streams in Dart

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 {...}
}
Enter fullscreen mode Exit fullscreen mode

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 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

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” 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 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.
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...
}

Enter fullscreen mode Exit fullscreen mode

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)