lila/documentation/tickets/t00004.md

4.6 KiB

ADR: Dependency injection for GameSessionStore via composition root

Status

Accepted

Date

2026-04-28

Context

gameService.ts had a module-level singleton:

const gameSessionStore = new InMemoryGameSessionStore();

This made the store invisible to anything outside the file. The GameSessionStore interface existed to make the store swappable — but the singleton made that impossible without editing the service itself. Tests shared the same instance across every test run, creating the potential for ghost sessions leaking between tests. The controller also briefly owned the singleton during an intermediate step, which violated the principle that controllers should only handle HTTP concerns.

Decision

Adopt a composition root pattern. The store is created once in createApp() and passed down through factory functions: createApiRouter(store)createGameRouter(store)createGameController(store) → service calls. Neither the controller nor the service knows which implementation they're working with — they both see GameSessionStore.

Options considered

Option A — Composition root

Convert routers and controllers to factory functions. Create the store in createApp() and pass it down. The store is created once, at the top, and injected through the call chain.

Chosen because: clean separation of concerns, no layer below createApp() needs to know the concrete implementation, swapping to ValKeyGameSessionStore is a one-line change in app.ts, and tests get fresh isolated store instances.

Option B — Keep singleton in controller

Leave the store as a module-level singleton in gameController.ts. Controllers own the store lifetime.

Rejected because: controllers should only handle HTTP concerns. Owning infrastructure lifetime is not an HTTP concern.

Option C — DI framework (tsyringe, inversify)

Use a proper dependency injection container.

Rejected because: overkill for the current scale. The composition root pattern achieves the same result with zero dependencies and no magic.

Consequences

  • Swapping InMemoryGameSessionStore for ValKeyGameSessionStore requires editing one line in app.ts
  • Tests create fresh InMemoryGameSessionStore instances per test — no shared state, no ghost sessions
  • Routers and controllers are now factory functions instead of module-level singletons — slightly more verbose but explicitly testable
  • gameController.test.ts uses createApp() which owns the store — controller tests remain integration-style and unaffected
  • All layers below createApp() depend only on the GameSessionStore interface, never the concrete implementation

Affected files

  • apps/api/src/app.ts — creates the store, passes to createApiRouter
  • apps/api/src/routes/apiRouter.ts — converted to createApiRouter(store) factory
  • apps/api/src/routes/gameRouter.ts — converted to createGameRouter(store) factory
  • apps/api/src/controllers/gameController.ts — converted to createGameController(store) factory
  • apps/api/src/services/gameService.tsstore parameter added to both functions, singleton removed
  • apps/api/src/services/gameService.test.ts — fresh store per describe block via beforeEach

References


Setup guide / implementation notes

  1. gameService.ts — remove module-level singleton, add store: GameSessionStore parameter to createGameSession and evaluateAnswer

  2. gameController.ts — convert exported functions to a factory:

    export const createGameController = (store: GameSessionStore) => ({
      createGame: async (req, res, next) => { ... },
      submitAnswer: async (req, res, next) => { ... },
    });
    
  3. gameRouter.ts — convert to factory:

    export const createGameRouter = (store: GameSessionStore): Router => {
      const router = express.Router();
      const controller = createGameController(store);
      router.post("/start", controller.createGame);
      router.post("/answer", controller.submitAnswer);
      return router;
    };
    
  4. apiRouter.ts — convert to factory:

    export const createApiRouter = (store: GameSessionStore): Router => {
      const router = express.Router();
      router.use("/game", createGameRouter(store));
      return router;
    };
    
  5. app.ts — create the store at the composition root:

    const store = new InMemoryGameSessionStore();
    app.use("/api/v1", createApiRouter(store));
    
  6. gameService.test.ts — add let store: InMemoryGameSessionStore to each describe block, reset in beforeEach, pass to every service call