This is how we write Firebase Cloud Functions


What are cloud functions

In the context of firebase, Cloud Functions is a serverless framework that will execute the deployed pieces of code in response to different events triggered by Firebase or external events like HTTPS requests.

You can use this link to learn more about cloud functions like how to add it to an existing Firebase project, how to trigger a cloud function and how to deploy etc.


Purpose of this Article

The purpose of this article would be to give you an idea about a proper architecture that you would need to follow in order to make the code more readable and manageable. Node-based backend architectures are very rare to find on the internet, so I would think this might be helpful for Node backend developers as well.

In this article, I would like to give my examples in typescript. But the same concepts apply for javascript as well. I think it is better if you can focus on the concepts rather than the programming language in this article.

I will not cover writing test cases for the cloud functions in this article. I will talk about the basic folder structure but an in-depth article on writing test cases is a topic for another article.


The problem we try to solve

We have 2 Firestore collections named Children (Child class) and Ages (Age class). Notice that we have given plural names as the collection names and singular names for their corresponding classes.

Two classes that will be implemented

Using this tutorial we will try to solve this small problem. A child can create a document in the Children collection. Then a cloud function will trigger automatically and calculate the age and write a document in the Ages collection.


Basic folder structure

Root folder structure

You can refer to the documentation that I mentioned above to learn how to create a cloud function project. Make sure to select the language as typescript and to turn on lint. You will find only the src folder and node_modules folder in the beginning. The lib folder will be created automatically after you compile the typescript code. You have to create the test folder when you are writing test cases.

For javascript users, there will be no folders except for node_modules. Also, there will be a index.js file. I recommend creating a lib folder and moving the index.js file inside that folder. That is a better way to separate test code from actual implementation. After you have done that you might need to add the following key-value pair into the main JSON of the package.json file.

1
"main": "lib/index.js",
Folder structure inside the src folder

The above image shows the basic folder structure of the project which is inside the src folder.

Folder Description
dao Fetching, writing to the Firestore happens through the files in here
interfaces The main interfaces, abstract classes that will be using in the project
mapper Convert Firestore data models to our internal models and vice versa
models Internal data models (entities)
services Main business logic
util Utility related code

Interfaces

I have put commonly used interfaces and abstract classes here.


Handler Interface

This interface is used to implement the functions that will run on cloud function triggers. In our case, our cloud function should trigger when a new document is added to the Firestore. The code that should run after that trigger is implemented using this interface.

1
2
3
4
5
6
// abstractHandler.ts
export abstract class AbstractHandler {
async onCreate(snap: DocumentSnapshot, context: EventContext): Promise<any> {
throw new Error("onCreate not implemented yet!!");
}
}

The above code is the function prototype for onCreate trigger. If you have more types of triggers that are addressed in your code, you can declare them all here. I have added some more functions to the Github repository of this project.


Database Model

Every model is created by extending this abstract class. This includes some basic information that is needed by every model in the system.

1
2
3
4
5
6
7
8
9
10
// dBModel.ts
export abstract class DBModel {
ref: DocumentReference | undefined;
id: string | undefined;

protected constructor(ref?: DocumentReference) {
this.ref = ref;
this.id = ref?.id;
}
}

More information about this will be provided in the Models section. I will explain the use of these variables there.


Mapper Interface

This interface is used to convert Firestore data structures to internal models and vice versa.

1
2
3
4
5
// mapper.ts
export interface IMapper<T extends DBModel> {
fromSnapshot(snapshot: DocumentSnapshot) : T | undefined;
toMap(item: T): DocumentData;
}

What is missing

It is better if you can have an interface for the data access layer as well. Then there will be an abstraction in there as well. The data access layer will implement this interface for each model that needs to be saved in Firestore. But for this case, I’m going to ignore that abstraction.



Models

Instances of classes in this folder are used to represent firebase documents. Basically, these classes should not have any dependency. But in my implementation, those classes depend on some Firestore DocumentReference classes. This is because it helps when converting these models back to the data structures that can be stored in Firestore. It is like keys(ids) in MySQL. But if you don’t like that dependency, you can store the path of the document instead. You can implement this behavior by changing the ref field in the DBModel and the mapper implementation that is related to this model.

