Serverless Email Notifications with Google Cloud Functions and ClojureScript

By Nizar

Nizar

At Multis, we use Google Cloud Firestore as our main database, this allows us to distribute multis as a SPA without having to write or deploy any backend code.

However, in order to send email notifications in response to user interactions (guest invitations, transaction requests, etc.), we needed a way to be able to run backend code, but we wanted it to be decoupled from the rest in order to keep distributing the app as a static website, without having to manage a backend server.

Enter Cloud Functions

Cloud functions allow you to run code in response to events. Your code runs in a managed Google Cloud environment and events can come from a variety of Firebase and Google Cloud services, including Firestore.

How does it work?

In order to take advantage of cloud functions, you write your function as normal JavaScript code (or technically any language that transpiles back to JavaScript, in our case it was ClojureScript).

The only difference is that you now have access to one of the Google/Firebase event providers, which help you define the conditions under which your function should execute.

For Firestore, triggers are any changes to your database, so you can listen for document writes, updates, deletions, or creation, and execute code accordingly.

Step 2: Testing

Firebase provides a local emulator that lets you do pretty much all sorts of testing, but it’s not the main topic of this article. You can learn more about the emulator here.

Step 3: Deploy & Monitor

Firebase provides a CLI that allows you to deploy or delete one or more functions in a single command. Once deployed, your function will wait for event triggers and execute any code that matches.

Google will manage your function so you don’t have to worry about scaling, if your function is idle, instances are freed, under huge loads, Google creates more instances to handle the work.

Sending emails

Our solution has been to build simple functions that would react to database changes. This meant writing and monitoring multiple functions, one function per use case, each function would wait for a database triggers and react accordingly in order to send emails.

For example, we had a function tracking Ethereum transactions and sending confirmation requests to wallet owners by email, another one tracking guest additions to a company account to send email invitations, and many more.

The handle-update method is then responsible for implementing the business logic required to know what to do with a given change, and finally, send the email if a route is found in the business logic tree.

Here’s an example of how an implementation for handling transaction updates might look:

Limitations of this approach

When your function is listening for a specific event trigger, it gets called with a snapshot of the new and old data as input, your function is then responsible for making sense of what changed and what actions need to be done.

This has many drawbacks

  1. Complexity: making decisions based on data changes is hard to reason about. Your function is now overloaded with lots of business logic.
  2. Reliability: directly listening for db changes to make decisions on what action to execute makes reliability harder to achieve. What if you missed a certain case? Intensive QA testing is needed and can never be enough.
  3. Scalability: handling new use cases is complex and requires implementing, deploying and monitoring a new cloud function, even for a simple use case. Probably making you even duplicate your frontend business logic inside of your cloud function.

One function to rule them all

After evaluating different possible solutions, we ended up implementing an actually rather simple one.

We now have one cloud function responsible for handling all email requests, and its role is even smaller: it now only acts as a dispatcher.

The rest of the architecture is as follows:

  • only one firestore collection is now being used to queue emails to be sent, apps that want to send emails simply write to this collection.
  • a simple base format has been defined to represent all emails.
  • each email has an action field which tells exactly how it needs to be dispatched.

Why is this cool?

We can now implement any new use case by simply implementing the necessary action, without even needing to deploy a new cloud function.

Polymorphism is implemented using ClojureScript multimethods, which we will discuss later on in this article.

A note on security

Unlike classical APIs where database access and security is managed by a backend server, firebase offers a path-matching configuration style that sits outside of your app and allows you to define your security rules. Along with conditional statements, one can define complex rules, with refined granularity levels to manage who can write, read or update any piece of information.

service <<name>> {// Match the resource path. match <<path>> {  // Allow the request if the following conditions are true.  allow <<methods>> : if <<condition>> }}

This was essential to prevent any malicious users from writing to the emails queue or to corrupt existing data.

An example Firebase rule for securing a resource

Show me the code

So now that we’re done with the theory, let’s dive in into some code to show you how it’s done.

Assuming we have an email collection already created in firestore, we will be listening for changes to that collection in order to process emails, more specifically, we will be watching firestore onCreated triggers; we’re only interested in processing newly created requests, handling failures is not covered in this article.

Main function for listening to newly created documents in the queue

Remember, our cloud function now only dispatches email data, the logic of how to handle each case is defined in a polymorphic way using Clojure multimethods.

Dispatching emails

Our dispatcher uses simple Clojure multimethods to implement polymorphism. With multimethods, we can associate our dispatch function with multiple implementations, the method to be called will depend on the dispatched value.

This means that dispatching depends on the value of :action inside of our email data, so we can implement new functionality by simply defining new actions, like in the example below, for handling guest invitations or pending confirmations.

JavaScript transpilation

Now that we have an idea about how to define a cloud function in ClojureScript, let’s make it ready for deployment.

We start by defining an export value, which is simply a Clojure map of all the functions we’d like to export so that we could use them from JavaScript.

After compiling our ClojureScript (more on that shortly), we can require it in our main JavaScript file like so:

Deploying cloud functions

After testing using the emulator, we can deploy the function to Google Cloud in a single command.

And that’s it. Your function can now be monitored in the Google Cloud console.

A word on shadow-cljs

We can’t end this article without talking about shadow-cljs, our ClojureScript build tool.

shadow-cljs allows you to define multiple build targets in the same project. For example, you can have a build for your main frontend app and another one dedicated to building your cloud functions, which is how we do it here at multis:

Our exports definition will be compiled by shadow-cljs and output in functions/index-cljs.js, the multis.index/exports map keys are your function names, so you can require them from the main JavaScript file like we saw earlier.

shadow-cljs goodies

shadow-cljs comes with plenty of build commands, one of them is watch, which monitors your source files for changes and re-compiles your project accordingly.

This is great for when you’re in development mode, you can open your firebase emulator, change your code and see it re-loaded automatically, so you can keep testing without restarting the firebase shell.

Combined with REPL-driven development, it makes for incredibly fast feedback cycles that can improve your developer experience.

Conclusion

In this article, we tried to show you how serverless architectures are turning out to be convenient, we demonstrated how you can write Google Cloud Functions in ClojureScript, and showed that a simpler architecture turned out to be more robust, easy to scale, and more importantly, it’s now allowing us to move faster.

Our golden rule at Multis: Keep It Simple.