beta

Mongoose

MongoDB integration via Mongoose with injectable models, multi-connection support, and standard `inject()` API.

Overview

@miiajs/mongoose wires Mongoose into MiiaJS. Declare models with defineModel(), register them per feature module, and inject them in services as plain DI tokens with inject(User). The raw mongoose.Connection is available through inject(mongooseConnection()) for migrations, transactions, and direct collection-level access.

The package owns the connection lifecycle (onInit/onDestroy), retry logic for transient errors, and model compilation. The internal MongooseService is an implementation detail - users never inject it directly.

Installation

bun add @miiajs/mongoose mongoose

Peer dependencies:

PackageVersion
mongoose^9.4.1

Setup

import { Module } from '@miiajs/core'
import { MongooseModule } from '@miiajs/mongoose'

@Module({
  imports: [
    MongooseModule.configure({
      uri: 'mongodb://localhost:27017/myapp',
    }),
  ],
})
class AppModule {}

With ConfigService (from @miiajs/config):

import { ConfigService } from '@miiajs/config'

MongooseModule.configure((resolve) => {
  const config = resolve(ConfigService)
  return {
    uri: config.getOrThrow('MONGODB_URL'),
    retry: { attempts: 5, delay: 2_000 },
  }
})

Configuration options

OptionTypeDefaultDescription
uristringrequiredMongoDB connection URI
connectionOptionsmongoose.ConnectOptions-Forwarded to createConnection()
retry.attemptsnumber3Connection attempts before giving up
retry.delaynumber2_000Delay (ms) between attempts

Only network-level errors (ECONNREFUSED, ECONNRESET, ETIMEDOUT, ENOTFOUND, EAI_AGAIN) are retried. Auth and schema errors abort immediately.

Define models

defineModel(name, schema) returns a class-token that doubles as a typed DI token for the compiled mongoose.Model<T>. Pass it to MongooseModule.register() and to inject():

import { Schema } from 'mongoose'
import { defineModel } from '@miiajs/mongoose'

export interface IUser {
  name: string
  email: string
  role: string
}

const userSchema = new Schema<IUser>(
  {
    name: { type: String, required: true },
    email: { type: String, required: true },
    role: { type: String, default: 'user' },
  },
  { timestamps: true },
)

export const User = defineModel<IUser>('User', userSchema)

For schemas with plugins or middleware, compose the schema before passing it:

const userSchema = new Schema<IUser>({ ... })
userSchema.plugin(autopopulate)
userSchema.pre('save', async function () { /* ... */ })

export const User = defineModel('User', userSchema)
The token is a class. Each defineModel(...) call returns a fresh class, so two calls with the same arguments produce two distinct tokens - this is the supported way to point one model at two different connections (see Multiple connections).

Register models

Register models in the feature module that owns them:

import { Module } from '@miiajs/core'
import { MongooseModule } from '@miiajs/mongoose'
import { User } from './user.model.js'

@Module({
  imports: [MongooseModule.register([User])],
  controllers: [UserController],
  providers: [UserService],
})
class UserModule {}

Each registered token becomes its own DI provider keyed by the token class. Mongoose compiles the model during onInit.

Use in services

Inject models with the standard inject() from @miiajs/core:

import { Injectable, inject } from '@miiajs/core'
import { User } from './user.model.js'

@Injectable()
class UserService {
  private users = inject(User)

  async findAll() {
    return this.users.find()
  }

  async findById(id: string) {
    return this.users.findById(id)
  }

  async create(data: { name: string; email: string }) {
    return this.users.create(data)
  }

  async update(id: string, data: Record<string, any>) {
    return this.users.findByIdAndUpdate(id, data, { new: true })
  }

  async delete(id: string) {
    const result = await this.users.deleteOne({ _id: id })
    return result.deletedCount > 0
  }
}

inject(User) returns a typed mongoose.Model<T> proxy. The proxy preserves Mongoose's instanceof checks (doc instanceof User) and new User(data) document construction; the underlying compiled model is created during app.init().

Raw connection access

For aggregations, transactions, or direct collection access, inject the raw mongoose.Connection:

import { Injectable, inject } from '@miiajs/core'
import { mongooseConnection } from '@miiajs/mongoose'

@Injectable()
class MigrationService {
  private connection = inject(mongooseConnection())

  async listCollections() {
    return (await this.connection.db?.listCollections().toArray()) ?? []
  }

  async runTransaction() {
    const session = await this.connection.startSession()
    try {
      await session.withTransaction(async () => {
        // multi-document writes
      })
    } finally {
      await session.endSession()
    }
  }
}

mongooseConnection(name?) is memoized: the same name (including the default empty name) always returns the same token, so DI identity is stable across files.

Multiple connections

Pass a name to configure() and register() for each additional database:

@Module({
  imports: [
    MongooseModule.configure({ uri: MAIN_DB }),
    MongooseModule.configure({ uri: ANALYTICS_DB }, 'analytics'),
    UserModule,
    EventModule,
  ],
})
class AppModule {}

Register each model under the connection it belongs to:

// UserModule - default connection
MongooseModule.register([User])

// EventModule - named connection
MongooseModule.register([Event], 'analytics')

If you need the same model on two connections, declare two distinct tokens. They look identical to the user but DI sees them as separate providers:

export const User = defineModel<IUser>('User', userSchema)
export const AnalyticsUser = defineModel<IUser>('User', userSchema)

MongooseModule.register([User])                      // default
MongooseModule.register([AnalyticsUser], 'analytics')

// In services:
private users = inject(User)
private analyticsUsers = inject(AnalyticsUser)
Registering the same token under two different connection names will log a warning and resolve to whichever provider was registered first. Always create a separate defineModel(...) per connection.

For raw connection access on the named connection:

private analyticsConn = inject(mongooseConnection('analytics'))

Connection lifecycle

  • onInit - validates that no two distinct tokens claim the same model name, connects to MongoDB with retry on transient errors, compiles all registered models. If model compilation fails, the connection is closed before the error is rethrown (no leaked sockets).
  • onDestroy - closes the connection and clears all model references.

Testing

Because models are DI tokens, you override them directly in tests with TestApp.override():

import { TestApp } from '@miiajs/core/testing'
import { AppModule } from './app.module.js'
import { User } from './user/user.model.js'

const fakeUserModel = {
  find: () => Promise.resolve([{ _id: '1', name: 'Alice' }]),
  create: async (doc: any) => ({ _id: 'fake', ...doc }),
} as any

const app = await TestApp.create(AppModule)
  .override(User, fakeUserModel)
  .compile()

const res = await app.request('GET', '/users')
expect(res.status).toBe(200)

await app.close()

For higher-level overrides (replace the whole service), use the standard TestApp.override(UserService, mock) pattern from @miiajs/core.

Exports

import {
  MongooseModule,
  defineModel,
  mongooseConnection,
  type MongooseModuleOptions,
  type ModelToken,
  type MongooseConnectionToken,
} from '@miiajs/mongoose'

See also

  • @miiajs/papr - Papr-based MongoDB integration with the same shape
  • @miiajs/drizzle - SQL ORM integration following a similar pattern
  • Modules - DI scoping and module composition