Node / Express Guide
Models
Interface + class model patterns for MongoDB documents.
Models define the shape of MongoDB documents. There is no Mongoose — just TypeScript interfaces and plain classes.
Pattern: Interface + Class
Each model file exports:
- An interface (
Iprefix) — the full document shape with mostly optional fields (as MongoDB may return partial projections). - A type alias for create inputs (
Tprefix +Createsuffix) usingPick. - A class implementing the interface — the constructor enforces defaults, timestamps, and derived fields.
// src/models/user.model.ts
import { ObjectId } from 'mongodb'
import { UserType, UserStatus } from '../enums/user.enum'
import type { TUserType, TUserStatus } from '../types/common.types'
export interface IUser {
_id?: ObjectId
type?: TUserType
organizationId?: ObjectId | string
firstName: string
lastName: string
email: string
phone: string
password?: string
refreshToken?: string | null
status?: TUserStatus
createdAt?: Date
updatedAt?: Date
deletedAt?: Date | null
}
export type TUserCreate = Pick<
IUser,
'type' | 'organizationId' | 'firstName' | 'lastName' | 'email' | 'phone'
> & { password: string }
export class User implements IUser {
_id?: ObjectId
type: TUserType
organizationId?: ObjectId
firstName: string
lastName: string
email: string
phone: string
password?: string
refreshToken: string | null
status: TUserStatus
createdAt: Date
updatedAt: Date
deletedAt: Date | null
constructor(data: TUserCreate) {
this.type = data.type ?? UserType.CUSTOMER
this.organizationId = data.organizationId ? new ObjectId(data.organizationId) : undefined
this.firstName = data.firstName
this.lastName = data.lastName
this.email = data.email.toLowerCase().trim()
this.phone = data.phone
this.password = data.password // service layer hashes before constructing
this.refreshToken = null
this.status = UserStatus.ACTIVE
this.createdAt = new Date()
this.updatedAt = new Date()
this.deletedAt = null
}
}
Resource Model Example
// src/models/resource.model.ts
import { ObjectId } from 'mongodb'
import { ResourceStatus } from '../enums/resource.enum'
import type { TResourceStatus } from '../types/common.types'
export interface IResource {
_id?: ObjectId
organizationId?: ObjectId | string
name: string
description?: string
status?: TResourceStatus
createdAt?: Date
updatedAt?: Date
deletedAt?: Date | null
}
export type TResourceCreate = Pick<IResource, 'name' | 'description' | 'organizationId'>
export class Resource implements IResource {
_id?: ObjectId
organizationId: ObjectId
name: string
description: string
status: TResourceStatus
createdAt: Date
updatedAt: Date
deletedAt: Date | null
constructor(data: TResourceCreate) {
this.organizationId = new ObjectId(data.organizationId as string)
this.name = data.name.trim()
this.description = data.description ?? ''
this.status = ResourceStatus.ACTIVE
this.createdAt = new Date()
this.updatedAt = new Date()
this.deletedAt = null
}
}
Rules
- Interfaces own the query/read shape — fields are optional where MongoDB might not include them.
- Classes own the write shape — the constructor enforces defaults so the DB never receives partial documents.
- Never skip
deletedAt: nullin the constructor — it enables the soft-delete filter{ deletedAt: null }on all queries. ObjectIdconversion happens in the constructor (new ObjectId(id)), not in the service or repository.- No methods on model classes other than the constructor. Behaviour lives in services.
- Email normalization (
toLowerCase().trim()) happens in the constructor on the model, not in the service.