Services & Dependency Injection

Services contain your business logic. They're exported as named exports from *.service.js files and automatically registered in the DI container. Services can be plain objects, factory functions, or classes.

Creating a Service

// modules/auth/auth.service.js

export const userStore = {
  async create({ name, email, password }) {
    return await User.create({ name, email, password })
  },

  async getByEmail(email) {
    return User.findOne({ email })
  },

  async verifyPassword(user, password) {
    return user.verifyPassword(password)
  },
}

export const tokenStore = {
  async create(userId) {
    const token = randomBytes(32).toString('hex')
    await Token.create({ token, userId })
    return token
  },

  async get(token) {
    return Token.findOne({ token })
  },
}
Important — Services must be named exports (export const userStore = {}). The export name becomes the DI key used in controllers. You can export multiple services from a single file.

Service Types

The DI container accepts three types of service registrations:

TypeExampleBehavior
Plain objectexport const userStore = { ... }Registered directly as singleton instance
Factory functionexport const db = (container) => new Database()Called lazily on first resolve, cached as singleton
Classexport const cache = new CacheService()Instance registered directly

Using Services in Controllers

Services are the second argument to every handler. Destructure services at the top of the function body to declare dependencies:

// modules/orders/orders.controller.js

export async function checkout(request, services) {
  const { body, user, send } = request
  const { orderStore, productStore } = services

  const product = await productStore.getById(body.productId)
  const order = await orderStore.create({ userId: user.id, product })
  send(201, order)
}
Cross-module access — All services from all modules are available in every handler. orderStore from the orders module and productStore from the products module — both accessible everywhere.

How DI Works

  1. On startup, all *.service.js files are imported.
  2. All named exports (objects, functions, classes) are registered in the DI container.
  3. Each service is also registered with a namespaced key (moduleName.serviceName) for disambiguation.
  4. On each request, container.getAll() returns a frozen object with all resolved services.
  5. This object is passed as the second argument to handlers and pipes.

Module Isolation

By default, services are globally accessible. To prevent name collisions, set isolated: true in the module config — services will only be accessible via the namespaced key:

// modules/payments/module.js
export default {
  prefix: '/payments',
  isolated: true,  // services only as 'payments.paymentStore'
  routes: [...]
}

// In controllers:
export async function pay(request, services) {
  const { send } = request
  const paymentStore = services['payments.paymentStore']
  // ...
}
No decorators, no reflection — The container is a key-value store. Service name = export name. No magic, no complexity.