Drivers and ODMs in application code

Wire the official driver correctly with a single client per process, know what an ODM adds and hides, and handle the errors that actually occur in production.

The official driver

import { MongoClient } from "mongodb";

const client = new MongoClient(process.env.MONGODB_URI, {
  maxPoolSize: 20,                 // per process, not per request
  minPoolSize: 2,
  maxIdleTimeMS: 60000,
  serverSelectionTimeoutMS: 5000,
  writeConcern: { w: "majority" },
});

await client.connect();

const orders = client.db("shop").collection("orders");
const doc = await orders.findOne({ _id: id }, { projection: { total: 1, status: 1 } });
  • Create one MongoClient per process and share it. It owns the connection pool and the topology monitor, so a client per request throws away pooling and multiplies heartbeat traffic.
  • The driver retries retryable reads and single-statement writes once after a network error. A multi-statement sequence is your responsibility, not the driver's.
  • The driver validates nothing about field types. If you want a runtime guarantee, use a validator on the collection or a schema layer in the application.
  • serverSelectionTimeoutMS is what stops a request from hanging forever when no member is reachable; set it and handle the error.

ODMs and schema layers

const orderSchema = new Schema({
  customer:  { type: Schema.Types.ObjectId, ref: "Customer", required: true },
  status:    { type: String, enum: ["new", "paid", "shipped"], default: "new" },
  total:     { type: Schema.Types.Decimal128, min: 0 },
  placedAt:  { type: Date, default: Date.now },
}, { timestamps: true, strict: "throw" });

orderSchema.index({ customer: 1, placedAt: -1 });

const Order = model("Order", orderSchema);
const doc = await Order.findById(id).lean();     // plain object, not hydrated
Reach for the driverReach for an ODM
You need exact control over the query sentYou want casting and defaults for free
Aggregation pipelines and bulk operationsYou want middleware for validation and hooks
Jobs, scripts and data pipelinesA team that benefits from one model layer
Latency-sensitive read pathsRapid prototyping
💡
An ODM schema is enforced by one application, not by the database. A second service, a migration script or an aggregation writing with $merge can store any shape it likes. Add a $jsonSchema validator to the collection when the shape really has to hold.

Errors, retries and shutdown

try {
  await orders.updateOne(
    { _id: id, version: 3 },
    { $set: { status: "shipped" }, $inc: { version: 1 } }
  );
} catch (err) {
  if (err.code === 112) return retryOptimistic();          // WriteConflict
  if (err.code === 11000) return handleDuplicate(err);     // duplicate key
  if (err.hasErrorLabel("RetryableWriteError")) return retry();
  throw err;
}

process.on("SIGTERM", async () => {
  await client.close();
  process.exit(0);
});
  • A duplicate key error (11000) is often a deliberate signal: a unique index enforcing idempotency on a natural key.
  • A write conflict inside a transaction is transient. Retry the entire transaction, not the individual statement that failed.
  • Optimistic concurrency with a version field avoids holding a lock between the read and the write, and turns a lost update into a detectable conflict.
  • Close the client on shutdown so the driver can stop its monitoring threads and let in-flight operations finish.

FAQ

Should I use an ODM from the start?
For a product with a stable domain model and a team that values guard rails, yes. For a pipeline, a script or any path where the exact query shape matters, the driver is less to learn and less to work around.
How should a request handle a connection failure?
Set serverSelectionTimeoutMS so the request fails quickly instead of hanging, retry only operations the driver marks retryable, and return an error the caller can act on rather than swallowing it and returning an empty result.

Transactions, sessions and read/write concerns Data modelling: embedding vs referencing and schema validation

Last refreshed 2026-09-18.