MongoDB + Mongoose
Pathrule2 Rules • 3 Memories • 1 Skill
MongoDB punishes a schema copied from a relational design and rewards one built around access patterns. This bundle carries the decisions that actually determine whether a collection scales: embed or reference, which indexes exist and who creates them, why reads should come back as plain objects, and how to use transactions without turning every write into one. It also covers the connection lifecycle that breaks first in serverless, where a new pool per invocation exhausts the cluster.
Suggested path map
Pathrule places each piece on the matching path, so your assistant only sees it where it belongs. This is the scoping you get on import; you can adjust it in your workspace.
Rules
2Declare an index for every query shape you ship/src/modelshighstrictEach filter, sort, and uniqueness requirement has a matching index declared in the schema and built deliberately, with autoIndex off in production.
| 1 | MongoDB will happily scan a collection forever. The query works in development on a thousand documents and takes seconds on a million. |
| 2 | |
| 3 | - Every field combination you filter or sort on needs a compound index in the right order: equality fields first, then the sort field, then range fields. An index on the wrong order does not serve the query. |
| 4 | - Declare indexes in the schema (or a migration) and turn `autoIndex` off in production. Automatic index builds on startup are a surprise write load on a live cluster, and on a large collection they block. |
| 5 | - Back every uniqueness rule with a unique index. A Mongoose `unique: true` is only an index hint, and a validator alone loses the race between two concurrent writes. |
| 6 | - Use partial or sparse indexes for fields present on a subset of documents, and TTL indexes for data that should expire, rather than a cron job that deletes rows. |
| 7 | - Verify with `explain("executionStats")` that a new query uses an index (`IXSCAN`, not `COLLSCAN`) and that the examined-to-returned ratio is near one. Do not add indexes by feel: each one costs write throughput and memory. |
Read lean with projections, write with targeted operators/src/dbhighstrictRead-only queries use lean and a projection, updates use atomic operators instead of read-modify-write, and every list query is bounded.
| 1 | Mongoose hydrates every result into a full document with getters, virtuals, and change tracking. For a read-only endpoint that is pure overhead. |
| 2 | |
| 3 | - Add `.lean()` to any query whose result is serialized and returned. It returns plain objects and is dramatically cheaper on large result sets. Keep hydration for paths that call document methods or save. |
| 4 | - Project the fields you need (`.select("name email")`). Returning whole documents is how a password hash or an internal flag reaches a client. |
| 5 | - Update with atomic operators (`$set`, `$inc`, `$push` with `$slice`, `findOneAndUpdate`) rather than loading a document, mutating it, and saving. Read-modify-write loses concurrent updates. |
| 6 | - Bound every list: a filter plus a limit, paginated by an indexed cursor field (`_id` or a timestamp) rather than a growing `skip`. |
| 7 | - Never use `$where` or pass raw user input into a query object without validating its shape; an object where a string is expected turns into an operator injection. |
Memories
3Embed or reference: decide from the read pattern/src/modelsEmbed data read together with its parent and bounded in size; reference data that grows without limit, is shared, or is queried on its own.
| 1 | This is the only schema decision that really matters in MongoDB, and it is a query decision, not a modelling-purity decision. |
| 2 | |
| 3 | - Embed when the child is always read with the parent, is bounded in count, and changes with it (an address on a user, line items on an order). One read, no join. |
| 4 | - Reference when the set is unbounded (events, messages, audit entries), when it is shared by many parents, or when it is queried independently. Then the child collection carries the parent id and its own indexes. |
| 5 | - Never let an array grow without bound inside a document. Documents have a hard size limit, and long arrays make every update rewrite the document and every index entry. |
| 6 | - Duplicating a few fields for read speed (a denormalised author name next to a post) is normal here, as long as you decide who updates the copy and accept that it is eventually consistent. |
| 7 | - `$lookup` exists but is not a join engine: on a hot path, prefer a shape that answers the query in one collection, or fetch by ids in a second query and join in memory. |
| 8 | |
| 9 | See /src/models for the index rule and /src/db for the query rules that assume this shape. |
Transactions are the exception, single-document atomicity is the rule/src/dbA write to one document is already atomic; multi-document transactions must stay short, be retried on transient errors, and never be the default path.
| 1 | MongoDB supports multi-document ACID transactions, and reaching for them constantly is a sign the schema is fighting the database. |
| 2 | |
| 3 | - A single document update is atomic on its own, including nested fields and arrays. Shape the document so the invariant you care about lives in one document, and you need no transaction at all. |
| 4 | - When you do need one (money moving between two accounts, a two-collection invariant), keep it tiny: no network calls, no user input waiting, ideally sub-second. The server aborts long-running transactions, and open transactions hold resources and block other writers. |
| 5 | - Wrap transactions in retry logic for `TransientTransactionError` and `UnknownTransactionCommitResult`. A transaction that is not retried is a transaction that fails under load. |
| 6 | - Transactions require a replica set (or sharded cluster). Code that assumes them will fail against a standalone development instance, so run a single-node replica set locally. |
| 7 | - Set read and write concern deliberately: `majority` for anything you must not lose or read stale, weaker only where you have decided the trade-off. |
| 8 | |
| 9 | See /src/models for the embedding decision that removes most transactions. |
Connection lifecycle, especially in serverless/src/dbConnect once per process and cache the connection across invocations; pool size, not request count, is what the cluster sees.
| 1 | The driver manages a connection pool. Most MongoDB outages in serverless apps are self-inflicted pool exhaustion. |
| 2 | |
| 3 | - Call `mongoose.connect` (or create the client) once per process, in a module that exports the promise, and await that promise in handlers. Never connect per request. |
| 4 | - In serverless, cache the connection on `globalThis` so warm invocations reuse it, and keep `maxPoolSize` small (a handful of sockets per instance): concurrency there is the number of instances, not the number of requests. Never call `disconnect` at the end of a handler. |
| 5 | - Set sensible timeouts (`serverSelectionTimeoutMS`, `socketTimeoutMS`) so a network problem fails fast instead of hanging every request behind a full pool. |
| 6 | - Handle connection events for observability (`connected`, `error`, `disconnected`) and expose pool metrics; a rising checkout wait time is the earliest signal of trouble. |
| 7 | - Register models once. Re-registering the same model name on hot reload throws, which is why the connection module and the model registry belong together. |
| 8 | |
| 9 | See /src/db for the query rules that run on this pool, and the redis-caching pattern if reads need a cache in front. |
Skills
1mongodb-query-review/rootChecklist for any change that adds a collection, query, or index in a MongoDB service.
| 1 | --- |
| 2 | name: mongodb-query-review |
| 3 | description: MongoDB and Mongoose review checklist covering index coverage, lean reads, document growth, transaction scope, and connection reuse. Run before merging a schema or query change. |
| 4 | --- |
| 5 | |
| 6 | # MongoDB review |
| 7 | |
| 8 | ## Indexes |
| 9 | - [ ] Every new filter and sort is covered by an index, in equality, sort, range order. |
| 10 | - [ ] `explain("executionStats")` shows an index scan and a sane examined-to-returned ratio. |
| 11 | - [ ] Uniqueness is enforced by a unique index, not only a validator. |
| 12 | - [ ] `autoIndex` is off in production; the index is built deliberately. |
| 13 | |
| 14 | ## Queries |
| 15 | - [ ] Read-only queries use `.lean()` and a projection. |
| 16 | - [ ] Updates use atomic operators, not read-modify-write. |
| 17 | - [ ] Every list is bounded and paginated on an indexed cursor field. |
| 18 | - [ ] No `$where`; user input cannot become an operator. |
| 19 | |
| 20 | ## Document design |
| 21 | - [ ] Embedded data is read with its parent and bounded in size. |
| 22 | - [ ] No array can grow without limit. |
| 23 | - [ ] Denormalised copies have a named owner that updates them. |
| 24 | |
| 25 | ## Transactions and connections |
| 26 | - [ ] The invariant genuinely spans documents; otherwise no transaction. |
| 27 | - [ ] Transactions are short, contain no network calls, and retry on transient errors. |
| 28 | - [ ] The connection is created once per process and reused across invocations. |
Why this pattern
AI agents translate a relational schema into collections, add a reference for every relationship, never declare an index, and hydrate full documents for read-only endpoints.
Built for Node and TypeScript teams running MongoDB with Mongoose.
Keeps your assistant from:
- Designing collections like normalized tables, then joining them with lookups on every read
- Growing an unbounded array inside a document until it approaches the size limit
- Relying on autoIndex in production instead of declaring and building indexes deliberately
- Opening a new connection pool per serverless invocation
- License
- Apache-2.0
- Version
- 1.0.0
- Updated
- 2026-08-24