Files
skills/docs/gaming/games/game-server/src/engine/di-container/di-container.md
2026-05-29 08:20:56 +01:00

4.7 KiB

DIContainer

(no submodules)

Purpose

DIContainer is a small, type-safe service locator that the broader @suaveplan/game-server engine uses to thread shared dependencies — a Clock, a LoggerLike, an EventBus, networking adapters, plugin registries — through component boundaries without forcing every call site to take a deep parameter list. It is intentionally minimal: register a factory under a string or symbol identifier, resolve to either a cached singleton or a fresh transient instance, and inspect what is registered for testing.

This file is the first feature migrated from the reference branch claude/review-game-engine-arch-Kaykr (src/engine/DIContainer.ts). The port replaces the upstream's plain throw new Error(...) on unresolved identifiers with a typed GameServerError carrying the stable code GAME_SERVER.SERVICE_NOT_REGISTERED, so consumers can branch on error.code rather than scraping message strings.

Features

  • Singleton lifecycle — factory invoked once; the instance is cached.
  • Transient lifecycle — factory invoked on every resolve.
  • Typed identifiers — generic parameter on ServiceIdentifier<T> carries through register and resolve.
  • Fluent API — register returns this for chaining.
  • Re-register invalidates cache — calling register again clears any cached singleton instance (improvement over upstream).
  • Typed error on miss — GameServerError(GAME_SERVER.SERVICE_NOT_REGISTERED).
  • Introspection — has, getRegisteredServices, and clear round out the surface.

Usage: Basic

import { DIContainer } from "@suaveplan/game-server/engine/di-container";

const container = new DIContainer();

container.register("logger", () => new ConsoleLogger(), "singleton");
container.register("requestId", () => crypto.randomUUID(), "transient");

const logger1 = container.resolve<ConsoleLogger>("logger");
const logger2 = container.resolve<ConsoleLogger>("logger");
console.log(logger1 === logger2); // true — singleton

const r1 = container.resolve<string>("requestId");
const r2 = container.resolve<string>("requestId");
console.log(r1 === r2); // false — transient

Usage: Advanced

import { DIContainer } from "@suaveplan/game-server/engine/di-container";
import { GAME_SERVER_CODES, GameServerError } from "@suaveplan/game-server/errors";

interface IClock { now(): number }
const CLOCK = Symbol("Clock") as ServiceIdentifier<IClock>;

const container = new DIContainer()
    .register<IClock>(CLOCK, () => ({ now: () => Date.now() }))
    .register("events", () => new EventBus());

try {
    container.resolve("nope");
} catch (err) {
    if (
        err instanceof GameServerError &&
        err.code === GAME_SERVER_CODES.SERVICE_NOT_REGISTERED
    ) {
        // surface a typed missing-dep diagnostic
    }
}

// Re-registering invalidates the cached singleton:
container.register<IClock>(CLOCK, () => new TestClock());
const freshClock = container.resolve<IClock>(CLOCK); // new TestClock instance

API Reference

class DIContainer

register<T>(identifier, factory, lifecycle = "singleton"): this

Register a service. Re-registering an identifier replaces the factory and discards any cached singleton instance.

resolve<T>(identifier): T

Resolve a registered service. Throws GameServerError with code GAME_SERVER.SERVICE_NOT_REGISTERED if the identifier is unknown.

has(identifier): boolean

unregister(identifier): boolean

Returns true when the identifier was previously registered.

clear(): void

Remove every registration and cached singleton.

getRegisteredServices(): ServiceIdentifier[]

Types

  • ServiceIdentifier<T = unknown> — string | symbol.
  • ServiceLifecycle — "singleton" | "transient".
  • ServiceDefinition<T> — internal record holding identifier, factory, singleton flag.

Implementation Notes

Re-register-invalidates-cache is the only behavioural change versus the upstream. The upstream kept the stale singleton after a second register call, which masked test-double swaps — code that worked in unit tests because the test installed its fake first, but failed in integration because production startup happened to register the real service first. Discarding the cached instance on re-register matches the "last writer wins" semantics every other suaveplan registry uses (@suaveplan/events, @suaveplan/config).

The unresolved-identifier path raises GameServerError, not a plain Error, because everything in this package is expected to be discriminable via error.code. String(identifier) is included in both the message and the context bag so symbols and plain strings surface identically in logs.