| name | api-database-mongodb |
| description | MongoDB with Mongoose ODM - schemas, models, queries, aggregation, indexes, TypeScript typing, connection management |
MongoDB / Mongoose Patterns
Quick Guide: Use Mongoose as the ODM for MongoDB. Define schemas with automatic TypeScript inference, use lean() for read-only queries, prefer embedding over referencing for co-accessed data, place $match early in aggregation pipelines, and always define indexes to match your query patterns.
<critical_requirements>
CRITICAL: Before Using This Skill
All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering, import type, named constants)
(You MUST define Mongoose middleware (pre/post hooks) BEFORE calling model() -- hooks registered after model compilation are silently ignored)
(You MUST pass { session } to EVERY operation inside a transaction -- missing session causes operations to run outside the transaction)
(You MUST use .lean() for read-only queries that send results directly to API responses -- skipping lean wastes 3x memory on hydration overhead)
(You MUST use 127.0.0.1 instead of localhost in connection strings -- Node.js 18+ prefers IPv6 and localhost can cause connection timeouts)
(You MUST NOT use findOneAndUpdate / updateOne and expect save middleware to fire -- only save() and create() trigger document middleware)
</critical_requirements>
Auto-detection: MongoDB, Mongoose, mongoose.connect, Schema, model, ObjectId, populate, aggregate, $match, $group, $lookup, lean, HydratedDocument, InferSchemaType, MongoClient, Atlas
When to use:
- Defining MongoDB schemas and models with Mongoose
- Building CRUD operations and complex queries
- Designing aggregation pipelines for analytics and reporting
- Managing indexes for query performance
- Connecting to MongoDB Atlas or local instances
- Modeling document relationships (embedding vs referencing)
Key patterns covered:
- Connection setup (Atlas URI, pooling, error handling)
- Schema definition (types, validation, defaults, enums)
- Models with TypeScript (automatic inference, methods, statics, virtuals)
- CRUD operations (create, find, update, delete, lean)
- Query building (filters, projection, sort, limit, populate)
When NOT to use:
- Relational data with complex joins and foreign key constraints (use a relational database)
- ACID transactions across many collections as a core pattern (use a relational database)
- Simple key-value storage (use a dedicated key-value store)
Detailed Resources:
Core Patterns:
- examples/core.md - Connection, schema definition, model creation, TypeScript typing
Query Patterns:
Aggregation:
Advanced Patterns:
- examples/patterns.md - Schema design (embedding vs referencing), transactions, middleware hooks, virtuals
Indexing:
Philosophy
MongoDB is a document database. Mongoose provides schema-based modeling on top of it. The core principle: data that is accessed together should be stored together.
Core principles:
- Schema-first design -- Define schemas before models. Schemas enforce structure, validation, and defaults at the application layer.
- Embed by default -- Co-accessed data belongs in the same document. Only reference when data is shared across many documents, grows unbounded, or is frequently updated independently.
- Lean for reads -- Use
.lean() for read-only queries. It returns plain objects (3x less memory) instead of full Mongoose documents.
- Index your queries -- Every query pattern needs a supporting index. Compound indexes follow the Equality-Sort-Range (ESR) rule.
- Aggregation over application logic -- Push data transformation to the database with aggregation pipelines instead of processing in application code.
- TypeScript inference -- Let Mongoose infer types from schema definitions. Avoid manually duplicating interfaces unless you need methods/statics/virtuals.
When to use MongoDB / Mongoose:
- Document-oriented data (user profiles, product catalogs, content)
- Flexible schemas that evolve over time
- Hierarchical or nested data structures
- High read throughput with embedding
- Geospatial queries and full-text search
When NOT to use:
- Highly relational data with complex joins (use a relational database)
- Strong ACID guarantees across many collections as a primary pattern
- Fixed schemas where relational constraints are critical
- Time-series data at scale (use a dedicated time-series database)
Core Patterns
Pattern 1: Connection Setup
Establish a single connection at application startup. Use environment variables for credentials.
import mongoose from "mongoose";
const POOL_SIZE_MAX = 10;
const POOL_SIZE_MIN = 2;
const SERVER_SELECTION_TIMEOUT_MS = 5000;
const SOCKET_TIMEOUT_MS = 45000;
async function connectDatabase(): Promise<typeof mongoose> {
const uri = process.env.MONGODB_URI;
if (!uri) {
throw new Error("MONGODB_URI environment variable is required");
}
const connection = await mongoose.connect(uri, {
maxPoolSize: POOL_SIZE_MAX,
minPoolSize: POOL_SIZE_MIN,
serverSelectionTimeoutMS: SERVER_SELECTION_TIMEOUT_MS,
socketTimeoutMS: SOCKET_TIMEOUT_MS,
retryWrites: true,
retryReads: true,
});
return connection;
}
export { connectDatabase };
Why good: Named constants for all numeric values, environment variable for URI, typed return, error on missing URI
mongoose.connect("mongodb://localhost:27017/mydb");
Why bad: Hardcoded connection string leaks credentials, localhost fails on Node.js 18+ (IPv6), no pool or timeout configuration, no error handling
Connection Events
mongoose.connection.on("connected", () => {
console.log("MongoDB connected");
});
mongoose.connection.on("error", (err) => {
console.error("MongoDB connection error:", err);
});
mongoose.connection.on("disconnected", () => {
console.log("MongoDB disconnected");
});
Pattern 2: Schema Definition with TypeScript
Let Mongoose infer types from the schema definition. Use explicit interfaces only when adding methods, statics, or virtuals.
Automatic Type Inference (Preferred)
import { Schema, model } from "mongoose";
const userSchema = new Schema(
{
name: { type: String, required: true, trim: true },
email: { type: String, required: true, unique: true, lowercase: true },
role: {
type: String,
enum: ["admin", "user", "moderator"] as const,
default: "user",
},
age: { type: Number, min: 0, max: 150 },
isActive: { type: Boolean, default: true },
tags: [{ type: String }],
metadata: { type: Schema.Types.Mixed },
},
{ timestamps: true },
);
= (, userSchema);
{ , userSchema };
Why good: TypeScript infers document types from schema, as const preserves enum literal types, timestamps added via schema option, named exports
Explicit Interface (For Methods/Statics/Virtuals)
import {
Schema,
model,
type HydratedDocument,
type Model,
type Types,
} from "mongoose";
interface IUser {
name: string;
email: string;
role: "admin" | "user" | "moderator";
organizationId: Types.ObjectId;
}
interface IUserMethods {
isAdmin(): boolean;
}
interface IUserVirtuals {
displayName: string;
}
type UserModel = Model<IUser, {}, IUserMethods, IUserVirtuals>;
const userSchema = new Schema<
IUser,
UserModel,
IUserMethods,
{},
IUserVirtuals
>({
name: { type: String, required: true },
email: { type: String, required: , : },
: { : , : [, , ], : },
: {
: ..,
: ,
: ,
},
});
userSchema.. = () {
. === ;
};
userSchema.().( () {
;
});
= model<, >(, userSchema);
{ , userSchema };
{ , };
= <
,
&
>;
Why good: Separate interfaces for document shape, methods, and virtuals; generic parameters correctly ordered; HydratedDocument type exported for consumers
interface IUser {
name: string;
email: string;
}
const userSchema = new Schema({ name: String, email: String });
Why bad: Manual interface duplicates schema definition, interface and schema can drift out of sync, no validation constraints in schema
Pattern 3: CRUD Operations
Create
const user = await User.create({
name: "Alice",
email: "alice@example.com",
role: "admin",
});
const BATCH_SIZE = 1000;
const users = generateUsers(BATCH_SIZE);
await User.insertMany(users, { ordered: false });
Read with Lean
const user = await User.findById(id).lean();
const PAGE_SIZE = 20;
const activeAdmins = await User.find({ role: "admin", isActive: true })
.select("name email role")
.sort({ name: 1 })
.limit(PAGE_SIZE)
.lean();
Why good: .lean() for read-only responses, .select() for projection, named constant for page size
Update
const user = await User.findById(id);
if (!user) {
throw new Error(`User not found: ${id}`);
}
user.name = "Updated Name";
await user.save();
await User.findByIdAndUpdate(
id,
{ $set: { name: "Updated" } },
{ new: true, runValidators: true },
);
await User.updateMany(
{ isActive: false },
{ $set: { archivedAt: new Date() } },
);
Why good: save() triggers middleware, findByIdAndUpdate with runValidators: true enforces schema validation, { new: true } returns updated document
Delete
await User.findByIdAndDelete(id);
await User.deleteMany({ isActive: false, archivedAt: { $lt: cutoffDate } });
const user = await User.findById(id);
user.deleteOne();
Why bad: No null check before calling method on potentially null result, crashes at runtime if document not found
Pattern 4: Query Building
Filters and Operators
const MIN_AGE = 18;
const MAX_AGE = 65;
const users = await User.find({
age: { $gte: MIN_AGE, $lte: MAX_AGE },
role: { $in: ["admin", "moderator"] },
email: { $regex: /@company\.com$/i },
}).lean();
const results = await User.find({
$or: [{ role: "admin" }, { isActive: true, age: { $gte: MIN_AGE } }],
}).lean();
Populate (Reference Resolution)
const post = await Post.findById(id)
.populate("author", "name email")
.populate({
path: "comments",
populate: { path: "author", select: "name" },
options: { sort: { createdAt: -1 }, limit: 10 },
})
.lean();
Why good: Field selection on populate reduces data transfer, nested populate for deep references, sort and limit on populated array
const post = await Post.findById(id)
.populate("author")
.populate("comments");
Why bad: No field selection wastes bandwidth and memory, unbounded populate can return thousands of documents, each populate is a separate database query
Pattern 5: Schema Validation
const productSchema = new Schema({
name: {
type: String,
required: [true, "Product name is required"],
minlength: [2, "Name must be at least 2 characters"],
maxlength: [100, "Name must be at most 100 characters"],
},
price: {
type: Number,
required: true,
min: [0, "Price cannot be negative"],
validate: {
validator: (v: number) => Number.isFinite(v),
message: "Price must be a finite number",
},
},
sku: {
type: String,
required: true,
unique: true,
match: [/^[A-Z]{2}-\d{6}$/, "SKU must match format XX-000000"],
},
status: {
type: String,
enum: {
values: ["draft", "active", "archived"] as const,
: ,
},
: ,
},
: {
: [
{
: { : , : },
: { : , : },
: { : , : , : },
},
],
: {
: v. > ,
: ,
},
},
});
{ productSchema };
Why good: Custom error messages for every validator, regex validation with descriptive message, array-level validation, enum with as const for TypeScript inference, subdocument schema inline
<red_flags>
RED FLAGS
High Priority Issues:
- Mutating a document fetched with
.lean() and expecting .save() to work -- lean returns plain objects without Mongoose methods
- Registering middleware after
model() call -- hooks are silently ignored
- Running operations in parallel inside a transaction (
Promise.all()) -- MongoDB does not support parallel operations within a single transaction
- Using
localhost in connection strings on Node.js 18+ -- IPv6 preference causes connection timeouts, use 127.0.0.1
- Missing
{ session } on any operation inside a transaction -- that operation runs outside the transaction
Medium Priority Issues:
- Using
findOneAndUpdate / updateOne and expecting pre('save') hooks to fire -- only save() and create() trigger document middleware
- Unbounded
.populate() without limit or field selection -- can return thousands of documents per query
- Not calling
runValidators: true on findOneAndUpdate -- schema validation is skipped by default on updates
- Creating indexes in production code instead of migration scripts -- index builds lock the collection
- Using
$where or JavaScript expressions in queries -- disables indexes and enables injection
Common Mistakes:
- Forgetting
{ new: true } on findOneAndUpdate -- returns the old document by default
- Using
Schema.Types.ObjectId in TypeScript interfaces instead of Types.ObjectId -- Schema.Types.ObjectId is for schema definitions, Types.ObjectId is for interfaces
- Not handling duplicate key errors (code 11000) from unique indexes
- Calling
.lean() on write operations -- lean is for reads only
- Checking
doc.isNew in post('save') hooks -- always false after save, use this.$locals.wasNew set in a pre('save') hook
Gotchas & Edge Cases:
- MongoDB has a 16 MB document size limit -- deeply embedded arrays can hit this
- Mongoose buffers operations before connection is established -- queries queue silently if connection fails
deleteOne / deleteMany do not trigger pre('remove') middleware -- use findOneAndDelete or document .deleteOne() if you need middleware
- Virtual properties are excluded from
toJSON() / toObject() by default -- set { toJSON: { virtuals: true } } in schema options
remove() was completely removed in Mongoose 7+ -- use deleteOne() or deleteMany() instead
- Mongoose 9 dropped callback-based
next() in pre hooks -- use async/await instead
- Mongoose 9 renamed
FilterQuery to QueryFilter -- update TypeScript imports if upgrading
- Mongoose 9 requires
updatePipeline: true for pipeline-style updates -- they throw by default
- Mongoose 9 removed the
background index option -- MongoDB 4.2+ builds all indexes in the background by default
</red_flags>
<critical_reminders>
CRITICAL REMINDERS
All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering, import type, named constants)
(You MUST define Mongoose middleware (pre/post hooks) BEFORE calling model() -- hooks registered after model compilation are silently ignored)
(You MUST pass { session } to EVERY operation inside a transaction -- missing session causes operations to run outside the transaction)
(You MUST use .lean() for read-only queries that send results directly to API responses -- skipping lean wastes 3x memory on hydration overhead)
(You MUST use 127.0.0.1 instead of localhost in connection strings -- Node.js 18+ prefers IPv6 and localhost can cause connection timeouts)
(You MUST NOT use findOneAndUpdate / updateOne and expect save middleware to fire -- only save() and create() trigger document middleware)
Failure to follow these rules will cause silent data corruption, middleware bypass, or transaction isolation failures.
</critical_reminders>