visit
To solve this problem, I first took a step back and looked at the patterns in the code I was writing. Each cloud function was performing multiple tasks and running the same code for different collections. But this goes against the very idea of a function. defines it as:
A sequence of program instructions that performs a specific task, packaged as a unit.
Rather than combining all the logic in a single cloud function, each task should be separated into its own cloud function triggered by the same event. There are no technical limitations preventing multiple functions subscribing to the same trigger.
Additionally, there was no good reason to be copy-pasting code that much anyway, so logic shared across different cloud functions should be written in its own function. For example, each -onUpdate cloud function should call a generalised syncFieldsToAlgolia function, where an argument can be passed to specify the fields to sync.While those two steps improve the reliability of the resulting cloud functions, they don’t do much to solve the poor developer experience of having to explicitly write a cloud function for each combination of collection, Firestore trigger, and task. To re-implement the three tasks I mentioned above with this solution, I would need to explicitly write 3 cloud functions for each of the 3 Firestore triggers for the 2 collections mentioned — that’s 18 cloud functions in total!Since each individual cloud function task has been simplified to a single function call with specific arguments, I could write a higher-order function that generates these cloud functions for me. All it needs is to take the collection name and any configuration arguments to be passed to the function. This allows me to separate the business logic, such as what fields need to be synced, with how this logic is implemented in the code.
In fact, this is the same approach that makes React so popular: it lets developers declare what should appear on screen without worrying about how to manipulate and update the underlying DOM to achieve it.Essentially, this is a approach to adding business logic where you don’t have to touch the underlying code.
Business requirement: We need to display a list of new users who haven’t been verified yet, sorting them by sign-up time.
Problem: Firestore for documents that do not have a specific field, i.e. the field is undefined.
Solution: All user documents must have the verified field set. We can securely ensure all documents have this field with a cloud function that runs when they are created.
Generalising this solution, we can create cloud functions that ensure documents have the correct initial values set.Let’s create a new function that returns a cloud function triggered by an onCreate event. When the document is created, it updates the document with the specified initial values. This function doesn’t need to know what collection it will be listening to or what the initial values should be.import { firestore } from "firebase-functions";
const initializeFn = (config) =>
firestore.document(`${config.collection}/{docId}`)
.onCreate(async (snapshot) =>
snapshot.ref.update(config.initialValues)
);
Then we can store the configurations separately, where we can simply declare what documents should have what initial values. In this case, we want to ensure all documents in the users collection have the verified field default to false and a createdAt field to the server timestamp.
import * as admin from "firebase-admin";
const initializeConfigs = [
{
collection: "users",
initialValues: {
verified: false,
createdAt: admin.firestore.FieldValue.serverTimestamp(),
},
},
];
export const initialize = initializeConfig.reduce(
(acc: any, config) => ({
...acc,
[config.collection]: initializeFn(config),
}),
{}
);
firebase deploy --only functions:initialize
There’s less code to maintain. We can easily add functionality or fix bugs in the one place the code is written. 🐛 🔫Zero errors from copy-pasting code. No more forgotten unchanged variable names that crash your function. 🚧Testing can be streamlined. It only needs to be run on the generalised function, making it easy to improve code coverage.
And since we’ve separated out the business logic by making it declarative, we have:Improved readability of business logic. It’s significantly easier to read what business logic is implemented and what the expected result is.A much easier way to add new business logic. We could even build a GUI to create these cloud functions and have non-technical users add to this — all it needs to do is output a correct configuration object.
Slower deploys. Since the individual cloud functions are generated at deploy time, the Firebase CLI does not immediately recognise them, so the entire function group must be deployed every time.Higher costs. As the original “mega”-cloud functions were broken down into single-operation cloud functions, we incur more costs from increased compute time (as a result of more functions being booted) and from more invocations (if the project uses more than ).
Recently, I’ve used this pattern on many of our cloud functions in an open-source project, Firetable, a spreadsheet-like GUI for Firestore. It’s used to , , , and more.Declarative cloud functions such as these are easily adaptable for different use cases and scale across different collections.