Applying factory method pattern: Vuejs with firebase and supabase use case

Hssan Bouzlima - Aug 26 '23 - - Dev Community

Imagine this scenario.
You are implementing a web application for several clients.
Each client wants to use a different backend service : firebase and supabase.
What would you do?

Implementing two versions of the same app, one to deal with firebase, the other with supabase?

What if a client changes his mind and wants to use another backend service: AWS amplify? Would you change the codebase one more time to fit this new requirement?
It does not make any sense.

Since this is an issue, our codebase should not rely on any specific backend service. Instead, the codebase must handle whatever backend service we need and, most importantly, be out of proportion to the number of backend services we may use.
That is to say, whenever we add or update a new backend service, it needs to be easy to integrate into our application with regard to time and complexity. This is achievable only when our implementation is based on abstraction rather than concretion.

Here is where design patterns and SOLID principles come in handy.

In this post, we will try to resolve this issue by applying the factory method pattern. It is not a unique solution, nor maybe a "must do" solution; it is rather an exploratory operation to discover its benefits in regard to this specific issue.


1-Factory Method pattern:

 
The factory method is a creational pattern, its primary task is to create objects with respect to a specific design. The method responsible for that is the "factory method", which belongs to a "factory class". Picking out this pattern is the best option when we don't know the object we want to instantiate until runtime.

For example, an object "Animal" won't be instantiated directly inside the client code instead, this will be delegated to the factory method :

createAnimal(){return new Animal()}

let's deep dive into this pattern. Below a UML class diagram for the factory method pattern:

UML class diagram

UML class diagram of factory method pattern

 

Creator: It is an abstract class representing a factory class signature.

Creator1: A concrete implementation of the factory class contains a concrete factory method.

Product: In the factory method pattern, the product is our object to be instantiated. It is an interface representing our object signature.

Product1: A concrete implementation of the object.

You may notice that we can have multiple factories and products.

Then, how could this design be applied to my web application?


2-Factory method pattern in typescript:

 
In brief, my app (find your coach) is made to help users connect with coaches and send them mentorship requests.

I want my application to handle several backend services. Until now, I needed Supabase and Firebase.

Backend service choice will be done inside the .env variable VITE_APP_DB. According to this value, one of the backend services will be instantiated.

These services contain data about coaches and requests. My main tasks will be getting a list of coach, getting a list of requests and adding a request.

Design our specific use case

Below is a UML class diagram of the factory method pattern applied to our case.

findcoach-uml-class

UML class diagram of factory method pattern: find your coach application

 

At this level, we transformed a general factory method pattern design into a more specific case: find your coach app

Creator --> dbCreator

Creator1 --> concreteDbCreator

Product --> IDatabase

Product1 --> FirebaseDb

Product2 --> SupabaseDb
 

Code implementation:

Let's take one more step. Now we are going to implement our design!

IDatabase:

Signature to the required backend operations.

import type { Request } from '@/types/Request'

export interface IDataBase {
  getRequests(email: string): Promise<any>
  addRequest(data: Request): Promise<any>
  getCoaches(): Promise<any>
}

Enter fullscreen mode Exit fullscreen mode

dbCreator:

Our factory class signature. A concrete creator should implement this class.

import type { IDataBase } from './IDataBase'
export abstract class DbCreator {
  //factory method
  public abstract createDb(dbType: string): IDataBase
}

Enter fullscreen mode Exit fullscreen mode

FirebaseDb: (full code:FirebaseDb)

