DEV Community

Cover image for Building a Firebase CRUD Service for Angular
Colum Ferry
Colum Ferry

Posted on

Building a Firebase CRUD Service for Angular

Anyone who knows me, knows I 😍😍 Firebase. It could be considered unhealthy 😂. Despite my love for it, I have had my fair share of disagreements with it. The biggest one that comes to mind relates to Firestore.

NOTE: This article assumes you have a basic knowledge of how Firestore works. (docs)

This article will contain:

  • 🤔 The Problem - Something that annoyed me
  • 💪 My Solution - A brief overview
  • 🤩 LET'S BUILD! - Party time! 🎉🎉

🤔 The Problem

I tend to use Firestore as my go to NoSQL store. But when we pair it with AngularFire the examples shown are at times not perfect, especially when we try to adhere to the DRY Principle.

The examples all tend to start with your call to the collection method in your Component to ensure you are working with the correct collection in Firestore. But all these calls to collection add up. There must be a better way?

💪 My Solution

To me, there is! But, this is subjective. I create a Generic Firebase CRUD Service*, that accepts a Type to define the model that I want to store in my Collection on Firestore.

This is what we are going to build in this article!

* I call this a service, but it is unlike a standard Angular Service that can be injected into a constructor, rather it is simply an instantiated class.

🤩 LET'S BUILD!

Ok, before we begin, let me take a moment to state that when I do this in codebases I work on, I tend to use the Bridge Pattern, by setting up a base Implementation for the CRUD Service, then define a Concrete Implementation of this, specific to Firetore.
My Abstractions have reference to the base Implementation but use the Firestore Concrete Implementation.

If any of this seems confusing, I highly recommend you read the Bridge Pattern article linked!

We'll break this build down into a few steps:

  • Setup - Setting up the class!
  • Create - The code to add the Document (henceforth called the Entity)
  • Read - The code to read one or many Entities in the Collection
  • Update - The code to update the Entity
  • Delete - The code to delete the Entity
  • Let's use it!

Let's get started!

🔧 Setup

We will assume you have an existing Angular project with AngularFire installed that you can work in.

If not, follow the instructions from the AngularFire docs.

First, we need to setup the class that will hold our logic.

import { AngularFirestore, AngularFirestoreCollection } from '@angular/fire/firestore';

// We need a function that will turn our JS Objects into an Object
// that Firestore can work with
function firebaseSerialize<T>(object: T) {
    return JSON.parse(JSON.stringify(object));
}

// We need a base Entity interface that our models will extend
export interface Entity {
  id?: string; // Optional for new Entities
}

export class FirestoreCrudService<T extends Entity> {
    // Reference to the Collection in Firestore
    private collection: AngularFirestoreCollection<T>;

    /* We need to ask for the AngularFirestore Injectable
     * and a Collection Name to use in Firestore
     */
    constructor(private afs: AngularFirestore, collectionName: string) {
        // We then create the reference to this Collection
        this.collection = this.afs.collection(collectionName);
    }
}
Enter fullscreen mode Exit fullscreen mode

NOTE: If your collection does not exist on Firebase, don't worry, this will create it for you when you add your first Document to the Collection

Now that the setup is done, lets move on!

➕ Create - Time to Add

We now need to define our first method that will allow us to add Entities into our Collection.

/**
* We look for the Entity we want to add as well
* as an Optional Id, which will allow us to set
* the Entity into a specific Document in the Collection
*/
add(entity: T, id?: string): Promise<T> {
    // We want to create a Typed Return of the added Entity
    return new Promise<T>((resolve, reject) => {
        if(id) {
            // If there is an ID Provided, lets specifically set the Document
            this.collection
            .doc(id)
            .set(firebaseSerialize(entity))
            .then(ref => {
                resolve(entity);
            });
        } else {
            // If no ID is set, allow Firestore to Auto-Generate one
            this.collection.add(firebaseSerialize(entity)).then(ref => {
                // Let's make sure we return the newly added ID with Model
                const newentity = {
                    id: ref.id,
                    ...entity
                };
                resolve(newentity);
            })
        }
    })
}
Enter fullscreen mode Exit fullscreen mode

What's going on here? 🤔

We set up a reusable method that will allow us to Add an Entity to the pre-defined Collection. We want to ensure the returned Promise is of the correct Entity Type so that our app will not break.

There is a use-case to add the Entity to a specific ID for scenarios such as adding a User to a Users Collection where the ID of the User comes from an external system.

📚 Read - Let's get Entities

