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.

NestJS framework with MongoDB database integration showing code architecture and aggregation pipelines

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:

app.module.tstypescript
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. 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.

user.schema.tstypescript
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument, Types } from 'mongoose';

// Document type for TypeScript autocomplete
export type UserDocument = HydratedDocument<User>;

@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.

user.service.tstypescript
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<UserDocument>,
  ) {}

  async findById(id: string): Promise<UserDocument> {
    // 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<UserDocument | null> {
    // Case-insensitive email lookup
    return this.userModel.findOne({ 
      email: { $regex: new RegExp(`^${email}$`, 'i') } 
    }).exec();
  }

  async create(data: Partial<User>): Promise<UserDocument> {
    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.

analytics.service.tstypescript
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<OrderDocument>,
  ) {}

  async getRevenueByMonth(year: number): Promise<MonthlyRevenue[]> {
    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.

Ready to ace your Node.js / NestJS interviews?

Practice with our interactive simulators, flashcards, and technical tests.

Transactions for Multi-Document Operations

MongoDB 4.0+ supports multi-document ACID transactions. Use transactions when multiple documents must update atomically.

transfer.service.tstypescript
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<AccountDocument>,
  ) {}

  async transfer(
    fromId: string,
    toId: string,
    amount: number,
  ): Promise<void> {
    // 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.

product.schema.tstypescript
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument } from 'mongoose';

export type ProductDocument = HydratedDocument<Product>;

@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.

order.schema.tstypescript
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
import { HydratedDocument } from 'mongoose';

export type OrderDocument = HydratedDocument<Order>;

@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<void> {
  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<T> 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

Start practicing!

Test your knowledge with our interview simulators and technical tests.

Daily challenge

Can you spot the bug in Node.js / NestJS?

One real snippet, one hidden bug, one attempt a day. No account needed to try.

Anthony Fillion-Maillet

Written by

Anthony Fillion-Maillet

Founder of SharpSkill

Full-stack developer for over 10 years. Runs SharpSkill and answers for everything published here.

Updated on August 28, 2026

Tags

#nestjs
#mongodb
#mongoose
#nodejs
#backend
#interview

Share

Related articles