Mongoose Schemas & Models
MongoDB is schemaless, but your application code rarely should be. The @nestjs/mongoose package lets you describe each document as a decorated TypeScript class, compile it into a real Mongoose schema, and inject a strongly typed model wherever you need to query. This gives you the safety and tooling of a typed data layer while keeping MongoDB’s flexible document model underneath. Get the schema right and your services, validation, and population all flow naturally from it.
Defining a schema class
A schema is an ordinary class decorated with @Schema(); each persisted field is annotated with @Prop(). Mongoose reads the TypeScript type via reflection, but you should pass explicit options for anything beyond a primitive so the generated schema is unambiguous. The class doubles as the document type your services and DTOs reference.
// src/users/schemas/user.schema.ts
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument } from 'mongoose';
export type UserDocument = HydratedDocument<User>;
@Schema({ timestamps: true })
export class User {
@Prop({ required: true, trim: true })
name: string;
@Prop({ required: true, unique: true, lowercase: true })
email: string;
@Prop({ default: true })
isActive: boolean;
@Prop({ type: [String], default: [] })
roles: string[];
}
export const UserSchema = SchemaFactory.createForClass(User);
SchemaFactory.createForClass(User) turns the decorated class into a mongoose.Schema, and HydratedDocument<User> is the proper document type to use in your services — it adds Mongoose’s instance methods (save, toObject, virtuals) on top of your plain fields.
Set
timestamps: trueon@Schema()to have Mongoose maintaincreatedAtandupdatedAtautomatically. It is the single most reused option and saves you from hand-managing audit dates.
@Prop options
@Prop() accepts the same options as a raw Mongoose path definition. For primitives, reflection infers the type; for arrays, enums, and embedded documents you must supply type explicitly because TypeScript metadata cannot carry that detail.
| Option | Purpose |
|---|---|
type | Explicit BSON/JS type — required for arrays, Buffer, mixed, refs |
required | Rejects documents missing the field on validation |
default | Default value or a factory function |
unique | Builds a unique index on the field |
index | Adds a non-unique index for faster reads |
enum | Restricts a string/number to an allowed set |
min / max | Numeric or date bounds |
lowercase / trim | Built-in string setters applied on write |
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument } from 'mongoose';
export type ProductDocument = HydratedDocument<Product>;
export enum Currency {
USD = 'USD',
EUR = 'EUR',
}
@Schema({ timestamps: true })
export class Product {
@Prop({ required: true, index: true })
sku: string;
@Prop({ required: true, min: 0 })
price: number;
@Prop({ type: String, enum: Currency, default: Currency.USD })
currency: Currency;
@Prop({ type: [String], default: [] })
tags: string[];
}
export const ProductSchema = SchemaFactory.createForClass(Product);
Embedded subdocuments
Nested objects are themselves schema classes. Decorate the child with @Schema() and reference it from the parent’s @Prop({ type: ... }). Pass _id: false to a subdocument schema when you don’t want Mongoose to mint an id for each embedded entry.
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
@Schema({ _id: false })
export class Address {
@Prop({ required: true })
street: string;
@Prop({ required: true })
city: string;
@Prop()
postalCode: string;
}
export const AddressSchema = SchemaFactory.createForClass(Address);
@Schema({ timestamps: true })
export class Customer {
@Prop({ required: true })
name: string;
@Prop({ type: AddressSchema })
billingAddress: Address;
@Prop({ type: [AddressSchema], default: [] })
shippingAddresses: Address[];
}
export const CustomerSchema = SchemaFactory.createForClass(Customer);
Registering models with forFeature
A schema becomes a usable model only after you register it in the owning module with MongooseModule.forFeature(). Each entry maps a model name to its schema; Nest then provides an injectable Model token for that name. This mirrors how TypeOrmModule.forFeature exposes repositories.
// src/users/users.module.ts
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
import { User, UserSchema } from './schemas/user.schema';
import { UsersService } from './users.service';
@Module({
imports: [
MongooseModule.forFeature([{ name: User.name, schema: UserSchema }]),
],
providers: [UsersService],
})
export class UsersModule {}
Using User.name as the model name keeps the registration in sync with the class — rename the class and the token follows.
Injecting and querying a model
Inject the model into a provider with @InjectModel(User.name), typed as Model<UserDocument>. From there you have the full Mongoose query API: create, find, findById, findOneAndUpdate, and so on. Most query methods return a Mongoose Query, so call .exec() to get a real promise.
// src/users/users.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { Model } from 'mongoose';
import { User, UserDocument } from './schemas/user.schema';
@Injectable()
export class UsersService {
constructor(
@InjectModel(User.name) private readonly userModel: Model<UserDocument>,
) {}
create(data: Partial<User>): Promise<UserDocument> {
return this.userModel.create(data);
}
findActive(): Promise<UserDocument[]> {
return this.userModel.find({ isActive: true }).sort({ createdAt: -1 }).exec();
}
async findById(id: string): Promise<UserDocument> {
const user = await this.userModel.findById(id).exec();
if (!user) {
throw new NotFoundException(`User ${id} not found`);
}
return user;
}
deactivate(id: string): Promise<UserDocument | null> {
return this.userModel
.findByIdAndUpdate(id, { isActive: false }, { new: true })
.exec();
}
}
A freshly created document hydrated with timestamps looks like this:
Output:
{
_id: new ObjectId('66a1f3c2e1b4a90d3c8f1a22'),
name: 'Ada Lovelace',
email: '[email protected]',
isActive: true,
roles: [ 'engineer' ],
createdAt: 2026-06-14T09:31:05.124Z,
updatedAt: 2026-06-14T09:31:05.124Z,
__v: 0
}
Best Practices
- Always export both the class and
SchemaFactory.createForClass(...); register the schema, not the class, withforFeature. - Use
User.namefor the model name so the injectable token never drifts from a class rename. - Type the injected model as
Model<UserDocument>and call.exec()on queries to return genuine promises with full TypeScript inference. - Provide an explicit
typefor arrays, enums, and embedded schemas — reflection cannot infer these. - Enable
timestamps: trueand addindex/uniquewhere your queries filter, but avoid indexing fields you never query. - Model nested objects as their own
@Schema()classes (with_id: falsewhen appropriate) instead of looseObjectprops, so subdocuments stay validated and typed.