SpaceNode
Controllers are plain JavaScript functions exported from *.controller.js files. Each export becomes a route handler referenced by name in module.js.
export async function handlerName(request, services) {
// request — all request data + utilities
// services — all DI services from all modules
}
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 })
}
// 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)
}
this, no class, no decorators. Just a function with two arguments: request and services. This makes handlers trivially testable.For very short handlers (1–2 lines), you can destructure directly in the parameters — both styles work:
export function me({ user, send }) {
send({ user })
}
If your handler doesn't call send(), the framework automatically sends a 204 No Content response. Useful for fire-and-forget operations.
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: [],
}
data (the emitted payload) as first arg, not request. They still get services as second arg for DI access.