# NestJS and MongoDB in 2026: Mongoose, Aggregations and Interview Questions > Master NestJS with MongoDB and Mongoose 9. Learn schema design, aggregation pipelines, and prepare for technical interviews with practical examples. - Published: 2026-08-28 - Updated: 2026-08-28 - Author: Anthony Fillion-Maillet - Tags: nestjs, mongodb, mongoose, nodejs, backend, interview - Reading time: 9 min --- NestJS 12 combined with MongoDB through Mongoose 9 provides a production-ready stack for building scalable Node.js backends. This guide covers schema design patterns, aggregation pipelines, and the interview questions that distinguish senior candidates from juniors. > **Quick Reference** > > Mongoose 9.9.4 requires Node.js 18+ and supports MongoDB 6.0 through 8.0. NestJS 12 ships ESM-ready packages but remains backward compatible with CommonJS projects. ## Setting Up Mongoose in a NestJS 12 Application The `@nestjs/mongoose` package integrates Mongoose with NestJS dependency injection. Install the required dependencies: ```bash # Install Mongoose integration npm install @nestjs/mongoose mongoose ``` Register the connection in the root module: ```typescript // app.module.ts import { Module } from '@nestjs/common'; import { MongooseModule } from '@nestjs/mongoose'; @Module({ imports: [ MongooseModule.forRoot(process.env.MONGODB_URI, { // Connection pool size for production workloads maxPoolSize: 10, // Timeout after 10 seconds if connection fails serverSelectionTimeoutMS: 10000, }), ], }) export class AppModule {} ``` The `forRoot` method accepts all [Mongoose connection options](https://mongoosejs.com/docs/connections.html). Setting `maxPoolSize` prevents connection exhaustion under load, a common production issue. ## Schema Design with TypeScript Decorators Mongoose schemas in NestJS use decorators from `@nestjs/mongoose`. Each schema maps to a MongoDB collection. ```typescript // user.schema.ts import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; import { HydratedDocument, Types } from 'mongoose'; // Document type for TypeScript autocomplete export type UserDocument = HydratedDocument; @Schema({ timestamps: true, // Adds createdAt and updatedAt collection: 'users', // Explicit collection name }) export class User { // MongoDB ObjectId, auto-generated _id: Types.ObjectId; @Prop({ required: true, unique: true, index: true }) email: string; @Prop({ required: true }) passwordHash: string; @Prop({ type: String, enum: ['admin', 'user', 'guest'], default: 'user' }) role: string; @Prop({ type: [String], default: [] }) permissions: string[]; } export const UserSchema = SchemaFactory.createForClass(User); ``` The `@Prop` decorator defines field constraints. Setting `index: true` on frequently queried fields improves read performance at the cost of slower writes. > **Interview Insight** > > Interviewers ask about the trade-off between embedded documents and references. Embedded documents suit data accessed together (user profile + preferences). References fit data that grows unboundedly or requires independent queries (user + orders). ## Repository Pattern with Injectable Services NestJS encourages separating database logic into services. The `@InjectModel` decorator provides access to the Mongoose model. ```typescript // user.service.ts import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; import { Model, Types } from 'mongoose'; import { User, UserDocument } from './user.schema'; @Injectable() export class UserService { constructor( @InjectModel(User.name) private userModel: Model, ) {} async findById(id: string): Promise { // Validate ObjectId format before querying if (!Types.ObjectId.isValid(id)) { throw new NotFoundException('Invalid user ID format'); } const user = await this.userModel.findById(id).exec(); if (!user) { throw new NotFoundException(`User ${id} not found`); } return user; } async findByEmail(email: string): Promise { // Case-insensitive email lookup return this.userModel.findOne({ email: { $regex: new RegExp(`^${email}$`, 'i') } }).exec(); } async create(data: Partial): Promise { const user = new this.userModel(data); return user.save(); } } ``` Calling `.exec()` returns a proper Promise instead of a Mongoose Query object. This matters for proper async/await behavior and error stack traces. ## Aggregation Pipelines for Complex Queries MongoDB aggregations handle reporting, analytics, and data transformations that SQL databases solve with joins and GROUP BY. ```typescript // analytics.service.ts import { Injectable } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; import { Model, PipelineStage } from 'mongoose'; import { Order, OrderDocument } from './order.schema'; @Injectable() export class AnalyticsService { constructor( @InjectModel(Order.name) private orderModel: Model, ) {} async getRevenueByMonth(year: number): Promise { const pipeline: PipelineStage[] = [ // Stage 1: Filter orders by year { $match: { createdAt: { $gte: new Date(`${year}-01-01`), $lt: new Date(`${year + 1}-01-01`), }, status: 'completed', }, }, // Stage 2: Group by month, sum revenue { $group: { _id: { $month: '$createdAt' }, totalRevenue: { $sum: '$amount' }, orderCount: { $sum: 1 }, avgOrderValue: { $avg: '$amount' }, }, }, // Stage 3: Sort by month ascending { $sort: { _id: 1 } }, // Stage 4: Reshape output { $project: { _id: 0, month: '$_id', totalRevenue: { $round: ['$totalRevenue', 2] }, orderCount: 1, avgOrderValue: { $round: ['$avgOrderValue', 2] }, }, }, ]; return this.orderModel.aggregate(pipeline).exec(); } } ``` Aggregation pipelines process documents through stages sequentially. Each stage transforms the output for the next stage. The `$match` stage filters early to reduce documents processed by subsequent stages. ## Transactions for Multi-Document Operations MongoDB 4.0+ supports multi-document ACID transactions. Use transactions when multiple documents must update atomically. ```typescript // transfer.service.ts import { Injectable, BadRequestException } from '@nestjs/common'; import { InjectConnection, InjectModel } from '@nestjs/mongoose'; import { Connection, Model, ClientSession } from 'mongoose'; import { Account, AccountDocument } from './account.schema'; @Injectable() export class TransferService { constructor( @InjectConnection() private connection: Connection, @InjectModel(Account.name) private accountModel: Model, ) {} async transfer( fromId: string, toId: string, amount: number, ): Promise { // Start a session for the transaction const session: ClientSession = await this.connection.startSession(); try { await session.withTransaction(async () => { // Debit source account const source = await this.accountModel.findOneAndUpdate( { _id: fromId, balance: { $gte: amount } }, { $inc: { balance: -amount } }, { session, new: true }, ); if (!source) { throw new BadRequestException('Insufficient balance or account not found'); } // Credit destination account const dest = await this.accountModel.findByIdAndUpdate( toId, { $inc: { balance: amount } }, { session, new: true }, ); if (!dest) { throw new BadRequestException('Destination account not found'); } }); } finally { await session.endSession(); } } } ``` The `session.withTransaction` wrapper handles commit and rollback automatically. If any operation throws, the entire transaction aborts. > **Production Note** > > Transactions require a MongoDB replica set or sharded cluster. Standalone MongoDB instances do not support transactions. Atlas M0/M2/M5 tiers include replica sets by default. ## Indexing Strategies for Query Performance Indexes determine query performance. Without proper indexes, MongoDB scans entire collections. ```typescript // product.schema.ts import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; import { HydratedDocument } from 'mongoose'; export type ProductDocument = HydratedDocument; @Schema({ timestamps: true }) export class Product { @Prop({ required: true, index: true }) sku: string; @Prop({ required: true }) name: string; @Prop({ required: true }) category: string; @Prop({ required: true }) price: number; @Prop({ default: true }) inStock: boolean; } export const ProductSchema = SchemaFactory.createForClass(Product); // Compound index for common query pattern ProductSchema.index({ category: 1, price: -1 }); // Text index for search functionality ProductSchema.index({ name: 'text', sku: 'text' }); ``` Compound indexes support queries that filter or sort by multiple fields. The index `{ category: 1, price: -1 }` optimizes queries like `find({ category }).sort({ price: -1 })`. ## Common Interview Questions on NestJS and MongoDB Technical interviews test both conceptual understanding and practical experience. These questions appear frequently in senior backend roles. **Q: How does Mongoose handle connection pooling?** Mongoose maintains a connection pool internally. The `maxPoolSize` option (default: 100) limits concurrent connections. Each operation checks out a connection from the pool, executes, and returns it. Connection pooling avoids the overhead of establishing new TCP connections per query. **Q: When should embedded documents be used instead of references?** Embed data that belongs together and has bounded growth. A user's shipping addresses (maximum 5-10) embed well. Order line items embed within the order document. References fit unbounded relationships: a user's orders over years, or products in a category. The rule: if the data loads together 90% of the time and stays under 16MB, embed it. **Q: Explain the aggregation pipeline $lookup stage.** The `$lookup` stage performs a left outer join between collections. It matches documents from a foreign collection based on field equality or a custom pipeline. Unlike SQL joins, `$lookup` executes during the aggregation and can include additional filtering and projection within the join. ```typescript // Example: Orders with customer details const pipeline: PipelineStage[] = [ { $lookup: { from: 'customers', localField: 'customerId', foreignField: '_id', as: 'customer', }, }, { $unwind: '$customer' }, ]; ``` **Q: How do you handle schema migrations in MongoDB?** MongoDB schemas evolve differently than SQL. Common strategies: - Add new fields with default values (backward compatible) - Run migration scripts that update existing documents in batches - Use schema versioning with a `schemaVersion` field - Mongoose middleware (`pre('save')`) can transform documents on write > **Interview Tip** > > Senior candidates explain trade-offs. Juniors list features. When asked about embedded vs referenced documents, discuss query patterns, document size limits (16MB), and update frequency, not just "it depends." ## Error Handling and Validation Patterns Mongoose validation runs before save operations. Custom validators handle business logic. ```typescript // order.schema.ts import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; import { HydratedDocument } from 'mongoose'; export type OrderDocument = HydratedDocument; @Schema({ timestamps: true }) export class Order { @Prop({ required: true, validate: { validator: (v: number) => v > 0, message: 'Amount must be positive', }, }) amount: number; @Prop({ type: String, enum: ['pending', 'processing', 'completed', 'cancelled'], default: 'pending', }) status: string; @Prop({ required: true }) items: OrderItem[]; } export const OrderSchema = SchemaFactory.createForClass(Order); // Pre-save hook for computed fields OrderSchema.pre('save', function (next) { // Recalculate total from items if (this.isModified('items')) { this.amount = this.items.reduce( (sum, item) => sum + item.price * item.quantity, 0, ); } next(); }); ``` The `pre('save')` middleware runs before each save operation. Use it for computed fields, audit logging, or cascading updates. ## Performance Monitoring with explain() The `explain()` method reveals query execution plans. Use it to identify missing indexes and slow queries. ```typescript // Debug query performance async analyzeQuery(category: string): Promise { const explanation = await this.productModel .find({ category, inStock: true }) .sort({ price: -1 }) .explain('executionStats'); console.log('Documents examined:', explanation.executionStats.totalDocsExamined); console.log('Documents returned:', explanation.executionStats.nReturned); console.log('Execution time (ms):', explanation.executionStats.executionTimeMillis); // If totalDocsExamined >> nReturned, add an index } ``` A ratio of `totalDocsExamined` to `nReturned` close to 1 indicates efficient index usage. High ratios signal missing indexes or non-selective queries. ## Key Takeaways for NestJS MongoDB Development - Configure `maxPoolSize` based on expected concurrency, default 100 suits most workloads - Use `HydratedDocument` for proper TypeScript typing of Mongoose documents - Call `.exec()` on queries to get native Promises with accurate stack traces - Place `$match` stages early in aggregation pipelines to reduce processed documents - Transactions require replica sets, verify the deployment topology before relying on them - Create compound indexes matching query patterns: filter fields first, sort fields second - Embed documents with bounded growth, reference documents that grow unboundedly - Monitor query performance with `explain('executionStats')` during development --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/en/blog/node-nestjs/nestjs-mongodb-mongoose-guide