Mongoose
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
npm install @miiajs/mongoose mongoose
pnpm add @miiajs/mongoose mongoose
yarn add @miiajs/mongoose mongoose
Peer dependencies:
| Package | Version |
|---|---|
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
| Option | Type | Default | Description |
|---|---|---|---|
uri | string | required | MongoDB connection URI |
connectionOptions | mongoose.ConnectOptions | - | Forwarded to createConnection() |
retry.attempts | number | 3 | Connection attempts before giving up |
retry.delay | number | 2_000 | Delay (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)
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)
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