Reading from the Collection comes in two forms. Get one specific Entity, or all the Entities in the Collection. We will define both below.
They will open an Observable Stream which will allow our App to remain up to date with the Hosted Collection, wherein any change to the Hosted Collection will be piped down into your App via this Stream. (REAL-TIME BABY 🚀🚀)

// Our get method will fetch a single Entity by it's Document ID
get(id: string): Observable<T> {
    return this.collection
        .doc<T>(id)
        .snapshotChanges()
        .pipe(
            // We want to map the document into a Typed JS Object
            map(doc => {
                // Only if the entity exists should we build an object out of it
                if (doc.payload.exists) {
                    const data = doc.payload.data() as T;
                    const payloadId = doc.payload.id;
                    return { id: payloadId, ...data };
                }
            })
        );
}

// Our list method will get all the Entities in the Collection
list(): Observable<T[]> {
    return this.collection.snapshotChanges().pipe(
        // Again we want to build a Typed JS Object from the Document
        map(changes => {
            return changes.map(a => {
                const data = a.payload.doc.data() as T;
                data.id = a.payload.doc.id;
                return data;
            });
        })
    );
}
Enter fullscreen mode Exit fullscreen mode

I feel like the code above is pretty self-explanatory. We will discuss usage of these methods after we complete this class.

☁️ Update - We modified some data, let's save it

We also need the ability to modify existing Entities in our Collection, so this little method will handle that for us!

// Our Update method takes the full updated Entity
// Including it's ID property which it will use to find the
// Document. This is a Hard Update.
update(entity: T): Promise<T> {
    return new Promise<T>((resolve, reject) => {
        this.collection
            .doc<T>(entity.id as string)
            .set(firebaseSerialize(entity))
            .then(() => {
                resolve({
                    ...entity
                });
            });
    });
}
Enter fullscreen mode Exit fullscreen mode

Pretty straightfoward, right? One method left, then we will show the full class!

🗑️ Delete - We don't like this Entity, let's dump it!

Finally, our Delete method will remove the Entity at a specific ID:

delete(id: string): Promise<void> {
    return new Promise<void>((resolve, reject) => {
        this.collection
            .doc<T>(id)
            .delete()
            .then(() => {
                resolve();
            });
    });
}
Enter fullscreen mode Exit fullscreen mode

Ok, here is the completed class:

import { AngularFirestore, AngularFirestoreCollection } from '@angular/fire/firestore';
import { Observable } from 'rxjs';
import { take, map } from 'rxjs/operators';

// We need a function that will turn our JS Objects into an Object
// that Firestore can work with
function firebaseSerialize<T>(object: T) {
    return JSON.parse(JSON.stringify(object));
}

// We need a base Entity interface that our models will extend
export interface Entity {
  id?: string; // Optional for new entities
}

export class FirestoreCrudService<T extends Entity> {
    // Reference to the Collection in Firestore
    private collection: AngularFirestoreCollection<T>;

    /* We need to ask for the AngularFirestore Injectable
     * and a Collection Name to use in Firestore
     */
    constructor(private afs: AngularFirestore, collectionName: string) {
        // We then create the reference to this Collection
        this.collection = this.afs.collection(collectionName);
    }

    /**
     * We look for the Entity we want to add as well
     * as an Optional Id, which will allow us to set
     * the Entity into a specific Document in the Collection
     */
    add(entity: T, id?: string): Promise<T> {
        // We want to create a Typed Return of the added Entity
        return new Promise<T>((resolve, reject) => {
            if (id) {
                // If there is an ID Provided, lets specifically set the Document
                this.collection
                    .doc(id)
                    .set(firebaseSerialize(entity))
                    .then(ref => {
                        resolve(entity);
                    });
            } else {
                // If no ID is set, allow Firestore to Auto-Generate one
                this.collection.add(firebaseSerialize(entity)).then(ref => {
                    // Let's make sure we return the newly added ID with Model
                    const newentity = {
                        id: ref.id,
                        ...entity,
                    };
                    resolve(newentity);
                });
            }
        });
    }

    /**
     * Our get method will fetch a single Entity by it's Document ID
     */
    get(id: string): Observable<T> {
        return this.collection
            .doc<T>(id)
            .snapshotChanges()
            .pipe(
                // We want to map the document into a Typed JS Object
                map(doc => {
                    // Only if the entity exists should we build an object out of it
                    if (doc.payload.exists) {
                        const data = doc.payload.data() as T;
                        const payloadId = doc.payload.id;
                        return { id: payloadId, ...data };
                    }
                })
            );
    }

