DI Container

SpaceNode's dependency injection is a simple, flat, key-value container with full lifecycle management. No decorators, no reflection, no magic.

How Services Get Registered

  1. You export named values from *.service.js files
  2. On startup, all exports are collected and registered
  3. The export name is the DI key (e.g. authService)
  4. On each request, all services are passed as second argument
// modules/auth/auth.service.js
export const authService = { ... }    // key: "authService"

// modules/users/users.service.js
export const userService = { ... }    // key: "userService"

// In ANY controller from ANY module:
export function handler(request, services) {
  const { send } = request
  const { authService, userService } = services
  // Both available – cross-module DI
}

Service Types

Services can be plain objects, factory functions, or classes:

// Plain object — registered as singleton
export const cache = { store: new Map() }

// Factory function — called with container on first resolve
export const db = (container) => {
  const config = container.resolve('config')
  return new Database(config.dbUrl)
}

// Class instance — registered as singleton
export const logger = new Logger('app')

Container API

MethodDescription
register(name, instanceOrFactory)Register a service (plain value → singleton, function → singleton factory)
registerAll(map)Register multiple services at once
singleton(name, factory)Register a singleton factory — one shared instance, created lazily
transient(name, factory)Register a transient factory — new instance per resolve() call
scoped(name, factory)Register a scoped factory — one instance per scope (per-request)
resolve(name)Get a specific service by name (respects lifecycle)
getAll()Get all services (frozen, cached). This is injected as the handler's second argument
has(name)Check if a service is registered
list()Get all service names
createScope()Create a scoped child container (for per-request services)
unregister(name)Remove a service registration
clear()Clear all services (for testing)

Service Lifecycles

LifecycleBehaviorUse case
SingletonOne shared instance for the entire app (default)Database connections, caches, configs
TransientNew instance every time resolve() is calledLoggers, context-specific utilities
ScopedOne instance per scope (per-request via createScope())Per-request state, unit-of-work
import { Container } from 'SpaceNode'

const c = new Container()

// Singleton — shared across the app
c.singleton('db', () => new Database())

// Transient — new instance every time
c.transient('requestLogger', () => new RequestLogger())

// Scoped — one per scope (per request)
c.scoped('unitOfWork', (container) => new UnitOfWork(container.resolve('db')))

// Create a scope for a request
const scope = c.createScope()
scope.resolve('unitOfWork')  // → new UnitOfWork (unique to this scope)
scope.resolve('unitOfWork')  // → same instance (within same scope)
scope.resolve('db')          // → delegates to parent (singleton)

ScopedContainer

Created via container.createScope(). Scoped services get a per-scope instance. Singletons and transients delegate to the parent container.

MethodDescription
resolve(name)Resolve a service — scoped ones are per-scope, rest delegates to parent
getAll()Get all services (scoped instances resolved within this scope)
has(name)Check if a service exists (delegates to parent)
list()List all service names (delegates to parent)

Circular Dependency Detection

The container detects circular dependencies during factory resolution and throws a clear error with the dependency chain:

// This will throw:
c.singleton('a', (c) => c.resolve('b'))
c.singleton('b', (c) => c.resolve('a'))

c.resolve('a')
// Error: Circular dependency detected: a → b → a

How Auto-Inject Works

StepWhereWhat happens
1 hello.service.js You export a named value: export const helloService = { ... }
2 loader.jsloadModule() Scans *.service.js, collects all named exports
3 app.jscontainer.register() Each export is registered by its name (function → singleton factory, object → singleton instance)
4 app.jscontainer.getAll() On each request, all services are resolved into one frozen object
5 handler(request, services) The frozen services object is passed as the second argument
// In any controller, from any module:
export async function greetUser(request, services) {
  const { body, send } = request
  const { helloService } = services

  const msg = helloService.greet(body.name)
  send({ message: msg })
}

Key points:

Caching & Performance

getAll() is cached and returns a frozen object. The cache is invalidated only when a new service is registered or removed. Zero overhead per request.

Removing Services

The container supports removing services for testing or dynamic reconfiguration:

// Remove a single service
app.container.unregister('emailService')

// Remove ALL services (useful in test teardown)
app.container.clear()

After unregister() or clear(), the getAll() cache is invalidated so subsequent calls reflect the change.