MongoDB with Mongoose
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against NestJS 12.1 (npm @nestjs/core 12.1.2), @nestjs/mongoose 12.0, Mongoose 9.10, MongoDB 8.2 (the server mongodb-memory-server starts), TypeScript 6 (6.0.x), Node.js 22 and 24 LTS, Express 5 and Fastify 5, and was checked on 2026-10-02. The examples use a Nest 12 project (ESM template, Vitest, supertest) against mongodb-memory-server 11, with a single-node replica set where transactions are involved. The output blocks are what the examples produce, with ids and timestamps shortened.
MongoDB stores JSON-like documents (see Documents and BSON) and Nest offers two ways to use it: the MongoDB connector of TypeORM (see Data Overview & TypeORM) or Mongoose, the most popular object modelling library for MongoDB, wrapped by the first-party @nestjs/mongoose package. This page covers the second. In the Bookshelf application that grows across this section, MongoDB holds the activity feed: an append-only stream of "book viewed" and "review posted" events, a per-book counter, and an audit log in a second database. Relational data (books, authors, users) stays in PostgreSQL; the MongoDB design questions (embed or reference, indexes, shard keys) are covered in Data Modeling and Indexes.
What @nestjs/mongoose adds to Mongoose
Mongoose itself is plain JavaScript with TypeScript typings. @nestjs/mongoose adds four things on top:
-
MongooseModule, a dynamic module (see Dynamic Modules) withforRoot()/forRootAsync()to open connections andforFeature()/forFeatureAsync()to register models per feature module. -
Decorators (
@Schema(),@Prop(),@Virtual()) andSchemaFactorythat build a MongooseSchemafrom a TypeScript class, using the metadata thatemitDecoratorMetadataemits (see TypeScript for NestJS). -
Injection tokens for models and connections:
@InjectModel(),@InjectConnection()and the helper functionsgetModelToken()andgetConnectionToken(). -
Lifecycle integration: connections are opened when the module is initialised and closed when the application shuts down.
Install both packages:
npm i @nestjs/mongoose mongoose
Mongoose 9 needs Node.js 20.19 or later, which matches the Node.js floor of Nest 12. The examples use .js import extensions, as the current ESM template does. Nothing else about the module is specific to the HTTP adapter: the Express and Fastify platforms behave identically because MongoDB access never touches the request object.
Connecting: forRoot() and forRootAsync()
MongooseModule.forRoot() takes a connection string and the same options object as mongoose.connect(). A connection string is configuration, so it belongs in the environment rather than in source; prefer forRootAsync() with ConfigService (see Configuration):
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { MongooseModule } from '@nestjs/mongoose';
import { hideVersionKey } from './plugins.js';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
MongooseModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
// Throws at startup if the variable is missing.
uri: config.getOrThrow<string>('MONGODB_URI'),
connectionFactory: (connection) => {
connection.plugin(hideVersionKey); // see "Plugins" below
return connection;
},
}),
}),
],
})
export class MongodbModule {}
useFactory may be async and may inject providers. Two other styles exist for the same options object, and both work:
-
useClassinstantiates a class insideMongooseModule; the class implementsMongooseOptionsFactoryand returns the options fromcreateMongooseOptions(). -
useExistingreuses a provider that already implementsMongooseOptionsFactoryinstead of creating a private copy.
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { MongooseModuleOptions, MongooseOptionsFactory } from '@nestjs/mongoose';
@Injectable()
export class MongooseConfigService implements MongooseOptionsFactory {
constructor(private readonly config: ConfigService) {}
createMongooseOptions(): MongooseModuleOptions {
return { uri: this.config.getOrThrow<string>('MONGODB_URI'), autoIndex: false };
}
}
// In a module:
// MongooseModule.forRootAsync({ useClass: MongooseConfigService })
Setting autoIndex: false is the production-minded choice: with the default (true) Mongoose calls createIndexes() for every model when the application starts, which on a large collection is a heavyweight operation you may not want to run on every deploy. Create indexes through a migration step instead (see Indexes).
Note that forRoot() waits for the first connection attempt: if MongoDB is unreachable the application fails to start after the driver’s server-selection timeout (30 seconds by default). In containers where the database starts later, that surfaces as a slow crash loop; set serverSelectionTimeoutMS in the options to fail faster.
Defining schemas with decorators
A Mongoose schema describes the shape of the documents in one collection; a model compiled from it creates and queries documents. @nestjs/mongoose lets a class carry the schema definition:
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument } from 'mongoose';
@Schema({ timestamps: true })
export class BookCounter {
@Prop({ required: true, unique: true })
bookId: string;
@Prop({ default: 0 })
reviews: number;
}
export type BookCounterDocument = HydratedDocument<BookCounter>;
export const BookCounterSchema = SchemaFactory.createForClass(BookCounter);
-
@Schema(options)marks the class. Its optional argument is the second argument ofnew mongoose.Schema(definition, options); for exampletimestamps,collection,discriminatorKeyortoJSON. Mongoose names the collection after the model, lower-cased and pluralised (BookCounterbecomesbookcounters); setcollectionto choose a name explicitly. -
@Prop(options)declares a property. Simple types (string,number,boolean,Date) are inferred from the TypeScript metadata. Arrays, unions,Map`s and nested objects cannot be reflected, so they need an explicit type: `@Prop([String])or@Prop({ type: [String], default: [] }). The options object accepts the usual schema-type options such asrequired,default,immutable,enum,min,max,indexandunique. -
SchemaFactory.createForClass(Class)turns the metadata into aSchemainstance. Do this once, in the schema file, and export the result next to the class. -
A schema can also be written without decorators, as
new mongoose.Schema({ … }); both styles register the same way.DefinitionsFactory.createForClass()returns the raw definition object when you need to modify the generated definition before building the schema.
HydratedDocument, not extends Document
Older tutorials declare export type CatDocument = Cat & Document or write class Cat extends Document. Both are outdated: extending Mongoose’s Document pulls its runtime class into your schema class (its properties, _id typing and methods leak into @Prop() metadata) and the intersection type does not model hydrated documents accurately. The current pattern, shown in the Nest documentation, keeps the schema class a plain class and derives the document type from it:
import { HydratedDocument } from 'mongoose';
export type BookCounterDocument = HydratedDocument<BookCounter>;
Model<BookCounter> is the type of an injected model; documents returned by create() or find() are HydratedDocument<BookCounter> (a BookCounter plus Mongoose’s document methods such as save(), toJSON() and populate()). Queries with .lean() return plain BookCounter objects without those methods and are cheaper to produce.
References, populate and raw()
References between collections are ObjectId properties with a ref to the target model name. Type the property as Types.ObjectId and populate on demand with an explicit type for the populated shape, so the field is never mistaken for a loaded document:
import { Injectable } from '@nestjs/common';
import { InjectModel, Prop, raw, Schema } from '@nestjs/mongoose';
import mongoose, { Model, Types } from 'mongoose';
@Schema()
export class Reader {
// A nested object that is not a class: pass a raw definition.
@Prop(raw({ firstName: { type: String }, lastName: { type: String } }))
details: Record<string, any>;
}
@Schema()
export class Shelf {
@Prop({ required: true })
label: string;
@Prop({ type: mongoose.Schema.Types.ObjectId, ref: Reader.name })
owner: Types.ObjectId;
}
@Injectable()
export class ShelfRepository {
constructor(@InjectModel(Shelf.name) private readonly shelves: Model<Shelf>) {}
findAllPopulated() {
return this.shelves.find().populate<{ owner: Reader }>('owner').exec();
}
}
The populate call returns owner as the full Reader document (for a reader saved with details: { firstName: 'Ada', lastName: 'L' }, owner.details.firstName is 'Ada'). Populating is a second query issued by Mongoose, not a join; for relationships that are always read together, embedding (see Subdocuments) is usually better.
Registering and injecting models
Models are registered per feature module with forFeature(), using the schema class name as the model name (Class.name), and injected with @InjectModel():
import { Module } from '@nestjs/common';
import { MongooseModule } from '@nestjs/mongoose';
@Module({
imports: [
MongooseModule.forFeature([{ name: BookCounter.name, schema: BookCounterSchema }]),
],
providers: [ActivityService],
// Export MongooseModule to let importing modules inject these models too.
exports: [MongooseModule],
})
export class ActivityModule {}
// activity.service.ts
@Injectable()
export class ActivityService {
constructor(@InjectModel(BookCounter.name) private readonly counters: Model<BookCounter>) {}
counterFor(bookId: string) {
return this.counters.findOne({ bookId }).lean().exec();
}
}
Three details are worth knowing. The token behind @InjectModel(name) is a string derived from the model name (getModelToken(name)), so the name given to forFeature() and to @InjectModel() must match exactly. .exec() returns a real Promise with a useful stack trace; the bare query object is only "thenable". And model registration is per connection: if the module should use a named connection, forFeature() and @InjectModel() both need its name (next sections).
Documents get an _id of type ObjectId; when a controller returns a Mongoose document, Nest serialises it with JSON.stringify, which calls the document’s toJSON(). Control the output with the toJSON schema option or, for DTO-style filtering, with class-transformer (see Serialization); note that a hydrated document is not an instance of your schema class, so ClassSerializerInterceptor and @Exclude() do not apply to it directly unless you convert it to a plain object first.
Injecting the connection
For native driver access, .collection(), or sessions, inject the Mongoose Connection:
import { Injectable } from '@nestjs/common';
import { InjectConnection } from '@nestjs/mongoose';
import { Connection } from 'mongoose';
@Injectable()
export class ActivityService {
constructor(@InjectConnection() private readonly connection: Connection) {}
}
The bound connection.name is the database name from the URI (bookshelf for the URI used on this page), which is also what connection.db.databaseName reports once connected.
Sessions and transactions
A single-document write in MongoDB is already atomic. When an invariant spans several documents, for example "the feed entry and the per-book counter change together", use a transaction (see Multi-document ACID transactions for the database-side semantics). With Mongoose you start a session from the connection that Nest manages, which is why the docs recommend @InjectConnection() over calling mongoose.startSession() directly:
async recordReview(input: ReviewInput) {
const session = await this.connection.startSession();
try {
return await session.withTransaction(async () => {
const [review] = await this.reviews.create(
[{
bookId: input.bookId,
rating: input.rating,
tags: input.tags ?? [],
actor: { userId: input.userId, displayName: input.displayName },
}],
{ session }, // every operation must be given the session explicitly
);
await this.counters.updateOne(
{ bookId: input.bookId },
{ $inc: { reviews: 1 } },
{ upsert: true, session },
);
return review;
});
} finally {
await session.endSession();
}
}
// The helper form: commits, aborts on error and retries transient errors.
recordReviewWithHelper(input: ReviewInput) {
return this.connection.transaction(async (session) => {
const [review] = await this.reviews.create(
[{ bookId: input.bookId, rating: input.rating, actor: { userId: input.userId, displayName: input.displayName } }],
{ session },
);
await this.counters.updateOne({ bookId: input.bookId }, { $inc: { reviews: 1 } }, { upsert: true, session });
return review;
});
}
Throwing an error after both writes inside the callback and then reading the data back gives:
counter 2 feed 2
Two transactions committed (one with each form) and the third, which threw after writing, left neither a feed entry nor a counter increment. The pitfalls are all about the session:
-
Pass the session to every operation. Nothing is implicit: an operation without
{ session }runs outside the transaction and is not rolled back.Model.create()takes an array when you pass options, which is why the example destructures[review]. -
Use
withTransaction()orconnection.transaction()rather than manualstartTransaction()/commitTransaction()unless you need the control: the helpers retry on transient transaction errors and on unknown commit results, so the callback must be safe to run more than once (no external side effects such as sending e-mail inside it). -
Always end the session (
finally), also after an abort, or the server keeps it until it times out. -
Do not use
Promise.allinside a transaction. Operations in one transaction on one session are not meant to run concurrently; run them sequentially. -
Keep transactions short. The default
transactionLifetimeLimitis 60 seconds and long transactions hold locks and cache pressure.
Transactions need a replica set
Multi-document transactions work only on a replica set or a sharded cluster, not on a standalone mongod. On a standalone server the same code fails, for instance with this message from the test (the exact text can vary between drivers and server versions):
This MongoDB deployment does not support retryable writes. Please add retryWrites=false to your connection string.
(Following that advice would only hide the real problem: the deployment must be a replica set. See Replication.) Practical consequences:
-
Local development: run MongoDB as a single-node replica set (for example
mongod --replSet rs0plusrs.initiate(), or a Docker image that does it for you). -
Atlas and any managed cluster are replica sets already.
-
In tests,
mongodb-memory-servercan start a replica set: useMongoMemoryReplSetinstead ofMongoMemoryServer. The test file in this page does exactly this (see Testing), and it is also how you can run transaction code against Testcontainers (see Testcontainers Fundamentals).
Calling into the transaction from a request handler is not special: the session is a local variable of the service method, so there is nothing request-scoped to manage. Do not store a session in a singleton provider’s field, since concurrent requests would share it.
Multiple connections
A second database, or a second cluster, needs a second named connection. Naming is mandatory: unnamed or equally named connections override each other.
MongooseModule.forRootAsync({
connectionName: 'audit',
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
uri: config.getOrThrow<string>('MONGODB_AUDIT_URI'),
}),
}),
Then pass the connection name everywhere a connection or model is chosen:
// Register the model on the named connection (second argument of forFeature).
MongooseModule.forFeature([{ name: AuditEntry.name, schema: AuditEntrySchema }], 'audit'),
// Inject the model (second argument of @InjectModel) and the connection.
constructor(
@InjectModel(AuditEntry.name, 'audit') private readonly audit: Model<AuditEntry>,
@InjectConnection('audit') private readonly auditConnection: Connection,
) {}
The default connection reports the database bookshelf and the audit connection bookshelf_audit, and writes made through the audit model land in the second database only:
dbs ["bookshelf","bookshelf_audit"]
For a custom provider, use getConnectionToken(name) (and getModelToken(name, connectionName) for models). A factory provider that receives a connection looks like this (shown with the default connection; pass the name for a named one):
import { getConnectionToken } from '@nestjs/mongoose';
import { Connection } from 'mongoose';
export const connectionAwareProvider = {
provide: ConnectionAware,
useFactory: (connection: Connection) => new ConnectionAware(connection),
inject: [getConnectionToken()], // getConnectionToken('audit') for a named one
};
A transaction cannot span two connections created this way, since each has its own client and its own sessions. If two databases must change atomically they have to live in the same cluster and be accessed through one connection (use connection.useDb() or fully qualified collection names), otherwise accept eventual consistency (see Events, Scheduling & Queues for outbox-style approaches).
Hooks (middleware)
Mongoose middleware (called hooks in this page to avoid confusion with HTTP middleware) are functions that run before or after operations such as save, updateOne, find or deleteOne. They are defined on the schema, and a hook defined after the model has been compiled has no effect. That is why @nestjs/mongoose offers forFeatureAsync(): its factory receives the schema before the model is compiled and can inject providers such as ConfigService:
MongooseModule.forFeatureAsync([
{
name: Activity.name,
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => {
// clone(): the module-level schema object stays untouched.
const schema = ActivitySchema.clone();
const maxTags = config.get<number>('ACTIVITY_MAX_TAGS', 5);
schema.pre('save', function () {
this.tags = [...new Set(this.tags.map((t) => t.toLowerCase()))].slice(0, maxTags);
});
return schema;
},
},
]),
Saving a review with the tags ['Great', 'GREAT', 'Scifi'] stored ["great","scifi"]. Notes:
-
Use
function, not an arrow function, when you needthis: for document hooks (save,validate)thisis the document; for query hooks (find,updateOne) it is the query. -
Write hooks as plain or
asyncfunctions, as above; Mongoose 9 does not pass anextcallback at all: apre('save')hook that callsnext()fails withnext is not a function, so older examples need adjusting. -
Mutating the shared
ActivitySchemaconstant instead of cloning it is the usual bug: each call of the factory (every test module, every application restart in watch mode inside one process) would register the hook again on the same object. Clone, or build the schema inside the factory. -
savehooks do not run forupdateOne(),findOneAndUpdate()orinsertMany(); those have their own hook points (pre('updateOne'),pre('insertMany')), andbulkWrite()bypasses most of them.
Plugins
A plugin is a function that receives a schema and extends it (fields, methods, hooks, options). Register one for a single schema in the same forFeatureAsync() factory with schema.plugin(fn) (the Nest docs show mongoose-autopopulate this way), or for every schema of a connection from connectionFactory, which runs before any model is compiled:
import { Schema } from 'mongoose';
// A global plugin: hides the Mongoose version key from JSON output.
export function hideVersionKey(schema: Schema): void {
schema.set('toJSON', { versionKey: false });
}
// MongooseModule.forRootAsync({
// useFactory: (config: ConfigService) => ({
// uri: config.getOrThrow<string>('MONGODB_URI'),
// connectionFactory: (connection) => {
// connection.plugin(hideVersionKey);
// return connection;
// },
// }),
// })
With the plugin installed, the JSON of a created review has no __v field, although the stored document still has one. Plugins registered on the connection apply to the models registered on that connection only, so a named connection needs its own connectionFactory.
Discriminators
Discriminators are schema inheritance for MongoDB: several models with overlapping schemas share one collection, and Mongoose stores a key in every document to say which model it belongs to. They fit the activity feed, where events differ in a few fields but are read as one time-ordered stream (the "single collection with a type field" pattern from Data Modeling).
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { Actor, ActorSchema } from './actor.schema.js';
export const BOOK_VIEWED = 'BookViewed';
export const REVIEW_POSTED = 'ReviewPosted';
// `discriminatorKey` names the path that identifies the model (default: __t).
@Schema({ discriminatorKey: 'kind', timestamps: true })
export class Activity {
@Prop({ type: String, required: true, enum: [BOOK_VIEWED, REVIEW_POSTED] })
kind: string;
@Prop({ required: true, index: true })
bookId: string;
@Prop({ type: ActorSchema, required: true })
actor: Actor;
@Prop({ type: [String], default: [] })
tags: string[];
}
export const ActivitySchema = SchemaFactory.createForClass(Activity);
@Schema()
export class BookViewed {
// Inherited paths are declared WITHOUT @Prop(): the base schema already has them.
kind: string;
bookId: string;
actor: Actor;
tags: string[];
@Prop({ required: true })
referrer: string;
}
export const BookViewedSchema = SchemaFactory.createForClass(BookViewed);
@Schema()
export class ReviewPosted {
kind: string;
bookId: string;
actor: Actor;
tags: string[];
@Prop({ required: true, min: 1, max: 5 })
rating: number;
}
export const ReviewPostedSchema = SchemaFactory.createForClass(ReviewPosted);
Register them with the discriminators option of forFeature() (or forFeatureAsync(), as in the hook example above, where the option sits next to useFactory):
MongooseModule.forFeature([
{
name: Activity.name,
schema: ActivitySchema,
discriminators: [
{ name: BOOK_VIEWED, schema: BookViewedSchema, value: BOOK_VIEWED },
{ name: REVIEW_POSTED, schema: ReviewPostedSchema, value: REVIEW_POSTED },
],
},
]),
Each discriminator is a model of its own, injected with @InjectModel(BOOK_VIEWED), while the base model reads all kinds. One view and one review go to a single activities collection:
{
"_id": "6abf...",
"kind": "ReviewPosted",
"bookId": "b1",
"actor": { "userId": "u1", "displayName": "Ada" },
"tags": ["great", "scifi"],
"rating": 5,
"createdAt": "2026-10-02T15:58:25.608Z",
"updatedAt": "2026-10-02T15:58:25.608Z",
"__v": 0
}
The pitfall that the examples above work around: if BookViewed simply extends Activity, SchemaFactory also collects the inherited @Prop() metadata, the discriminator schema then declares kind itself, and Mongoose refuses to register it with Discriminator "BookViewed" cannot have field with name "kind". Declare the inherited fields as plain TypeScript properties without @Prop() (this is what the Nest docs do) and the types stay accurate. Mongoose also requires the discriminator key not to be redefined, the same collection and connection for the base and its discriminators, and a unique value per discriminator (it defaults to the model name).
Subdocuments
Embedded documents have their own schema but live inside the parent document, with no collection of their own. Give a subdocument class @Schema({ _id: false }) when it does not need an identifier, create its schema, and reference that schema in the parent:
@Schema({ _id: false })
export class Actor {
@Prop({ required: true })
userId: string;
@Prop({ required: true })
displayName: string;
}
export const ActorSchema = SchemaFactory.createForClass(Actor);
// In the parent schema class:
@Prop({ type: ActorSchema, required: true })
actor: Actor;
// The hydrated document type is overridden so that TypeScript knows `actor` is a subdocument:
export type ActivityDocument = HydratedDocument<
Activity,
{ actor: Types.Subdocument<Types.ObjectId> & Actor }
>;
For many subdocuments use an array of the schema, and override the property with Types.DocumentArray:
@Schema({ _id: false })
export class Name {
@Prop({ required: true })
firstName: string;
@Prop({ required: true })
lastName: string;
}
export const NameSchema = SchemaFactory.createForClass(Name);
@Schema()
export class Author {
@Prop([NameSchema])
names: Name[];
}
export type AuthorDocument = HydratedDocument<Author, { names: Types.DocumentArray<Name> }>;
export const AuthorSchema = SchemaFactory.createForClass(Author);
The nested schema’s validators apply to the subdocument, and its fields can be queried with dot notation, for example find({ 'actor.userId': 'u1' }). The MongoDB maximum document size (16 MiB) and unbounded arrays are the practical limits of embedding; see Data Modeling.
Virtuals
A virtual is a property computed on access and never stored. @Virtual() from @nestjs/mongoose declares one on the schema class:
import { Virtual } from '@nestjs/mongoose';
// in Author
@Virtual({
get: function (this: Author) {
const [first] = this.names ?? [];
return first ? `${first.firstName} ${first.lastName}` : '';
},
})
fullName: string;
// in Activity
@Virtual({
get: function (this: Activity) {
return `${this.actor?.displayName ?? 'someone'} -> ${this.kind} on ${this.bookId}`;
},
})
summary: string;
For an author created with names: [{ firstName: 'Ursula', lastName: 'Le Guin' }], fullName is Ursula Le Guin while the stored and serialised document contains only names. Remember that:
-
Virtuals are not in the database, so they cannot be used in queries, indexes or
.lean()results (lean returns the stored data only). -
Virtuals are left out of
toJSON()andtoObject()unless the schema optiontoJSON: { virtuals: true }is set; the example controller therefore addssummaryby hand. Setting it globally with a plugin is convenient but exposes every virtual. -
Use
function, not an arrow function, inget/set, so thatthisis the document.
Connection events
onConnectionCreate receives each connection as soon as it exists and before it opens, so listeners cannot miss the first events (connected, open, disconnected, reconnected, disconnecting). It works in forRoot() and in the object returned by the forRootAsync() factory:
import { Logger } from '@nestjs/common';
import { Connection } from 'mongoose';
export const CONNECTION_EVENTS = ['connected', 'open', 'disconnected', 'reconnected', 'disconnecting'] as const;
export function logConnectionEvents(name: string) {
const logger = new Logger(`mongoose:${name}`);
return (connection: Connection): Connection => {
for (const event of CONNECTION_EVENTS) {
connection.on(event, () => logger.log(event));
}
return connection;
};
}
// MongooseModule.forRootAsync({
// useFactory: (config: ConfigService) => ({
// uri: config.getOrThrow<string>('MONGODB_URI'),
// onConnectionCreate: logConnectionEvents('default'),
// }),
// inject: [ConfigService],
// })
During start-up of the two-connection test application the listeners recorded:
["default:connected","default:open","audit:connected","audit:open"]
The order between the two connections is not guaranteed (a second run listed audit first), since each connects independently. Use these events for logging and metrics; for a liveness check on the database use a health indicator (see the health-checks page of this section) rather than reading the events.
Testing with getModelToken
Unit tests of a service should not need a database. Because models are ordinary providers with a deterministic token, replace them with getModelToken(), and connections with getConnectionToken():
import { Test } from '@nestjs/testing';
import { getConnectionToken, getModelToken } from '@nestjs/mongoose';
it('uses mock models', async () => {
const find = vi.fn().mockReturnValue({ sort: () => ({ exec: async () => [{ kind: 'BookViewed' }] }) });
const moduleRef = await Test.createTestingModule({
providers: [
ActivityService,
{ provide: getModelToken(Activity.name), useValue: { find } },
{ provide: getModelToken('BookViewed'), useValue: {} },
{ provide: getModelToken('ReviewPosted'), useValue: {} },
{ provide: getModelToken('BookCounter'), useValue: {} },
{ provide: getModelToken('AuditEntry', 'audit'), useValue: {} },
{ provide: getConnectionToken(), useValue: { name: 'fake' } },
{ provide: getConnectionToken('audit'), useValue: { name: 'fake-audit' } },
],
}).compile();
const service = moduleRef.get(ActivityService);
expect(await service.findForBook('b1')).toEqual([{ kind: 'BookViewed' }]);
expect(find).toHaveBeenCalledWith({ bookId: 'b1' });
});
(vi.fn() is Vitest; use jest.fn() in the CommonJS/Jest template.) A mock proves the service calls the model as intended; it does not prove the query, the hooks, the discriminators or the transactions work. For those, run against a real MongoDB: mongodb-memory-server downloads and starts a real mongod for the test run, and MongoMemoryReplSet provides the replica set that transactions require:
import { MongoMemoryReplSet } from 'mongodb-memory-server';
let replSet: MongoMemoryReplSet;
let app: INestApplication;
beforeAll(async () => {
replSet = await MongoMemoryReplSet.create({ replSet: { count: 1 } });
// The application reads these through ConfigService.
process.env.MONGODB_URI = replSet.getUri('bookshelf');
process.env.MONGODB_AUDIT_URI = replSet.getUri('bookshelf_audit');
const moduleRef = await Test.createTestingModule({
imports: [MongodbModule, ActivityModule],
}).compile();
app = moduleRef.createNestApplication();
await app.init();
});
afterAll(async () => {
await app.close(); // closes the Mongoose connections
await replSet.stop();
});
Calling the HTTP endpoint of the example controller exercised the whole stack (validation, hook, transaction, serialisation):
curl -X POST http://localhost:3000/books/b3/activity/reviews \
-H 'Content-Type: application/json' \
-d '{"userId":"u2","displayName":"Bo","rating":2,"tags":["Meh"]}'
{
"kind": "ReviewPosted",
"bookId": "b3",
"actor": { "userId": "u2", "displayName": "Bo" },
"tags": ["meh"],
"_id": "6abf...",
"rating": 2,
"createdAt": "2026-10-02T15:59:00.240Z",
"updatedAt": "2026-10-02T15:59:00.240Z"
}
The first start of mongodb-memory-server downloads a mongod binary into a cache directory; CI jobs should cache it or set the MONGOMS_DOWNLOAD_DIR/MONGOMS_VERSION environment variables, and air-gapped builds may prefer Testcontainers instead (see Testcontainers Fundamentals).
Where older material is outdated, and what changed recently
-
Nest 10 and
extends Documenttutorials. Material written for Nest 10 (including the book this section consulted, which targets Nest 10) types documents asCat & Document. UseHydratedDocument<Cat>. The book’s GraphQL case study also reuses Mongoose classes as GraphQL types without@ObjectType(): that does not work, because GraphQL needs its own decorated type; keep schema classes and GraphQL types separate (or deliberately decorate one class for both, accepting the coupling). -
Connection options. Options such as
useNewUrlParseranduseUnifiedTopology, still common in older tutorials, belong to old driver versions; do not copy them. Use a plain URI plus the options you actually need. -
Hooks. Mongoose 9 hooks are plain or async functions without
next; tutorials written for Mongoose 5 to 8 callnext(). -
Mongoose queries are thenables, not Promises.
await model.find()works, but.exec()gives better stack traces and a realPromiseforPromise.all.
Express and Fastify
Nothing in @nestjs/mongoose depends on the HTTP platform. The one place the platform shows is around it: ObjectId strings arrive as route parameters on both adapters, so validate them with a pipe before they reach a query (see Pipes & Validation); an invalid id otherwise surfaces as a Mongoose CastError and a 500 response.
Related pages
-
Data Overview & TypeORM and Prisma, Drizzle, MikroORM & Sequelize for the relational options, and Caching for reducing reads.
-
Multi-document ACID transactions, Replication, Indexes, Change Streams and Drivers and Tooling for the database side.
-
Lifecycle Events & Shutdown: connections close when the application shuts down only if shutdown hooks are enabled.
References
-
NestJS documentation, MongoDB: https://docs.nestjs.com/data/mongodb
-
NestJS documentation, Testing: https://docs.nestjs.com/fundamentals/testing
-
NestJS documentation, Custom providers: https://docs.nestjs.com/fundamentals/custom-providers
-
Mongoose documentation: https://mongoosejs.com/docs/
-
mongodb-memory-server: https://github.com/typegoose/mongodb-memory-server