As I mentioned above ref and id fields are used when converting the models to Firestore data structures. The reference of the document that is storing the data of this model is in the ref field. The id field is useful when creating the document. By default, the id field is equal to the documentId of the ref if ref is not null. Making use of this id field will be discussed in the DAO section.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// age.ts
export class Age extends DBModel {
static readonly CHILD_FIELD = 'child';
static readonly YEARS_FIELD = 'years';
static readonly MONTHS_FIELD = 'months';
static readonly DAYS_FIELD = 'days';

child: DocumentReference | undefined;
years: number | undefined;
months: number | undefined;
days: number | undefined;

constructor(ref?: DocumentReference, child?: DocumentReference, years?: number, months?: number, days?: number) {
super(ref);
this.child = child;
this.years = years;
this.months = months;
this.days = days;
}
}

// child.ts
export class Child extends DBModel {
static readonly NAME_FIELD = 'name';
static readonly DOB_FIELD = 'dob';

name: string | undefined;
dob: Date | undefined;

constructor(ref?: DocumentReference, name?: string, dob?: Date) {
super(ref);
this.name = name;
this.dob = dob;
}
}

You will notice that I have declared static variables inside each model. Those variables are used to get data from the Firestore snapshot. As it returns a plain javascript object you can consider it as a set of key-value pairs. Instead of using the string literal to access data, you can use the variables declared here to access the data.

You can see the use of these variables in the mapper classes. You might see that declaring these variables here will add another level of dependency for Firestore in these model classes. You can avoid that by declaring these variables in the specific mappers. But I prefer to do them here.


Mappers

As I said above, the functionality of mappers is to convert Firestore data structures to internal models when reading data and do the vice versa when writing the data. For the two models we have, we should have two corresponding mappers.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// ageMapper.ts
export class AgeMapper implements IMapper<Age> {
fromSnapshot(snapshot: FirebaseFirestore.DocumentSnapshot): Age | undefined {
if (snapshot === null || snapshot === undefined) return undefined;
const data = snapshot.data();
if (data === null || data === undefined) return undefined;

return new Age(snapshot.ref, data[Age.CHILD_FIELD],
data[Age.YEARS_FIELD], data[Age.MONTHS_FIELD], data[Age.DAYS_FIELD]);
}

toMap(item: Age): FirebaseFirestore.DocumentData {
return {
[Age.CHILD_FIELD]: item.child,
[Age.YEARS_FIELD]: item.years,
[Age.MONTHS_FIELD]: item.months,
[Age.DAYS_FIELD]: item.days,
};
}

}

// childMapper.ts
export class ChildMapper implements IMapper<Child> {
fromSnapshot(snapshot: DocumentSnapshot): Child | undefined {
if (snapshot === null || snapshot === undefined) return undefined;
const data = snapshot.data();
if (data === null || data === undefined) return undefined;

return new Child(snapshot.ref, data[Child.NAME_FIELD], data[Child.DOB_FIELD]?.toDate());
}

toMap(item: Child): DocumentData {
return {
[Child.NAME_FIELD]: item.name,
[Child.DOB_FIELD]: item.dob ? Timestamp.fromDate(item.dob) : null,
};
}

}

When accessing the Firestore data structures, notice that I have used the static variables that were declared in the model classes instead of using string literals. This way you can minimize the chance of making a mistake.


DB Utility

The util folder is used to store utility data of the system. Currently, there is only a single file there named dBUtil.ts. That file is used to store the names of the Firestore collections that are used by this system.

1
2
3
4
5
// dBUtil.ts
export abstract class DBUtil {
static readonly AGE = 'Ages';
static readonly CHILD = 'Children';
}

Notice the naming convention that we used in Firestore collections. A collection name is always plural and written in camel case. But when accessing the collection, we use the singular term of the collection’s name as the variable to store the name of the collection.


Data Access Objects (DAO)

These classes are used to fetch or store data from/to Firestore. There are two things that these classes are required to do.

  1. Access Firestore
  2. Map the Firestore data and internal models as required.

