Firestore improvements for iOS app development

Profile photo of Max Kalik
Max Kalik
Mar 23, 2023
11 min
Categories: Development
An iOS app displaying Firestore improvements on a computer screen
An iOS app displaying Firestore improvements on a computer screen

Improving the usage of Firebase by creating an extension with a set of helpers for reading, updating, listening, and querying data from Firestore by using custom Requests.

Firebase provides a comprehensive set of tools and services that make it easy for developers to build and deploy web and mobile applications quickly and easily. Its ease of use, real-time database, authentication and authorization, hosting, analytics, and Cloud Functions make it an excellent choice for developers of all levels of experience.

Almost any software engineer used these services before, especially at the beginning of their career. I personally used to use Firebase in my first projects years ago.

Besides all provided features in Firebase, the key one is easy-to-use. Firebase is easy to set up and use, with a user-friendly dashboard and documentation that makes it convenient for developers to get started quickly.

But even with all these prons, when it comes to a more complex project than just some MVP, we can come across with an architectural question: How to organize better communication with Firebase? In this article, I will try to answer this question by focusing on Firebase Cloud Firestore in iOS applications.

Note: This article is assumed to be for those who have an experience in Swift programming language at least in middle grade and who are already familiar with Firestore because I’m going to omit some obvious moments of how to install and start to use Firebase.

Firestore Documentation

Let’s take a look at how Firebase suggests starting to set and reading the data. After installing, configuring, and initializing Firestore we can use it for setting and reading.

import FirebaseCore import FirebaseFirestore FirebaseApp.configure() let db = Firestore.firestore()

There is a simple concept of setting data. Create a reference to the collection and set the documents there with some data.

let citiesRef = db.collection("cities") citiesRef.document("SF").setData([ "name": "San Francisco", "state": "CA", "country": "USA", "capital": false, "population": 860000, "regions": ["west_coast", "norcal"] ]) citiesRef.document("LA").setData([ "name": "Los Angeles", "state": "CA", "country": "USA", "capital": false, "population": 3900000, "regions": ["west_coast", "socal"] ])

Retrieving is also pretty straightforward by using a reference to the particular document from the collection.

let docRef = db.collection("cities").document("SF") docRef.getDocument { (document, error) in if let document = document, document.exists { let dataDescription = document.data().map(String.init(describing:)) ?? "nil" print("Document data: \(dataDescription)") } else { print("Document does not exist") } }

What conclusion can be drawn from the above? As you can see to access some data by reference we need to use path components — the names of collections and documents. The document itself should be set as a dictionary. But in real life (in real app development), it can be hard to maintain and test such an approach, especially if your app is scaling. Hence we need some order, structure, and a helper. So let’s try to improve this by making an extension for the Firestore object.

Response

As you know responses (documents) from Firestore are Codable so let’s just create our simple protocol and call it Response.

protocol Response: Codable { }

In case a response is empty we can use this simple struct.

struct EmptyResponse: Response { init() {} }

Request

When we know about a response, let’s build our next protocol called Request. If you check any reference to access data from Firestore then we can consider it as a path, so let’s add this property as a type of String. The output is going to be the Response itself as the associated type.

protocol Request { associatedtype Output: Response var path: String { get set } var output: Output? { get } }
extension Request { var output: Output? { nil } }

Firestore extension

Let’s start with the simple part. Right in the extension we are going to have static access to the Firestore database. It’s needed for the future methods which we are going to implement in this extension.

