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
InMemoryGameSessionStoreforValKeyGameSessionStorerequires editing one line inapp.ts - Tests create fresh
InMemoryGameSessionStoreinstances 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.tsusescreateApp()which owns the store — controller tests remain integration-style and unaffected- All layers below
createApp()depend only on theGameSessionStoreinterface, never the concrete implementation
Affected files
apps/api/src/app.ts— creates the store, passes tocreateApiRouterapps/api/src/routes/apiRouter.ts— converted tocreateApiRouter(store)factoryapps/api/src/routes/gameRouter.ts— converted tocreateGameRouter(store)factoryapps/api/src/controllers/gameController.ts— converted tocreateGameController(store)factoryapps/api/src/services/gameService.ts—storeparameter added to both functions, singleton removedapps/api/src/services/gameService.test.ts— fresh store per describe block viabeforeEach
References
Setup guide / implementation notes
-
gameService.ts— remove module-level singleton, addstore: GameSessionStoreparameter tocreateGameSessionandevaluateAnswer -
gameController.ts— convert exported functions to a factory:export const createGameController = (store: GameSessionStore) => ({ createGame: async (req, res, next) => { ... }, submitAnswer: async (req, res, next) => { ... }, }); -
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; }; -
apiRouter.ts— convert to factory:export const createApiRouter = (store: GameSessionStore): Router => { const router = express.Router(); router.use("/game", createGameRouter(store)); return router; }; -
app.ts— create the store at the composition root:const store = new InMemoryGameSessionStore(); app.use("/api/v1", createApiRouter(store)); -
gameService.test.ts— addlet store: InMemoryGameSessionStoreto eachdescribeblock, reset inbeforeEach, pass to every service call