Module: MongoDB
MongoDB·145·8 MIN READ

145: Mongoose Deep Dive — Schemas, Models, Validation, Middleware, `lean()`, Populate, Transactions, Discriminators, and Concurrency

TOPICS COVERED: Mongoose Deep Dive — Schemas, Models, Validation, Middleware, `lean()`, Populate, Transactions, Discriminators, and Concurrency

Learning objectives

You will learn to:

  • understand what Mongoose adds over the native driver;
  • use modern Mongoose 9 patterns;
  • create schemas, models, documents, and subdocuments;
  • understand casting and validation;
  • use defaults, timestamps, getters, setters, virtuals, and methods carefully;
  • understand middleware order and side effects;
  • understand save() versus query updates;
  • use lean() appropriately;
  • use populate() without recreating relational over-normalization;
  • declare indexes correctly;
  • use sessions/transactions;
  • use discriminators;
  • understand optimistic concurrency;
  • avoid common Mongoose performance and correctness traps.

Current baseline

As of August 2026, Mongoose 9 is the current major line.

Install:

bash
npm install mongoose

Avoid old tutorials containing:

js
useNewUrlParser: true
useUnifiedTopology: true
useFindAndModify: false
useCreateIndex: true

These legacy connection/configuration options are from older Mongoose generations.

What Mongoose is

Mongoose is an ODM:

text
Object Document Mapper

It adds application modeling on top of MongoDB:

text
Schema
Model
Document
casting
validation
middleware
virtuals
populate
discriminators
plugins

Mongoose internally uses the MongoDB Node driver.

It does not replace MongoDB knowledge.

If you do not understand:

  • indexes;
  • aggregation;
  • transactions;
  • shard keys;
  • read/write concerns;
  • document modeling;

Mongoose cannot make those decisions for you.

Connect

js
import mongoose from 'mongoose';

await mongoose.connect(
  process.env.MONGODB_URI,
);

For a larger application, create connection/lifecycle explicitly rather than importing a module that auto-connects on import.

Shutdown:

js
await mongoose.disconnect();

or close specific connection.

Schema

js
const taskSchema = new mongoose.Schema(
  {
    tenantId: {
      type: mongoose.Schema.Types.ObjectId,
      required: true,
      index: true,
    },

    title: {
      type: String,
      required: true,
      trim: true,
      minlength: 3,
      maxlength: 80,
    },

    completed: {
      type: Boolean,
      default: false,
    },

    priority: {
      type: String,
      enum: [
        'low',
        'normal',
        'high',
      ],
      default: 'normal',
    },

    version: {
      type: Number,
      default: 1,
    },
  },
  {
    timestamps: true,
    strict: true,
  },
);

Schema is application-layer contract.

MongoDB collection can also have database validator.

SchemaTypes

Common:

text
String
Number
Date
Buffer
Boolean
ObjectId
Array
Decimal128
Map
Mixed
UUID in supported versions
BigInt in supported versions

Check current Mongoose docs for exact SchemaType support.

Choose types to match MongoDB semantics.

Casting

Mongoose may cast input:

js
Task.findOne({
  _id: '66d0...',
});

into ObjectId if valid.

Casting convenience can hide bad API validation.

A malformed external ID should be validated at HTTP boundary and return controlled 400/422, not rely on a CastError reaching global error middleware.

Validation

Built-in validators run on document save and selected operations according to API/options.

Example:

js
const task = new Task({
  title: 'x',
});

await task.save();
// validation error

Application can inspect validation errors.

But server/API should map to stable public error format.

Custom validator

js
title: {
  type: String,
  validate: {
    validator(value) {
      return !value.includes('\0');
    },
    message: 'Title contains invalid characters.',
  },
}

Do not put database/network I/O into every field validator.

Cross-document business validation belongs service/repository/transaction architecture.

Update validators

Mongoose update operations have different validation semantics from save().

Do not assume:

js
Model.updateOne(...)

runs every document validator/middleware exactly like:

js
document.save()

Use current options such as runValidators where appropriate, and understand which validators run on updated paths.

This difference is a common production bug.

Defaults

js
priority: {
  type: String,
  default: 'normal',
}

Default applies when value is undefined according to Mongoose semantics.

Do not expect default to replace explicit:

js
null

unless schema logic says so.

Timestamps

js
{
  timestamps: true
}

creates/maintains:

text
createdAt
updatedAt

Useful.

Do not update createdAt manually.

For business dates separate from persistence timestamps, define separate field.

Getters/setters

Setter:

js
email: {
  type: String,
  set(value) {
    return value.trim().toLowerCase();
  },
}

Normalization can be useful.

But email normalization is more nuanced than simple lowercase for all identity policies.

Do not hide high-impact business transformations in obscure setters.

Virtuals

js
taskSchema.virtual('isOpen').get(
  function () {
    return !this.completed;
  },
);

Virtual not stored in MongoDB.

Useful presentation/domain convenience.

Do not query/index a virtual as if persisted.

Instance methods

js
taskSchema.methods.canBeClosed = function () {
  return !this.completed;
};

Can be useful for document behavior.

