This chapter follows Application lifecycle hooks in NestJS.
Mongoose already gives MongoDB schemas, validation, and models. What it does not give is a place in a Nest application: who opens the connection, how a service gets a model, and how a test replaces it. @nestjs/mongoose answers those with the patterns from the previous chapters. The connection is a forRootAsync() dynamic module, each feature registers its models with forFeature(), and models are injected by token. For Mongoose itself, schemas, queries, and population, see Mongoose with MongoDB.
Connecting
pnpm add @nestjs/mongoose mongooseThe connection is opened once, in the root module, with the URI from configuration:
import { Module } from '@nestjs/common';import { ConfigModule, ConfigService } from '@nestjs/config';import { MongooseModule } from '@nestjs/mongoose';import { CatsModule } from './cats/cats.module';
@Module({ imports: [ ConfigModule.forRoot({ isGlobal: true }), MongooseModule.forRootAsync({ inject: [ConfigService], useFactory: (config: ConfigService) => ({ uri: config.getOrThrow<string>('MONGODB_URI'), }), }), CatsModule, ],})export class AppModule {}Nest waits for the connection before it finishes starting, so a wrong URI fails at startup. The module also closes the connection during shutdown, which is one more reason to call app.enableShutdownHooks().
Defining a schema
Schemas can be written as classes with decorators. SchemaFactory turns the class into a regular Mongoose schema:
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';import { HydratedDocument, Types } from 'mongoose';
@Schema({ timestamps: true })export class Cat { @Prop({ required: true, trim: true }) name: string;
@Prop({ min: 0 }) age: number;
@Prop({ index: true }) breed: string;
@Prop({ type: Types.ObjectId, ref: 'User', required: true }) owner: Types.ObjectId;}
export type CatDocument = HydratedDocument<Cat>;export const CatSchema = SchemaFactory.createForClass(Cat);CatSchema.index({ owner: 1, name: 1 }, { unique: true });The class is the type of a stored document. It is a separate thing from the CreateCatDto that validates a request: the DTO decides what a client may send, the schema decides what the database stores, and the two tend to differ (owner comes from the authenticated user, not from the body).
Registering and injecting a model
A feature module registers the models it uses on the shared connection:
import { Module } from '@nestjs/common';import { MongooseModule } from '@nestjs/mongoose';import { CatsController } from './cats.controller';import { CatsService } from './cats.service';import { Cat, CatSchema } from './schemas/cat.schema';
@Module({ imports: [MongooseModule.forFeature([{ name: Cat.name, schema: CatSchema }])], controllers: [CatsController], providers: [CatsService],})export class CatsModule {}The service injects the model with @InjectModel() and uses the normal Mongoose API:
import { Injectable, NotFoundException } from '@nestjs/common';import { InjectModel } from '@nestjs/mongoose';import { isValidObjectId, Model } from 'mongoose';import { CreateCatDto } from './dto/create-cat.dto';import { Cat } from './schemas/cat.schema';
@Injectable()export class CatsService { constructor(@InjectModel(Cat.name) private readonly catModel: Model<Cat>) {}
create(dto: CreateCatDto, ownerId: string) { return this.catModel.create({ ...dto, owner: ownerId }); }
findAll(breed?: string) { return this.catModel.find(breed ? { breed } : {}).lean().exec(); }
async findOne(id: string) { const cat = isValidObjectId(id) ? await this.catModel.findById(id).lean().exec() : null; if (!cat) throw new NotFoundException(`Cat ${id} not found`); return cat; }}lean() returns plain objects instead of full Mongoose documents. That is faster for reads that go straight into a response. Leave it off when the code needs document methods such as save().
Mapping database errors
A duplicate key violates the unique index and arrives as a Mongo error with code 11000. Left alone, the built-in filter answers 500. A small exception filter turns it into a 409:
import { ArgumentsHost, Catch, ConflictException, ExceptionFilter } from '@nestjs/common';import { BaseExceptionFilter } from '@nestjs/core';import { mongo } from 'mongoose';
@Catch(mongo.MongoServerError)export class MongoDuplicateKeyFilter extends BaseExceptionFilter implements ExceptionFilter { catch(exception: mongo.MongoServerError, host: ArgumentsHost) { if (exception.code === 11000) { return super.catch(new ConflictException('Resource already exists', { cause: exception }), host); } return super.catch(exception, host); }}Extending BaseExceptionFilter hands the response back to Nest’s built-in filter, so the 409 has the same JSON shape as every other HTTP exception. When registered with APP_FILTER, BaseExceptionFilter receives the HTTP adapter through the injector, so the class needs no constructor of its own.
Testing with a model
@InjectModel(Cat.name) resolves to a provider whose token is getModelToken(Cat.name). A unit test supplies a fake under that token and never touches a database:
import { getModelToken } from '@nestjs/mongoose';import { Test } from '@nestjs/testing';import { CatsService } from './cats.service';import { Cat } from './schemas/cat.schema';
describe('CatsService', () => { const catModel = { find: jest.fn(), findById: jest.fn(), create: jest.fn() }; let service: CatsService;
beforeEach(async () => { const moduleRef = await Test.createTestingModule({ providers: [CatsService, { provide: getModelToken(Cat.name), useValue: catModel }], }).compile(); service = moduleRef.get(CatsService); });});For tests that should exercise real queries and indexes, mongodb-memory-server starts a throwaway MongoDB that forRootAsync() can point at. Testing NestJS applications covers both kinds of test.
Next: Authentication in NestJS.
Check your understanding
Question 1 of 5
0 of 5 checked