Skip to content
NestJS ns database 5 min read

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: true on @Schema() to have Mongoose maintain createdAt and updatedAt automatically. 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.

OptionPurpose
typeExplicit BSON/JS type — required for arrays, Buffer, mixed, refs
requiredRejects documents missing the field on validation
defaultDefault value or a factory function
uniqueBuilds a unique index on the field
indexAdds a non-unique index for faster reads
enumRestricts a string/number to an allowed set
min / maxNumeric or date bounds
lowercase / trimBuilt-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, with forFeature.
  • Use User.name for 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 type for arrays, enums, and embedded schemas — reflection cannot infer these.
  • Enable timestamps: true and add index/unique where your queries filter, but avoid indexing fields you never query.
  • Model nested objects as their own @Schema() classes (with _id: false when appropriate) instead of loose Object props, so subdocuments stay validated and typed.
Last updated June 14, 2026
Was this helpful?