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:

  1. An interface (I prefix) — the full document shape with mostly optional fields (as MongoDB may return partial projections).
  2. A type alias for create inputs (T prefix + Create suffix) using Pick.
  3. 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: null in the constructor — it enables the soft-delete filter { deletedAt: null } on all queries.
  • ObjectId conversion 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.