Skip to content

Search docs

Jump between documentation pages.

Browse docs

Use Mongoose with DaloyJS

Mongoose is the default ODM choice for MongoDB teams who want schemas, model middleware, casting, validation, and transactions through sessions. It fits naturally into DaloyJS when you register the connection once and expose a small model surface on state.

Mongoose setup
  1. 01Installpnpm add mongoose
  2. 02Schema & modelnew Schema · model('User')
  3. 03Pluginconnect · decorate('db') · onClose
  4. 04Augment stateinterface AppState { db }
  5. 05Use in routesstate.db.User.findById()
The connection happens once inside the plugin. After you augment AppState, handlers get a fully typed state.db model surface.

1. Install

This guide targets Mongoose 9 (mongoose@latest is 9.x as of October 2026).

bash
pnpm add mongoose@^9

Mongoose 9 removed the next() callback from pre middleware: write hooks as async functions (or return a promise) instead of calling next().

2. Define a schema and model

The scaffolded tsconfig.json sets verbatimModuleSyntax, and Node's type stripping (what daloy dev runs) needs type-only imports marked as such, so import InferSchemaType with import type.

src/db/mongoose.tsts
import mongoose, { model, Schema } from "mongoose";
import type { InferSchemaType } from "mongoose";

// Filter safety, set once before any model is used:
// - sanitizeFilter wraps nested `$`-prefixed objects in filters with $eq,
//   so { email: { $ne: null } } from a request cannot become an operator.
// - strictQuery drops filter keys that are not in the schema.
mongoose.set("sanitizeFilter", true);
mongoose.set("strictQuery", true);

const userSchema = new Schema(
  {
    email: { type: String, required: true, unique: true },
    name: { type: String, default: null },
  },
  {
    timestamps: true,
    versionKey: false,
  },
);

const auditLogSchema = new Schema(
  {
    action: { type: String, required: true },
    userId: { type: Schema.Types.ObjectId, required: true },
  },
  { timestamps: true, versionKey: false },
);

export type UserDocument = InferSchemaType<typeof userSchema>;
export const User = model("User", userSchema);
export const AuditLog = model("AuditLog", auditLogSchema);

export const connection = mongoose;
export const db = { connection, User, AuditLog };

3. Create a Mongoose plugin

src/db/plugin.tsts
import type { App } from "@daloyjs/core";
import { connection, db } from "./mongoose.ts";

// Call with the root app, not through app.register(): a decoration made
// inside a registered plugin is visible only to that plugin's own routes.
export async function mongoosePlugin(app: App) {
  await connection.connect(process.env.MONGODB_URI!);
  app.decorate("db", db);
  app.onClose(async () => {
    await connection.disconnect();
  });
}

onClose() runs on the Node.js, Bun, and Deno adapters only. On Lambda, Vercel, or Azure Functions, connect once at module scope, keep maxPoolSize small, and see Serverless and edge lifecycle.

4. Augment app state types

Add the declare module block to the same module that exports db, not to a separate .d.ts file. Declaration files are exempt from type-checking when skipLibCheck is on (the scaffolded default), so a broken import inside a .d.ts fails silently and state.db degrades to any.

src/db/mongoose.tsts
// the module that exports db
declare module "@daloyjs/core" {
  interface AppState {
    db: typeof db;
  }
}

5. Use it in routes

Validate the ObjectId format in the params schema. A malformed id otherwise reaches Mongoose, which throws a CastError that surfaces as a 500.

src/server.tsts
import { z } from "zod";
import { App, NotFoundError } from "@daloyjs/core";
import { serve } from "@daloyjs/core/node";
import { mongoosePlugin } from "./db/plugin.ts";

const ObjectIdSchema = z.string().regex(/^[a-f\d]{24}$/i, "Invalid id");

const UserSchema = z.object({
  id: ObjectIdSchema,
  email: z.email(),
  name: z.string().nullable(),
});

const app = new App();
await mongoosePlugin(app);

app.get(
  "/users/:id",
  {
    operationId: "getUser",
    request: { params: z.object({ id: ObjectIdSchema }) },
    responses: {
      200: { description: "Found", body: UserSchema },
      404: { description: "Not found" },
    },
  },
  async ({ params, state }) => {
    // lean() returns a plain object; the projection fetches only what the
    // response needs.
    const user = await state.db.User.findById(params.id, { email: 1, name: 1 }).lean();
    if (!user) throw new NotFoundError("User not found");

    return {
      status: 200,
      body: {
        id: String(user._id),
        email: user.email,
        name: user.name ?? null,
      },
    };
  },
);

await app.ready();
serve(app, { port: 3000 });

Sessions and transactions

Use MongoDB sessions for multi-document transactions. Start the session inside the handler and thread it through every model call in the unit of work.

Session-scoped transaction
HandlerSessionModels
  1. 01requestHandlerSessionStart a sessionstartSession()
  2. 02requestHandlerModelsRun every write inside withTransactionUser.create([...], { session })
  3. 03responseSessionHandlerCommit on success, roll back on throw
  4. 04noteHandlerSessionAlways end the session in finallyendSession()
The session is opened once, threaded through every model call, and ended in a finally block so it closes whether the transaction commits or rolls back.

This example assumes a route that declares 201 with a body schema matching the returned object.

ts
handler: async ({ body, state }) => {
  const session = await state.db.connection.startSession();

  try {
    let created = { id: "", email: body.email, name: body.name ?? null };
    await session.withTransaction(async () => {
      const [user] = await state.db.User.create([{ email: body.email, name: body.name }], { session });
      created = { id: String(user._id), email: user.email, name: user.name ?? null };
      await state.db.AuditLog.create([{ action: "user.created", userId: user._id }], { session });
    });

    return { status: 201, body: created };
  } finally {
    await session.endSession();
  }
}

Validation and errors

Keep transport validation in Zod and let Mongoose own document-level validation. Translate duplicate keys and cast failures into DaloyJS errors so they serialize as problem+json, and declare 404 and 409 in the route's responses.

ts
import mongoose from "mongoose";
import { ConflictError, NotFoundError } from "@daloyjs/core";

try {
  // Pick fields explicitly. Spreading `body` is safe only because z.object()
  // strips unknown keys; a looseObject() or passthrough() schema would let a
  // client set any field (mass assignment).
  const created = await state.db.User.create({ email: body.email, name: body.name });
  return { status: 201, body: { id: String(created._id), email: created.email, name: created.name ?? null } };
} catch (err) {
  if (err instanceof mongoose.Error.CastError) {
    throw new NotFoundError(); // a malformed id cannot match a document
  }
  if (typeof err === "object" && err && "code" in err && err.code === 11000) {
    throw new ConflictError("Email already in use");
  }
  throw err;
}

Runtime constraints

Mongoose is a Node.js-first ODM because it depends on the MongoDB Node driver. The Mongoose README lists Deno support as alpha, and we have not verified Bun or the serverless platforms with DaloyJS. For SQL databases or edge runtimes, stay in the ORM section instead.

Compare with the MongoDB driver without an ODM, Ottoman for Couchbase, the SQL ORM overview, or return to the ODM overview.

Edit this page on GitHub

Last updated