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:
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.
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>
}
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
}
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() {}
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() { }
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()
}
}
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)
//
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)
Then inject it wherever we want.
const appDataBase: IDataBase = inject('appDataBase')!
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)
})
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)
})
})
},
},
})
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.
Top comments (0)