extension Firestore { static var db = Firestore.firestore() // ... }

Get Document

Let’s start with a simple reading of a document from a collection. I suggest using the async/await approach since Firebase is providing its methods in this new way.

All methods are with a single argument – request. As you remember, the protocol request has everything to build Firestore reference and type of response. From the request, we need only the path and output for now.

extension Firestore { // ... static func get<R: Request>(request: R) async throws -> R.Output? { try await get(request.path) } // ... }

But to make it work we need a little bit to extend the dictionary and the data method from Document Snapshot. To extend the dictionary we need to be sure that a key is supposed to be a string and a value is any.

extension Dictionary where Key: ExpressibleByStringLiteral, Value: Any { func toData() -> Data? { try? JSONSerialization.data(withJSONObject: self) } func toCodable<T: Codable>(of type: T.Type) -> T? { guard let data = toData() else { return nil } return try? JSONDecoder().decode(T.self, from: data) } }

These dictionary methods toData and toCodable help to prepare a Codable object for the data method in DocumentSnapshot. As you can see we just passing T as Codable — it supposes to be an Output.

import FirebaseFirestore extension DocumentSnapshot { func data<T: Codable>(as: T.Type) -> T? { data()?.toCodable(of: T.self) } // ... }

To combine all the pieces we will get a final method in Firestore which takes only the path to the document but the data method already knows about the type of the document and it returns us a prepared Codable model.

extension Firestore { // ... static func get<R: Request>(request: R) async throws -> R.Output? { try await get(request.path) } static func get<T: Codable>(_ path: String) async throws -> T? { try await db.document(path) .getDocument() .data(as: T.self) } // ... }

Get Documents from a Collection

We built a request protocol and applied it in Firestore extended methods to get a document. Now let’s use request in the case when it’s needed to get documents from a Firestore collection. It’s pretty straightforward and looks similar with one difference — we need to cast all fetched documents to our actual array model type using compactMap.

extension Firestore { // ... static func get<R: Request>(request: R) async throws -> [R.Output] { try await self.get(request.path) } static func get<T: Codable>(_ path: String) async throws -> [T] { try await db.collection(path) .getDocuments() .documents .compactMap { $0.data(as: T.self) } } // ... }

How the method data(as: Type.self) works you can read above.

Extend Request possibilities

In this shape, our request looks a bit primitive. Let’s make it more useful and adjustable. Let’s assume we need to get documents with a limit count. In the request protocol, we can describe a property with a name limit as an optional integer.

protocol Request { associatedtype Output: Response // ... var limit: Int? { get } }

The Firestore extension method get can be improved using this limit from the request.

extension Firestore { // ... static func get<R: Request>(request: R) async throws -> [R.Output] { guard let limit = request.limit else { return try await self.get(request.path) } return try await db.collection(request.path) .limit(to: limit) .getDocuments() .documents .compactMap { $0.data(as: R.Output.self) } } // ... }

Firestore Listeners

Before starting to discuss observing from Firestore I need to make a small remark. If you are familiar with how Firestore listeners work and what you have to keep in mind using references there you can just jump into the next paragraph. I’m not going to show you here how to solve the problem of multiple listeners and how to keep your set of listeners clean. Check Firestore documentation on how to detach listeners.

At this time we will use completions instead of async/await, but it won’t change the main conception of using request protocol. To handle the result of the completion we will use a pretty well-known and convenient swift enum Result with associated values Output and Error. Plus for a more subtle configuration, we will use our own Error type, let’s start with it.

At this time we will use completions instead of async/await, but it won’t change the main conception of using request protocol. To handle the result of the completion we will use a pretty well-known and convenient swift enum Result with associated values Output and Error. Plus for a more subtle configuration, we will use our own Error type. Let’s start with that.

enum FirestoreError: Error { case error(Error) case noData }

Document Listening

We have all components to build new methods for listening documents: request protocol, output, and associated error type in the result. As before we can build two static generic helpers. Using FirestoreError we can catch specific failures, for example, when data is nil.

extension Firestore { // ... static func listenDocument<R: Request>(request: R, completion: @escaping (Result<R.Output, FirestoreError>) -> Void) { let ref: DocumentReference = db.document(request.path) listenDocument(ref, completion: completion) } static func listenDocument<T: Codable>(_ ref: DocumentReference, completion: @escaping (Result<T, FirestoreError>) -> Void) { ref.addSnapshotListener { snapshot, error in if let error = error { completion(.failure(.error(error))) } else if let result: T = snapshot?.data(as: T.self) { completion(.success(result)) } else { completion(.failure(.noData)) } } } // ... }

Collection Listening

At this time we are not going to use Firestore DocumentReference. Instead, we will prepare our methods using QueryFIRQuery. For now, it looks pretty similar to us when we used Reference. The pattern also looks the same when we build a helper for reading documents from the Firestore collection (read above). As a result, we will get success or failure. If there is no data then we just return success with an empty array.

extension Firestore { // ... static func listenDocuments<R: Request>(request: R, completion: @escaping (Result<[R.Output], FirestoreError>) -> Void) { let query: Query = db.collection(request.path) listenDocuments(query, completion: completion) } static func listenDocuments<T: Codable>(_ query: Query, completion: @escaping (Result<[T], FirestoreError>) -> Void) { query.addSnapshotListener { snapshot, error in if let error = error { completion(.failure(.error(error))) } else if let result: [T] = snapshot?.documents.compactMap({ $0.data(as: T.self) }) { completion(.success(result)) } else { completion(.success([])) } } } // ... }

If you check what Query is you will get this info from the documentation:

A Query refers to a query which you can read or listen to. You can also construct refined Query objects by adding filters and ordering.

Since we use Query protocol for listening to Firestore collections, let’s try to extend our possibilities from a Request perspective. For querying a collection we have multiple tools but let’s use something more interesting: NSPredicate!

A definition of logical conditions for constraining a search for a fetch or for in-memory filtering.

protocol Request { // ... var queryPredicate: NSPredicate? { get } }

Using this constraint we can pass it right to our Firestore Query for querying some particular data from a database. Thus, in the method where we pass a request, we can check for predicate existence and use it in the query filter method.

extension Firestore { // ... static func listenDocuments<R: Request>(request: R, completion: @escaping (Result<[R.Output], FirestoreError>) -> Void) { var query: Query = db.collection(request.path) if let predicate = request.queryPredicate { query = query.filter(using: predicate) } listenDocuments(query, completion: completion) } // ... }

Updating Document

First of all, I strongly recommend not updating your data right from the client. If you are building a more robust architecture with the backend part, it’s better to use particular endpoints to update data in Firestore. But to complete our idea let’s consider updating documents as well.

In the Request protocol, let’s add an additional property — data fields that are supposed to be sent to the database. I called it updatedDataFields but you can come up with something on your own.

protocol Request { // ... var updatedDataFields: Codable? { get } }

Original Firestore’s DocumentReference method updateData accepts fields as NSDictionary, but in a request, we have only a Codable element. This means we need an additional extension for the Encodable protocol for having access to the dictionary from the Codable object. Manipulating with Foundation's amazing API JSONEncoder and JSONSerialization helpers we can prepare everything we need: dictionary and data optional properties.

extension Encodable { var dictionary: [String: Any]? { guard let data = self.data else { return nil } return ( try? JSONSerialization.jsonObject( with: data, options: .allowFragments ) ) .flatMap { $0 as? [String: Any] } } var data: Data? { try? JSONEncoder().encode(self) } }

In the example below, we can use async/await methods from Firestore that simplifies our implementation and converts updateDataFields into a dictionary.

extension Firestore { // ... static func update<R: Request>(request: R) async throws { try await update( data: request.updatedDataFields?.dictionary ?? [:], path: request.path ) } static func update(data: [AnyHashable: Any], path: String) async throws { try await db.document(path).updateData(data) } // ... }

Examples

When we have prepared almost all the major helpers in the Firestore extension we can try them out. Let’s start with a small request (you can come up with an example yourself).

As you can see the struct Preferences conforms to the Response protocol, nothing else.

struct Preferences: Response { let name: String let message: String? let version: Int }

Then it’s time to prepare a request. It’s another struct conformed to the Request protocol where the output is the Preferences type and the path to this document in Firestore.

struct PreferencesRequest: Request { typealias Output = Preferences var path: String = "config/preferences" }

Reading Document Example

Now you can use this prepared request. Let’s make a method fetchPreferences.

func fetchPreferences() async throws -> Preferences? { let request = PreferencesRequest() return try await Firestore.get(request: request) }

Listening Document Example

If you need to listen to this document, the method can be transformed in this way:

func observePreferences() { let request = PreferencesRequest() Firestore.listenDocument(request: request) { result in switch result { case .success(let output): print(output) case .failure(let error): print(error) } } }

Querying using NSPredicate

Let’s say in the database we have a collection of users. The first step is creating a model of the expected data from Firestore.

struct User: Response, Identifiable { let id: String let username: String let userpic: String let followers: [String] }

We are going to fetch an array of users, but not all of them, so we need to filter users using Firestore API but only in the scope of our request. Here we can use our prepared queryPredicate. For that, we need to make NSPredicate an object with format value as a String and argument (id) to substitute into predicateFormat.

struct UserFollowersRequest: Request { typealias Output = User var path: String var queryPredicate: NSPredicate? init(id: String) { self.path = "users/" self.queryPredicate = NSPredicate(format: "followers CONTAINS %@", id) } }

Eventually, the method of fetching users will look like this.

func observeUserFollowers() { guard let id = Auth.auth().currentUser?.uid else { return } let request = UserFollowersRequest(id: id) Firestore.listenDocuments(request: request) { result in switch result { case .success(let output): print(output) case .failure(let error): print(error) } } }

Conclusion

As you can see the approach of using Request / Response in Firestore can drastically simplify the structure of your project and development process if you are developing an app with a team. You could arrange even something like a session layer where you would structure all your requests by domain.

Screenshot 2023-03-23 at 11.10.18.png

If you don’t want to import everywhere Firebase dependencies (it would be a good point), then it’s pretty reasonable to make your own service where you could pass all these Firestore extension methods.

Of course, in this article, I didn’t touch on all aspects and possible scenarios of using Firestore, such as reading arrays or dictionaries from a document, handling multiple listening references, and eventually more options for querying, because these problems (in the scope of this article), are more specific and technical.

The coolest feature of having a Request protocol is not using it only in Firestore. You can use it to build requests even for URLSession as well to update documents in the database, for example. I’m successfully using this approach in several projects and trying to improve communication between a client and the Firestore database.

How do you use Firestore?

 


Source code: https://github.com/maxkalik/firestore-extension

Big thanks for the review Raitis Šaripo and Chili Labs team 🌶.

You can find me on Twitter and share your feedback!

— Max 👋

Share with friends

Let’s build products together!

Contact us