Large business logic tightly coupled to Mongoose document can make service testing/migration harder.

Keep critical domain rules in explicit service/domain modules when architecture benefits.

Static methods

js
taskSchema.statics.findOpenForTenant =
  function (tenantId) {
    return this.find({
      tenantId,
      completed: false,
    });
  };

Can centralize query.

Repository abstraction may still be clearer in large systems.

Model

js
const Task = mongoose.model(
  'Task',
  taskSchema,
);

Model maps to collection.

Model compilation is global-ish per Mongoose connection.

In hot-reload/serverless development, model recompilation can produce errors if code repeatedly defines same model. Use framework-aware patterns.

Documents

js
const task = new Task({
  tenantId,
  title: 'Learn Mongoose',
});

await task.save();

Hydrated document includes:

  • getters/setters;
  • change tracking;
  • methods;
  • save;
  • virtuals.

That has memory/CPU overhead compared with plain BSON objects.

lean()

Read-only query:

js
const tasks = await Task
  .find({
    tenantId,
  })
  .lean();

lean() skips Mongoose document hydration and returns plain objects.

Benefits:

  • lower memory;
  • faster reads.

Trade-offs:

  • no document methods;
  • no save;
  • getters/virtual behavior differs unless specific plugins/options;
  • change tracking absent.

Use lean() for API list/read paths where hydrated document behavior is unnecessary.

Do not blindly use for code expecting methods/virtuals.

save()

js
const task = await Task.findOne({
  _id: taskId,
  tenantId,
});

if (!task) ...

task.completed = true;

await task.save();

Mongoose tracks changes and runs save validation/middleware.

Potential read-modify-write concurrency issue.

Use optimistic concurrency or atomic query updates where needed.

Query updates

js
await Task.updateOne(
  {
    _id: taskId,
    tenantId,
  },
  {
    $set: {
      completed: true,
    },
  },
  {
    runValidators: true,
  },
);

More direct/atomic.

But document save middleware does not automatically run for query update.

Mongoose has separate query middleware.

Choose based on invariants.

Middleware

Types include hooks around operations such as:

text
validate
save
find
findOneAndUpdate
deleteOne
aggregate

Example:

js
taskSchema.pre(
  'save',
  function () {
    this.title = this.title.trim();
  },
);

Middleware is powerful but can hide work.

Avoid middleware that:

  • sends external emails;
  • makes expensive remote calls;
  • changes unrelated collections silently;
  • creates retry-unfriendly side effects.

A developer calling:

js
await task.save();

should not unknowingly charge payment.

Middleware order

Validation/save hook ordering matters.

Plugins can add hooks.

When debugging surprising behavior, inspect all schema middleware/plugins.

Do not treat Mongoose lifecycle as magic.

Error middleware

Mongoose has error-handling middleware patterns.

Still map DB errors at repository/service boundary.

Duplicate key from MongoDB is not a Mongoose validation error.

Catch Mongo duplicate key code and map to conflict.

Unique is not validator

This:

js
email: {
  type: String,
  unique: true,
}

declares index intention; it is not a per-document validator guaranteeing race-free precheck.

Database unique index enforces uniqueness.

Handle duplicate-key error.

Indexes

Schema:

js
taskSchema.index({
  tenantId: 1,
  completed: 1,
  createdAt: -1,
});

Production recommendation:

  • define indexes in code/migration plan;
  • avoid uncontrolled automatic index builds at application startup on huge production collection.

Mongoose autoIndex behavior should be deliberately configured for production.

Use an explicit deployment/index management process.

Subdocuments

js
const itemSchema = new mongoose.Schema(
  {
    sku: String,
    quantity: Number,
  },
  {
    _id: false,
  },
);

const orderSchema = new mongoose.Schema({
  items: [itemSchema],
});

Subdocuments have Mongoose behavior/middleware.

Understand difference between nested path and subdocument.

Do not create subdocument _id when no need.

Arrays

Mongoose tracks arrays.

Unbounded arrays remain a Mongo modeling anti-pattern.

ODM convenience does not change BSON document size or index cost.

Mixed

js
mongoose.Schema.Types.Mixed

allows arbitrary shape.

Useful in carefully scoped polymorphic metadata.

Risk:

  • no strong casting/validation;
  • change tracking nuances;
  • schema becomes opaque.

Do not make every field Mixed to avoid schema design.

Map

For dynamic key-value structure with consistent value type:

js
settings: {
  type: Map,
  of: String,
}

Dynamic field names can be hard to query/index.

Use only if access pattern fits.

Populate

Schema:

js
authorId: {
  type: ObjectId,
  ref: 'User',
}

Query:

js
const post = await Post
  .findById(id)
  .populate('authorId');

Populate performs additional query/join-like ODM work.

It is not embedding.

Do not normalize everything into references because populate is convenient.

Mongo data modeling lessons still apply.

Populate performance

Potential problems:

  • many populations;
  • deep nested populate;
  • large result sets;
  • huge fields;
  • repeated query work.

Use:

  • projection/select;
  • lean;
  • explicit aggregation $lookup when suitable;
  • embedding/denormalization;
  • batch query.

Measure.

