← Blog
mongodbnode.jstypescriptengineering

MongoDB Without Mongoose

5 March 2026

MongoDB Without Mongoose

If you search for a Node.js + MongoDB tutorial, you'll find Mongoose. Almost every guide, every boilerplate, every "getting started" article reaches for it automatically. It's become so standard that using the native driver feels like a contrarian choice that needs explaining.

I stopped using Mongoose. Not out of contrarianism — out of a gradual realisation that most of what it provides, I either don't need or can handle better without it.

What Mongoose actually does

To be fair to Mongoose, it does real things. It gives you schemas with validation, middleware hooks (pre/post save), virtuals, a query builder with chainable syntax, and populate() for joining documents across collections.

That's a reasonable feature set. The question is whether you need those features, and what you pay for them.

The TypeScript argument

The main selling point of Mongoose schemas — enforced structure on your documents — became significantly less compelling the moment I started writing TypeScript.

With TypeScript interfaces or types, I already have compile-time guarantees about the shape of my data. Adding a Mongoose schema on top duplicates that effort: I define the same structure twice, in two different syntaxes, and keep them in sync manually.

In a TypeScript project, Mongoose's schema validation is solving a problem that TypeScript already solves at the language level. You're paying for the middleware, the query builder, and the runtime validation — and the last one is the weakest reason of the three.

The aggregation problem

Here's where the abstraction really breaks down. Mongoose's query builder is comfortable for simple CRUD:

await Invoice.find({ status: "delivered" }).sort({ date: -1 }).limit(10);

Clean. Readable. Fine.

But real applications don't stay at simple CRUD. The moment you need an aggregation pipeline — grouping, projections, lookups, computed fields — you're writing raw MongoDB anyway:

await Invoice.aggregate([
  { $match: { status: "delivered" } },
  { $group: { _id: "$clientId", total: { $sum: "$amount" } } },
  { $sort: { total: -1 } },
]);

At that point you're inside MongoDB's query language directly, and Mongoose is just a wrapper you're passing through. The abstraction has leaked. You're back to needing to understand MongoDB deeply — which you should have been doing from the start.

What the native driver looks like

With the native MongoDB driver, the same queries look like this:

const db = client.db("mydb");
const invoices = db.collection<Invoice>("invoices");

await invoices.find({ status: "delivered" }).sort({ date: -1 }).limit(10).toArray();

await invoices.aggregate([
  { $match: { status: "delivered" } },
  { $group: { _id: "$clientId", total: { $sum: "$amount" } } },
  { $sort: { total: -1 } },
]).toArray();

There's no magic here. The collection is typed with your TypeScript interface. The queries are identical to what you'd write in Mongoose's aggregate call. The difference is: nothing is hidden.

What you actually give up

I want to be honest about the tradeoffs, because this isn't a free lunch.

populate() — Mongoose's document-level join is genuinely convenient. With the native driver, you either run multiple queries and join in application code, or you write a $lookup stage in an aggregation pipeline. Neither is as ergonomic as populate(). For applications that do a lot of relational-style queries across collections, this is a real cost.

Middleware hookspre('save') and post('save') hooks are useful for cross-cutting concerns like updating timestamps, triggering side effects, or hashing passwords before persistence. Without them, you handle this in your service layer explicitly. That's more code, but also more visible code — the logic doesn't disappear into a schema definition.

Schema-level validation — Mongoose can reject malformed documents at the ORM level. With the native driver you validate in your application logic (or use MongoDB's built-in JSON Schema validation at the collection level, which is underused but powerful). The validation still happens — it's just yours to own.

How I handle it in practice

In the accounting system I built for Honeyside, I have TypeScript interfaces that define every document shape. Collection access goes through a single db.ts module that initialises the connection and creates indexes on startup. Validation happens in a dedicated validators layer before anything reaches the database.

It's more code than the Mongoose equivalent would be. It's also code I fully understand, code that has no hidden behavior, and code that I can reason about without reading Mongoose's documentation.

When I'd still use Mongoose

Mongoose is a good choice when:

  • You're working in JavaScript without TypeScript and need schema enforcement at runtime
  • Your team is junior and benefits from the guardrails and conventions it provides
  • Your data model is heavily relational and you'd use populate() constantly
  • You're prototyping quickly and want the scaffolding

None of those apply to how I work. Your context might be different.

The real point

The default is Mongoose because Mongoose came first, tutorials use it, and inertia is powerful. That's a fine reason to use a library — until you've understood what it does, and you can decide whether the abstraction is serving you or just adding weight.

The native driver is not harder. It's more direct. And in a TypeScript project, more direct usually means more maintainable.