Supercharge your backend development — Dynamic routing with OOP and Schema Plugins using Mongoose, TypeScript and Express
As a programmer, I love the concepts of OOP (Object-Oriented Programming) for one big reason: Inheritance. The ability to relate logical components, share attributes and functions — it’s like getting free code!
But then I wondered, wouldn’t it be even nicer to have this concept in a database schema? This question arose when I was working on a moderately-sized backend project powered by MongoDB (with Mongoose), Express, Node.js, and TypeScript.
The use case:
Consider a MongoDB database where multiple collections have these three fields in common — uid
, tags
and properties
. The model and schema look like this:
// product.ts
export interface ProductModel extends Document {
uid: string
tags: string[]
properties: Map<string, string>
}
const ProductSchema: Schema<ProductModel> = new Schema<ProductModel>({
uid: {
type: String,
required: true
},
tags: {
type: [String],
required: false,
default: [],
},
properties: {
type: Map,
of: Schema.Types.String,
required: false,
default: {}
}
})
Now, I want the other schemas to have the same three fields — uid
, tags
and properties
. Plus, they can have their own distinct fields as well. Fair enough, we can just make all the models extend one BaseModel
and they will share these features.
But what about the schemas — will I have to write them separately for all models? The answer is both yes and no. If you stick to a concrete schema for the model, you may have to do the Ctrl+C Ctrl+V
. But if we just create a Plugin as per our BaseModel
, we can add it to any schema and get the same result. Interesting, right? Let’s see how this looks in code.
// base.ts
export interface BaseModel extends Document {
uid: string
tags: string[]
properties: Map<string, string>
}
export const BasePlugin = (schema: Schema<any>): void => {
schema.add({
uid: {
type: String,
required: true
},
tags: {
type: [String],
required: false,
default: [],
},
properties: {
type: Map,
of: Schema.Types.String,
required: false,
default: {}
}
})
}
Here, we do not create any Schema for the BaseModel
. Instead, we create a Plugin. This is what the modified version of our product.ts
file looks like using the BasePlugin
we just created.
// product.ts
const modelName = 'product'
export interface ProductModel extends BaseModel {}
const ProductSchema: Schema<ProductModel> = new Schema<ProductModel>()
ProductSchema.plugin(BasePlugin)
export const Product: Model<ProductModel> = model(modelName, ProductSchema)
And that’s it! The Product model now includes all those fields. Other models can follow the same approach and achieve the same results.
Let’s see another case. The User
model wants the same three properties — but wants to add some of its own. This is how we handle that case.
// user.ts
const modelName: string = 'user'
export interface UserModel extends BaseModel {
phone: string | null
email: string | null
}
const UserSchema: Schema<UserModel> = new Schema<UserModel>({
phone: {
type: String,
required: false,
default: null
},
email: {
type: String,
required: false,
default: null
}
})
UserSchema.plugin(BasePlugin)
export const User: Model<UserModel> = model(modelName, UserSchema)
Interesting, isn’t it? I was happy too, until I asked myself another question: If we can prevent rewriting models and schemas, can we do the same for API endpoints as well?
Let’s ponder over these points:
- If the properties of models are the same, the logic for all associated CRUD operations will be the same.
- In case some model needs to be handled differently, can we override the base logic?
- If we need additional API endpoints for a model, can we add to it?
I did some research followed by some trial-error, and finalised this pattern. Each collection and its features will have three files:
- A model file that contains the model and schema declaration, say
user.ts
. - A helper class that stores all the CRUD functions along with overrides and additional functions for the model.
- A routes file that holds the routes associated to the model.
This is how the contents of base
directory look, having base.ts
, baseHelper.ts
and baseRoutes.ts
files.
// base.ts (same as above)
export interface BaseModel extends Document {
uid: string
tags: string[]
properties: Map<string, string>
}
export const BasePlugin = (schema: Schema<any>): void => {
schema.add({
uid: {
type: String,
required: true
},
tags: {
type: [String],
required: false,
default: [],
},
properties: {
type: Map,
of: Schema.Types.String,
required: false,
default: {}
}
})
}
// baseHelper.ts
export default class BaseHelper<T extends BaseModel> {
// the actual model we will be dealing with
protected model: Model<T>
constructor(model: Model<T>) {
this.model = model
}
// gets all documents
async getAll(): Promise<BaseModel[]> {
return this.model.find()
}
// gets a document by uid
async getByUid(uid: string): Promise<T | null> {
return this.model.findOne({ uid: uid })
}
// creates a new document
async create(data: any): Promise<T> {
data = this.sanitiseData(data)
const doc = new this.model({
uid: uid,
...data
})
return doc.save()
}
// updates a document
async update(uid: string, data: any): Promise<T | null> {
data = this.sanitiseData(data)
const existing = await this.getByUid(uid)
if (existing == null) {
return null
}
return this.model.findOneAndUpdate({ uid: uid}, data, { new: true })
}
// deletes a document
async delete(uid: string): Promise<T | null> {
return this.model.findOneAndDelete({ uid: uid })
}
// a utility function that avoids updating fields
// which shouldn't be updated in any case
private sanitiseData(data: any): any {
delete data['_id']
delete data['id']
delete data['uid']
return data
}
// a static function that helps to output only
// the desired fields of a model as response
static toJson(model: BaseModel): any {
return {
uid: model.uid,
tags: model.tags,
properties: model.properties
}
}
// list version of the [toJson] function
static toJsonList(models: BaseModel[]): any {
return models.map((model: BaseModel) => this.toJson(model))
}
}
// baseRoutes.ts
export default class BaseRoutes {
protected router: express.Router
protected helper: BaseHelper<BaseModel>
// gets the router and helper as parameters
constructor(router: express.Router, helper: BaseHelper<BaseModel>) {
this.router = router
this.helper = helper
}
// generates routes for the [router] associated to the model
generateRoutes() {
this.router.post('/', this.createRoute)
this.router.get('/', this.getAllRoute)
this.router.get('/:uid', this.getRoute)
this.router.put('/:uid', this.updateRoute)
this.router.delete('/:uid', this.deleteRoute)
}
private createRoute = async (req: Request, res: Response): Promise<void> => {
const uid = req.body.uid
const data: any = {
tags: req.body.tags,
properties: req.body.properties
}
try {
const model = await this.helper.create(uid, data)
const response: ApiResponse = ApiResponse.success(BaseHelper.toJson(model), 'Created')
res.status(201).json(response)
} catch (error: unknown) {
const response: ApiResponse = ApiResponse.error((error as Error).message)
res.status(500).json(response)
}
}
private getAllRoute = async (req: Request, res: Response): Promise<void> => {
try {
const models = await this.helper.getAll()
const response: ApiResponse = ApiResponse.success(BaseHelper.toJsonList(models), 'Fetched')
res.status(200).json(response)
} catch (error: unknown) {
const response: ApiResponse = ApiResponse.error((error as Error).message)
res.status(500).json(response)
}
}
private getRoute = async (req: Request, res: Response): Promise<void> => {
const uid = req.params.uid
try {
const model = await this.helper.getByUid(uid)
if (model == null) {
const response: ApiResponse = ApiResponse.error(`Not found`)
res.status(404).json(response)
return
}
const response: ApiResponse = ApiResponse.success(BaseHelper.toJson(model), 'Fetched')
res.status(200).json(response)
} catch (error: unknown) {
const response: ApiResponse = ApiResponse.error((error as Error).message)
res.status(500).json(response)
}
}
private updateRoute = async (req: Request, res: Response): Promise<void> => {
const uid = req.params.uid
try {
const model = await this.helper.update(uid, req.body)
if (model == null) {
const response: ApiResponse = ApiResponse.error(`Not found`)
res.status(404).json(response)
return
}
const response: ApiResponse = ApiResponse.success(BaseHelper.toJson(model), 'Updated')
res.status(200).json(response)
} catch (error: unknown) {
const response: ApiResponse = ApiResponse.error((error as Error).message)
res.status(500).json(response)
}
}
private deleteRoute = async (req: Request, res: Response): Promise<void> => {
const uid = req.params.uid
try {
const model = await this.helper.delete(req.params.uid)
if (model == null) {
const response: ApiResponse = ApiResponse.error(`Not found`)
res.status(404).json(response)
return
}
const response: ApiResponse = ApiResponse.success(BaseHelper.toJson(model), 'Deleted')
res.status(200).json(response)
} catch (error: unknown) {
const response: ApiResponse = ApiResponse.error((error as Error).message)
res.status(500).json(response)
}
}
}
Fine. Now this is all the code in product
directory:
// product.ts (same as above)
const modelName = 'product'
export interface ProductModel extends BaseModel {}
const ProductSchema: Schema<ProductModel> = new Schema<ProductModel>()
ProductSchema.plugin(BasePlugin)
export const Product: Model<ProductModel> = model(modelName, ProductSchema)
export default class ProductHelper extends BaseHelper<ProductModel> {}
const router = express.Router()
const helper = new ProductHelper(Product)
new BaseRoutes(router, helper).generateRoutes()
module.exports = router
See that? We shredded so many lines of code and repetitions by introducing OOP to our project. Other models can do the same and avoid all the Ctrl+C Ctrl+V
. Now let’s see some cases where we need to add or override functions.
Case 1 — Additional routes and fields
The User
collection needs a route /:uid/identify
where it could perform create or update at once. Also, the User
model has additional fields phone
and email
. This is how we will solve for it.
// user.ts (same as above)
const modelName: string = 'user'
export interface UserModel extends BaseModel {
phone: string | null
email: string | null
}
const UserSchema: Schema<UserModel> = new Schema<UserModel>({
phone: {
type: String,
required: false,
default: null
},
email: {
type: String,
required: false,
default: null
}
})
UserSchema.plugin(BasePlugin)
export const User: Model<UserModel> = model(modelName, UserSchema)
// userHelper.ts
export default class UserHelper extends BaseHelper<UserModel> {
// we override the [toJson] function to include
// the [phone] and [email] fields
static override toJson(model: UserModel): any {
const json = super.toJson(model)
json.phone = model.phone
json.email = model.email
return json
}
}
// userRoutes.ts
const router = express.Router()
const helper = new UserHelper(User)
//@ts-ignore
new BaseRoutes(router, helper).generateRoutes()
router.post('/:uid/identify', async (req: Request, res: Response): Promise<void> => {
try {
const uid: string = req.params.uid
const phone: string | null = req.body.phone
const email: string | null = req.body.email
const tags: string[] = req.body.tags ?? []
const properties: Map<string, string> = req.body.properties ?? {}
const data = {
phone: phone,
email: email,
tags: tags,
properties: properties
}
let user: UserModel | null = await helper.update(uid, data)
if (user == null) {
user = await helper.create(uid, data)
}
const response: ApiResponse = ApiResponse.success(UserHelper.toJson(user))
res.status(200).json(response)
} catch (error: unknown) {
const response: ApiResponse = ApiResponse.error((error as Error).message)
res.status(500).json(response)
}
})
module.exports = router
I had to put a @ts-ignore
here because TypeScript was giving me a hard time over UserHelper
and BaseHelper
types. I don’t like it but I believe this is the price we pay for salvation.
And done! We made our approach compatible for additional routes and fields.
Case 2— Overriding functions
Let’s see a case where we have a UserCategory
model as well, which has the same common fields as other models, but needs to do something differently. For instance — the UserCategoryHelper
needs to override its delete()
function as it has to delete some mapping data as well.
This is how we will do it. The userCategory.ts
and userCategoryRoutes.ts
files have been configured in the same way as others, we just need to make a change in the userCategoryHelper.ts
file.
export default class UserCategoryHelper extends BaseHelper<UserCategoryModel | null> {
override async delete(uid: string): Promise<UserCategoryModel> {
const userCategory = await super.delete(uid)
if (userCategory != null) {
// assume a 'UserToUserCategoryMap' collection
// with fields [userCategoryId] and [userId]
await UserToUserCategoryMap.deleteMany({userCategoryId: userCategory.id})
return userCategory
}
return null
}
}
And voila! We have achieved absolute success.
Other questions that might arise
The amount of code on this blog might be overwhelming, so here’s some FAQ:
Why didn’t we put the router declaration in the helper files?
I strongly believe that the routers belong to the route files and not the helpers, so kept it separately. You may do otherwise.
What about indexing?
You can put the indexing code either in the base.ts
file or separately in model files. I like to do the latter for flexibility. Here’s an example:
// user.ts
UserSchema.index({ uid: 1 }, { unique: true })
What is the ApiResponse object in every route?
It is just a wrapper for JSON output. I don’t want to spill all the boilerplate here in this blog so didn’t include it. You can find it here.
How do we incorporate middlewares?
I have the solution for that — with an interesting twist. I’ll share about that in a subsequent blog.
Conclusion
I am absolutely thrilled while writing this blog. I see this as an achievement, considering it has only been one month since I started working with TypeScript and Mongoose.
Please feel free to point out any errors in the code and ask questions. I believe there is still a lot of room for improvement. Let’s meet in another blog. Happy coding!