Controllers

Controllers are plain JavaScript functions exported from *.controller.js files. Each export becomes a route handler referenced by name in module.js.

Handler Signature

export async function handlerName(request, services) {
  // request — all request data + utilities
  // services — all DI services from all modules
}

Code Style Convention

The recommended style is to destructure request and services at the top of the function body. This keeps the signature clean and makes it immediately clear what the handler uses:

export async function register(request, services) {
  const { body, guard, check, emit, send } = request
  const { userStore, tokenStore } = services

  const existing = await userStore.getByEmail(body.email)
  guard(existing, 409, 'User with this email already exists')

  const user = await userStore.create(body)
  check(user, 500, 'Failed to create user')

  const token = await tokenStore.create(user._id)
  await emit('auth:register', { userId: user._id })

  send(201, { user, token })
}
Why this style? — Destructuring at the top acts as a manifest: you see at a glance which request utilities and which services the handler depends on. It also makes refactoring and testing easier.

Full Example

// modules/products/products.controller.js

export async function list(request, services) {
  const { query, send } = request
  const { productStore } = services

  const products = await productStore.list(query)
  send(products)
}

export async function getById(request, services) {
  const { params, check, send } = request
  const { productStore } = services

  const product = await productStore.getById(params.id)
  check(product, 404, 'Product not found')
  send(product)
}

export async function create(request, services) {
  const { body, user, check, emit, send } = request
  const { categoryStore, productStore } = services

  const category = await categoryStore.getById(body.categoryId)
  check(category, 400, 'Category not found')

  const product = await productStore.create(body)
  await emit('product:created', { productId: product._id, adminId: user.id })

  send(201, product)
}

export async function remove(request, services) {
  const { params, user, check, emit, send } = request
  const { productStore } = services

  const product = await productStore.getById(params.id)
  check(product, 404, 'Product not found')
  check(product.sellerId === user.id || user.role === 'admin', 403, 'Forbidden')

  await productStore.delete(params.id)
  await emit('product:deleted', { productId: params.id, adminId: user.id })

  send(204)
}
Key Insight — Every handler is a standalone function. No this, no class, no decorators. Just a function with two arguments: request and services. This makes handlers trivially testable.

Simple Handlers

For very short handlers (1–2 lines), you can destructure directly in the parameters — both styles work:

export function me({ user, send }) {
  send({ user })
}

Auto-204

If your handler doesn't call send(), the framework automatically sends a 204 No Content response. Useful for fire-and-forget operations.

Event Handlers

Controllers can also contain event handler functions for inter-module communication. These are referenced by name in module.js's on property. Event handlers receive (data, services) instead of (request, services):

// modules/notifications/notifications.controller.js

export function onOrderCreated(data, services) {
  const { notificationService } = services
  console.log('New order:', data.orderId)
  notificationService.send(data.userId, 'Your order was placed!')
}

// modules/notifications/module.js
export default {
  prefix: '/notifications',
  on: {
    'order:created': 'onOrderCreated',  // maps to controller function
  },
  routes: [],
}
Key difference — Event handlers get data (the emitted payload) as first arg, not request. They still get services as second arg for DI access.