Autopopulate caution

Plugins that automatically populate every query hide I/O.

This can turn:

js
find()

into many expensive operations unexpectedly.

Prefer explicit populate for critical paths.

Discriminators

Use polymorphic documents sharing one collection.

Base:

js
const eventSchema = new Schema({
  tenantId: ObjectId,
  occurredAt: Date,
}, {
  discriminatorKey: 'type',
});

const Event = model(
  'Event',
  eventSchema,
);

Subtype:

js
const PaymentEvent = Event.discriminator(
  'payment',
  new Schema({
    amountPaise: Number,
  }),
);

Good for related variants.

Do not put completely unrelated domains in one discriminator collection.

Transactions

Mongoose uses MongoDB sessions.

js
await mongoose.connection.transaction(
  async (session) => {
    await Order.updateOne(
      {
        _id: orderId,
      },
      {
        $set: {
          status: 'paid',
        },
      },
      {
        session,
      },
    );

    await Payment.create(
      [
        {
          orderId,
          amountPaise,
        },
      ],
      {
        session,
      },
    );
  },
);

Use current Mongoose transaction helper.

Do not parallelize unrelated operations inside same transaction casually.

Do not call external side effects inside retryable transaction callback.

Session propagation

Queries/documents need correct session.

Mongoose can associate sessions with documents in some flows.

Be explicit in repository/service architecture.

Missing session on one write means it is outside transaction.

Optimistic concurrency

Mongoose supports schema option:

js
const schema = new Schema(
  {...},
  {
    optimisticConcurrency: true,
  },
);

Mongoose uses version key to detect stale save().

Default version key is commonly:

text
__v

You can configure it.

This protects read-modify-save workflows.

Understand difference from your own domain version field/API ETag.

VersionKey

Do not simply disable:

js
versionKey: false

because it looks ugly before understanding how versioning/concurrency may use it.

If API should not expose __v, transform output rather than necessarily removing concurrency metadata.

findOneAndUpdate versus save

findOneAndUpdate is atomic at query operation level and useful for direct updates.

But:

  • save middleware differs;
  • document validation differs;
  • return semantics differ.

Use the right tool.

Serialization

Customize toJSON/toObject carefully:

js
taskSchema.set(
  'toJSON',
  {
    transform(doc, ret) {
      ret.id = ret._id.toString();
      delete ret._id;
      delete ret.__v;
      return ret;
    },
  },
);

Do not serialize secrets/internal fields.

Do not mutate database representation accidentally.

Query casting security

Mongoose can cast query values, but does not mean raw client filter is safe.

Never:

js
Task.find(req.query);

Build allowlisted filter.

Mongoose ODM does not remove NoSQL injection/query-cost risks.

Strict query behavior

Mongoose has strict query options/settings that evolve by major version.

Do not rely on defaults for security.

Validate at HTTP boundary and construct known filter.

Plugins

Plugins can add:

  • soft delete;
  • pagination;
  • auditing;
  • autopopulate.

Every plugin can:

  • add middleware;
  • mutate queries;
  • add dependencies.

Review plugin source/maintenance.

Do not install a plugin for five lines of code without understanding impact.

Repository with Mongoose

js
export function createTaskRepository({
  Task,
}) {
  return {
    async findById({
      tenantId,
      taskId,
    }) {
      return Task
        .findOne({
          _id: taskId,
          tenantId,
        })
        .lean();
    },

    async create(input) {
      const document = await Task.create(
        input,
      );

      return document.toObject();
    },
  };
}

Service still should not import Express.

Testing

Test:

  • casting;
  • validation;
  • unique index integration;
  • middleware;
  • lean paths;
  • populate;
  • transaction;
  • optimistic concurrency.

Use real Mongo instance for repository integration.

Mocking Model methods alone does not prove indexes/query semantics.

Failure clinic

unique: true treated as validator

Race bug.

lean() then calling .save()

Plain object has no document API.

every query autopopulates

Performance surprise.

query update assumed save middleware

Invariant missed.

raw req.query into Model.find

Injection/query abuse.

autoIndex builds huge indexes at every production startup

Operational problem.

business side effects in pre-save middleware

Hidden retries/coupling.

Mongoose schema used as only database validation when multiple writers exist

Other writers can bypass.

Exercises

  1. Build Task schema with validation/timestamps.
  2. Compare hydrated document memory/behavior versus lean result.
  3. Write save and updateOne variants and compare hooks.
  4. Add compound unique index and handle duplicate key.
  5. Add subdocument items.
  6. Use populate, then remodel same access pattern with embedding and compare.
  7. Create discriminator collection.
  8. Enable optimistic concurrency and reproduce stale save.
  9. Execute transaction with session.
  10. Disable auto index in production plan and create index deployment script.
  11. Audit a plugin before installation.

Mastery checklist

Explain:

  • ODM/native driver difference;
  • schemas/models/documents;
  • casting/validation;
  • save versus query update;
  • middleware;
  • indexes/unique;
  • lean;
  • populate;
  • subdocuments;
  • discriminators;
  • transactions;
  • optimistic concurrency;
  • plugin/autoIndex risks.

Official references