    /*
     * Our list method will get all the Entities in the Collection
     */
    list(): Observable<T[]> {
        return this.collection.snapshotChanges().pipe(
            // Again we want to build a Typed JS Object from the Document
            map(changes => {
                return changes.map(a => {
                    const data = a.payload.doc.data() as T;
                    data.id = a.payload.doc.id;
                    return data;
                });
            })
        );
    }

    /* Our Update method takes the full updated Entity
     * Including it's ID property which it will use to find the
     * Document. This is a Hard Update.
     */
    update(entity: T): Promise<T> {
        return new Promise<T>((resolve, reject) => {
            this.collection
                .doc<T>(entity.id as string)
                .set(firebaseSerialize(entity))
                .then(() => {
                    resolve({
                        ...entity,
                    });
                });
        });
    }

    delete(id: string): Promise<void> {
        return new Promise<void>((resolve, reject) => {
            this.collection
                .doc<T>(id)
                .delete()
                .then(() => {
                    resolve();
                });
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

That's it, thats our Generic Class!

🔥 Let's use it!

Ok, now that we have created our generic class, let's take the Traditional Todo List example and recreate it with our new class.

Let's start with our Todo Model:

export interface Todo extends Entity {
    todo: string;
    category: string;
}
Enter fullscreen mode Exit fullscreen mode

When we typically work with Entities in our code, we usually have a service that will handle specific logic relating to that Entity. We will also want this service to talk to our Firestore. We will use our newly created Crud Class for this.

So let's create a service:

@Injectable({
    providedIn: 'root'
})
export class TodoService {

    private crudService: FirestoreCrudService;

    // AngularFirestore should be found by Angular DI System
    constructor(private afs: AngularFirestore) {
        // Let's create our CrusService and use the a Collection with the name 'todos'
        this.crudService = new FirestoreCrudService<Todo>(afs, 'todos');
    }

    addTodo(todo: string, category: string) {
        return this.crudService.add({todo, category});
    }

    updateTodoCategory(todo: Todo, category: string) {
        return this.crudService.update({..todo, category});
    }

    deleteTodo(todo: Todo) {
        return this.crudService.delete(todo.id);
    }

    getAllTodos() {
        return this.crudService.list();
    }
}
Enter fullscreen mode Exit fullscreen mode

Hopefully, you can see from this service above how easy it will now be to create custom logic, but reuse one class to talk to our Firestore, for multiple different models!

Isn't that awesome! 🚀🚀🚀


Hopefully this has been educational in some form or other!

If you have any questions, feel free to ask below or reach out to me on Twitter: @FerryColum.

Top comments (9)

Collapse
 
briancodes profile image
Brian • Edited

I'm not sure this is using the Bridge Pattern (as I understand it) - the TodoService is tightly coupled to the FirestoreCrudService using a class reference not an interface, which couldn't really be substituted without changing the implementation of both

Collapse
 
tailorvj profile image
Tailor Vijay

I think the use of JSON.parse(JSON.stringify(object)); might be very slow.

Kind regards,
Tailor

Collapse
 
coly010 profile image
Colum Ferry

I've yet to see a performance hit with it in production environments running on older mobile devices :)

Collapse
 
tailorvj profile image
Tailor Vijay

When it comes to Firebase apps, this will probably slow down your data fetching by hundreds of milliseconds, which, in many cases, is a lot for synchronized experiences developed with this type of database.

Thread Thread
 
coly010 profile image
Colum Ferry

Perhaps it would be faster to spread the typed object into a new object:

{...entity}

Thread Thread
 
dylanwatsonsoftware profile image
Dylan Watson

What problem are you trying to solve with that? Why not just put the object directly into firebase?

Collapse
 
mytsu profile image
Jonathan Arantes

You can't access data.id if you're using a generic type, isn't better to use a abstract class?

Collapse
 
coly010 profile image
Colum Ferry

I missed a part of my total usage of this.

I create a base interface called Entity:

export interface Entity {
id: string;
}
Enter fullscreen mode Exit fullscreen mode

Then the Crud Type Generic should be

export class FirebaseCrudService<T extends Entity> {
...
}
Enter fullscreen mode Exit fullscreen mode

Then finally, all our models will extend the Entity interface

export interface Todo extends Entity {
...
}
Enter fullscreen mode Exit fullscreen mode
Collapse
 
dylanwatsonsoftware profile image
Dylan Watson • Edited

Thanks! This is a great idea!
Is there any reason why you manually create Promises though? I normally just return the promises returned by firebase's set/add/delete methods.