Generic Repository for Firestore With Combine
During my pet project, my code was turning into spaghetti. It was time to learn about generics, as I felt my Firestore repository was spiraling out of control. My code did not follow the advice I had learned from the books I’d read and what my mentors had taught. It was time to learn about these damn generics.
This is my take on a generic FirestoreRepository using Combine. It’s not bulletproof, and there are probably many improvements. I want to preface this by asking you all to leave a comment if you have any suggestions, so I and other readers can improve.
The first issue I wanted to tackle was using a string path instead of dot notation. When using Firestore, we can access collection with collection(collectionName) or document(documentID).
My document references/collection paths would look something like this:
let ref = firestore.collection("users").document(uid).collection("bookshelves").document(bookshelfName).collection("books")
We both can agree that is UGLY.
I decided to go this route for collections:
enum CollectionPath {
case users
case bookshelf(bookshelf: Bookshelf)
case books(bookshelf: Bookshelf)
var uid: String {
Auth.auth().currentUser?.uid ?? ""
}
var stringValue: String {
switch self {
case .users:
return "users/(uid)"
case let .bookshelf(bookshelf):
return "users/(uid)/bookshelves/(bookshelf.shelfName)"
case let .books(bookshelf):
return "users/(uid)/bookshelves/(bookshelf.shelfName)/books"
}
}
}
And the same for documents:
enum DocumentPath {
case book(book: Book, bookshelf: Bookshelf)
var uid: String {
Auth.auth().currentUser?.uid ?? ""
}
var stringValue: String {
switch self {
case let .book(book, bookshelf):
return "users/(uid)/bookshelves/(bookshelf.shelfName)/books/(book.id ?? "")"
}
}
}
With that out of the way, we can now focus on the fun part.
Update 03/08/23: The Firebase SDK began supporting Combine.
So, using this GitHub repository would make this code even better. It’ll be even easier to add documents.
Thanks, Peter Friese, for pointing that out to me!
For the first operation of CRUD, we want to be able to add a document. So first, let’s break down this function signature, as they will almost be the same throughout the operations.
func addDocument(collection: CollectionPath, value: some Encodable) -> AnyPublisher<Void, Error>
The CollectionPath leads to the collection that holds documents, as described earlier when we created the CollectionPath enum.
The input parameter value is the value type, and it represents the data/document we want to add to Firestore. It has to conform to the Encodable protocol. We then return AnyPublisher<Void, Error>.
We don’t want any value back; we only want to wait until the code executes so we can implement a loading state. If AnyPublisher doesn’t make sense, I recommend reading about Combine’s publishers and subscribers first.
Here’s the completed function:
func addDocument(collection: CollectionPath, value: some Encodable) -> AnyPublisher<Void, Error> {
let subject = PassthroughSubject<Void, Error>()
do {
try firestore.collection(collection.stringValue).addDocument(from: value) { error in
if let error {
subject.send(completion: .failure(error))
} else {
subject.send(completion: .finished)
}
}
} catch let err {
subject.send(completion: .failure(err))
}
return subject.eraseToAnyPublisher()
}
I can’t explain a PassthroughSubject better than Apple, so here we go:
As a concrete implementation of Subject, the PassthroughSubject provides a convenient way to adapt existing imperative code to the Combine model.
The PassthroughSubject conforms to Subject and Publisher. Now, we have created a Publisher that will return either completed or failed with an error. Later, we will subscribe to this publisher. One way to do this is by setting loading to false once we receive the finished state or show an error if that is the case.
If you’d like to pass the value to the publisher, you could do that this way:
func addDocument<T: Encodable>(collection: CollectionPath, value: T) -> AnyPublisher<T, Error> {
let subject = PassthroughSubject<T, Error>()
do {
try firestore.collection(collection.stringValue).addDocument(from: value) { error in
if let error {
subject.send(completion: .failure(error))
} else {
subject.send(value)
subject.send(completion: .finished)
}
}
} catch let err {
subject.send(completion: .failure(err))
}
return subject.eraseToAnyPublisher()
}
Let’s take a look at how we can fetch documents:
func fetchDocuments<T: Decodable>(collection: CollectionPath) -> AnyPublisher<[T], Error> {
return firestore.collection(collection.stringValue)
.snapshotPublisher()
.map { snapshot in
snapshot.documents.compactMap { documentSnapshot in
try? documentSnapshot.data(as: T.self)
}
}
.eraseToAnyPublisher()
}
The first difference is that we now use <T: Decodable> in the function signature. T is the placeholder for the type, and with the constraint Decodable. This means the data type we pass must conform to Decodable. Here we don’t need to use PassthroughSubject as Firebase has been so kind to us and already created a Publisher for us.
All right, now you’re tired of me. Here is the full implementation:
import Combine
import FirebaseAuth
import FirebaseFirestore
enum CollectionPath {
case users
case bookshelf(bookshelf: Bookshelf)
case books(bookshelf: Bookshelf)
var uid: String {
Auth.auth().currentUser?.uid ?? ""
}
var stringValue: String {
switch self {
case .users:
return "users/(uid)"
case let .bookshelf(bookshelf):
return "users/(uid)/bookshelves/(bookshelf.shelfName)"
case let .books(bookshelf):
return "users/(uid)/bookshelves/(bookshelf.shelfName)/books"
}
}
}
enum DocumentPath {
case book(book: Book, bookshelf: Bookshelf)
var uid: String {
Auth.auth().currentUser?.uid ?? ""
}
var stringValue: String {
switch self {
case let .book(book, bookshelf):
return "users/(uid)/bookshelves/(bookshelf.shelfName)/books/(book.id ?? "")"
}
}
}
class GenericFirestoreRepository {
var firestore: Firestore {
Firestore.firestore()
}
func addDocument<T: Encodable>(collection: CollectionPath, value: T) -> AnyPublisher<T, Error> {
let subject = PassthroughSubject<T, Error>()
do {
try firestore.collection(collection.stringValue).addDocument(from: value) { error in
if let error {
subject.send(completion: .failure(error))
} else {
subject.send(value)
subject.send(completion: .finished)
}
}
} catch let err {
subject.send(completion: .failure(err))
}
return subject.eraseToAnyPublisher()
}
func mergeDocument<T: Encodable>(collection: CollectionPath, value: T, documentID: String) -> AnyPublisher<T, Error> {
let subject = PassthroughSubject<T, Error>()
do {
try firestore.collection(collection.stringValue).document(documentID).setData(from: value, merge: true) { error in
if let error {
subject.send(completion: .failure(error))
} else {
subject.send(completion: .finished)
}
}
} catch let err {
subject.send(completion: .failure(err))
}
return subject.eraseToAnyPublisher()
}
func fetchDocuments<T: Decodable>(collection: CollectionPath) -> AnyPublisher<[T], Error> {
firestore.collection(collection.stringValue)
.snapshotPublisher()
.map { snapshot in
snapshot.documents.compactMap { documentSnapshot in
try? documentSnapshot.data(as: T.self)
}
}
.eraseToAnyPublisher()
}
func fetchDocument<T: Decodable>(document: DocumentPath) -> AnyPublisher<T, Error> {
firestore.document(document.stringValue)
.snapshotPublisher()
.tryMap { documentSnapshot in
try documentSnapshot.data(as: T.self)
}
.eraseToAnyPublisher()
}
func deleteDocument<T: Decodable>(document: DocumentPath, documentID _: String) -> AnyPublisher<T, Error> {
let subject = PassthroughSubject<T, Error>()
firestore.document(document.stringValue).delete { error in
if let error {
subject.send(completion: .failure(error))
} else {
subject.send(completion: .finished)
}
}
return subject.eraseToAnyPublisher()
}
func updateDocument<T: Encodable>(document: DocumentPath, value: T) -> AnyPublisher<T, Error> {
let subject = PassthroughSubject<T, Error>()
do {
try firestore.document(document.stringValue).setData(from: value) { error in
if let error {
subject.send(completion: .failure(error))
} else {
subject.send(value)
subject.send(completion: .finished)
}
}
} catch {
subject.send(completion: .failure(error))
}
return subject.eraseToAnyPublisher()
}
}
We would then want to initialize this repository in our use case.
If we want to add a book, we can write this code:
import Combine
import Foundation
class AddBookUseCase {
let repository = GenericFirestoreRepository()
func add(book: Book, bookshelf: Bookshelf) -> AnyPublisher<Book, Error> {
repository.mergeDocument(collection: .books(bookshelf: bookshelf), value: book, documentID: book.id ?? "")
}
}
Now we can use this case wherever we want to add a book(or any other data type) to Firestore!
We have to attach a subscriber to the publisher, and for this example, I will use the sink operator:
class BookViewModel {
private var cancellables = Set<AnyCancellable>()
func addBook(book: Book, bookshelf: Bookshelf) {
addBookUseCase.add(book: book, bookshelf: bookshelf)
.sink { completion in
switch completion {
case .finished:
print("Book added!")
case let .failure(err):
print(err)
}
} receiveValue: { value in
print(value)
}.store(in: &cancellables)
}
}
Now you have an end-to-end implementation that will execute CRUD operations with Combine and Firestore.
Thanks for reading.
Generic repository for Firestore with Combine was originally published in Better Programming on Medium, where people are continuing the conversation by highlighting and responding to this story.