import{Database,getDatabase,ref,equalTo,query,get,
orderByChild
,set} from 'firebase/database'
import type { IDataBase } from './IDataBase'
import type { Coach } from '@/types/Coach'
import type { Request } from '@/types/Request'
import { initializeApp } from 'firebase/app'
const firebaseConfig = {}
export class FirebaseDb implements IDataBase {
  private db: Database
  constructor() {
    initializeApp(firebaseConfig)
    this.db = getDatabase()
  }
  getRequests(email: string) {//}
  addRequest(data: Request) {
    return set(ref(this.db, 'requests/' + data.time), data)
  }
  getCoaches() {}
Enter fullscreen mode Exit fullscreen mode

SupabaseDb: (full code:SupabaseDb)

import { createClient } from '@supabase/supabase-js'
import type { IDataBase } from './IDataBase'
import type { Request } from '@/types/Request'
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
const supabaseKey = import.meta.env.VITE_SUPABASE_KEY
export class SupabaseDb implements IDataBase {
  private supaBase
  constructor() {
    this.supaBase = createClient(supabaseUrl, supabaseKey)
  }
  getRequests(email: string) {}
  addRequest(data: Request) {  }
  getCoaches() {   }
Enter fullscreen mode Exit fullscreen mode

Supabase and Firebase must implement IDatabase.

ConcreteDbCreator:

This is the core of the factory method pattern. Based on the dbType value, a particular backend service will be instantiated through if else statements.

import { DbCreator } from './DbCreator'
import { FirebaseDb } from './FirebaseDb'
import { SupabaseDb } from './SupabaseDb'

export class ConcreteDbCreator extends DbCreator {
  public createDb(dbType: string) {
    if (dbType === 'firebase') return new FirebaseDb()
    else return new SupabaseDb()
  }
}

Enter fullscreen mode Exit fullscreen mode

Client code (main.ts):

In this context, client code is where our object is instantiated.
As mentioned above, the factory method is called with the dbType param obtained from .env file.

//
const appDB = import.meta.env.VITE_APP_DB
const appDataBase = new ConcreteDbCreator().createDb(appDB)
//
Enter fullscreen mode Exit fullscreen mode

 


3-Inject a database instance into Vue:

So far, we have designed and implemented our pattern in typescript. The next step is to inject the database instance into vue to make it accessible across the application.

Vuejs comes up with a dependency injection mechanism to pass data from a parent component to a child: Provide/Inject.

We will use this mechanism to inject our database instance.
First provide()

app.provide('appDataBase', appDataBase)
Enter fullscreen mode Exit fullscreen mode

Then inject it wherever we want.

  const appDataBase: IDataBase = inject('appDataBase')!
Enter fullscreen mode Exit fullscreen mode

 

Dependency injection with Pinia

In find your coach app, I use Pinia for state management. Unfortunately, the Provide/Inject mechanism is not accepted in pinia store. Therefore, I need a different way to inject my database instance.

Yet, Pinia provides another way to use external properties by adding an attribute to pinia store object containing the required properties. This object must be wrapped inside markRaw().

pinia.use(({ store }) => {
  store.appDataBase = markRaw(appDataBase)
})
Enter fullscreen mode Exit fullscreen mode

Then our database instance will be available through this inside the store.

import { defineStore } from 'pinia'
import type { Coach } from '@/types/Coach'

export const useCoachesStore = defineStore('coaches', {
  state: () => ({
    coaches: [] as Coach[],
  })
  actions: {
    fetchCoaches() {
      this.appDataBase
        .getCoaches()
        .then((data: Coach[]) => {
          data.forEach((coach: Coach) => {
            this.coaches.push(coach)
          })
        })
    },
  },
})

Enter fullscreen mode Exit fullscreen mode

4-Notes:

You may wonder, why we don't use simply an interface and several services implementations, and instantiate the service we need since the database service will remain the same at runtime.

Applying the factory method pattern has mainly two benefits:

-Separation of concern: Choosing adequate service is the factory method job. The client code will remain unchangeable, whatever service we decide to use.

-Limit code modification and mistakes: Adding a new service requires only changing the .env variable, adding a concrete class and adding a new conditional statement. It is a standardized task.


5-Conclusion:

Throughout this post, we successfully implemented the factory method pattern to meet our application need for flexibility in choosing backend service.

This is done through the .env variable which specifies the backend type, and the factory method.

Now, we have the freedom to choose between firebase and supabase by just changing one variable!

Anytime we want to use another alternative, we have to follow these steps :

  • Create a concrete class of specified service. This concrete class must implement IDatabase interface

  • Add one more conditional statement in the factory method to instantiate this service according to the dbType param.

  • On any occasion we want to use this service, we just need to update the .env variable: VITE_APP_DB.

THANK you for taking the time to read this article.

Full project code

. . . . . . . . . . . .