In thiscase, we only need a DAO to store documents in the Ages collection.

1
2
3
4
5
6
7
8
9
10
11
12
// ageDAO.ts
export class AgeDAO {
firestore = admin.firestore();
mapper = new AgeMapper();

async add(age: Age) {
const collectionRef = this.firestore.collection(DBUtil.AGE);
const docRef = (age.id === null || age.id === undefined) ? collectionRef.doc() : collectionRef.doc(age.id);
age.ref = docRef;
await docRef.set(this.mapper.toMap(age));
}
}

A model that is required to store in Firestore does not have a document reference. Hence the ref field is null. But the documentId of the newly created document is determined by the id field of the model. If the id is null, the document ID will be generated randomly. But if a string was given as the id, it will be used to create the document and it will be the document’s ID.

After the document was created, the ref field of the model has to be updated by the DAO. This will make sure that this newly created document is referenced by the model after the creation of the document and in other places in the project you will need not to be worried about it. If you need to update some things in the same model and update the document later in the code, you have nothing to worry about creating duplicate documents in the database.

I will add some more functionality to the DAO like update document, in the github repository of this project.

In a separate article I will talk about the Repository design pattern that can be used instead of DAO to avoid the mess inside the DAO (Most probably bloaters in the code). Also it will add another level of abstraction to the code.


Services

This is the core of the application. All the business logic happens inside this package. This package is broken down into sub packages, one sub package per one cloud function. Each subpackage has a Handler file to catch the cloud function trigger. The subpackage also includes a set of Service files. You can create multiple service files to implement different logics that need be done in the cloud function. Handler will call them when needed.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// createAgeHandler.ts
export class CreateAgeHandler extends AbstractHandler {
async onCreate(snap: FirebaseFirestore.DocumentSnapshot, context: EventContext): Promise<any> {
const childMapper = new ChildMapper();
const child = childMapper.fromSnapshot(snap);
if (child === null || child === undefined) {
console.log("Console log");
return;
}
await new CreateAgeService().processChild(child);
console.log("All Done!");
}
}

// createAgeService.ts
export class CreateAgeService {
private ageDAO = new AgeDAO();

childToAge(child: Child): Age {
const now = moment();
const duration = moment.duration(now.diff(child.dob));
return new Age(undefined, child.ref, duration.years(), duration.months(), duration.days());
}

async processChild(child: Child): Promise<any> {
const age = this.childToAge(child);
await this.ageDAO.add(age);
}
}

The functionality of a handler is to,

  1. Validate input data if required
  2. Call the required service methods

The services are the brain of the application. Processing, doing logical operations and saving data if needed is done through services. It is better to use different classes for each functionality although I did data processing and updating Firestore in the same class.

If you are writing interfaces to abstract the functionality of these service classes and DAO, you might need to inject the implementation of those interfaces to handlers and services. You can use the service locator pattern or inject the dependencies as parameters in the constructor. Personally I prefer to inject them in the constructor.

There might be cases where you will need the same functionality of the services across different cloud functions. As we are writing the packages for different cloud functions separately, it might feel a little weird to share the services across the packages. If you need that level of separation between packages, you can duplicate the code in those packages. But I prefer to call the appropriate function in the other package to do the thing needed instead of duplicating the code. Duplicating code is a very bad smell to have in a project. If you have an interface to abstract the service classes, make sure to add the shared functions to the interface class.


Connecting

Finally, you have to connect the handler to the index file. The appropriate function of the handler has to be executed via this index file.

1
2
3
4
5
// index.ts
if (admin.apps.length === 0) admin.initializeApp();

export const createAge = functions.firestore.document(`${DBUtil.CHILD}/{childId}`)
.onCreate(new CreateAgeHandler().onCreate);

This concludes our way of writing Firebase cloud functions. You can access the codebase of this small project in my Github. Clone it and play with it.

See you in another article. Bye!

Typescript Cloud Functions - Firebase

Typescript Cloud Functions - Firebase

This repository is an example project that do a simple cloud function trigger which was written to explain a proper way to write cloud functions.

github.com