lila/documentation/tickets/t00004.md

110 lines
4.6 KiB
Markdown

# ADR: Dependency injection for GameSessionStore via composition root
## Status
Accepted
## Date
2026-04-28
## Context
`gameService.ts` had a module-level singleton:
```ts
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.ts``store` parameter added to both functions, singleton removed
- `apps/api/src/services/gameService.test.ts` — fresh store per describe block via `beforeEach`
## References
- [Composition root pattern](https://blog.ploeh.dk/2011/07/28/CompositionRoot/)
---
## 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:
```ts
export const createGameController = (store: GameSessionStore) => ({
createGame: async (req, res, next) => { ... },
submitAnswer: async (req, res, next) => { ... },
});
```
3. `gameRouter.ts` — convert to factory:
```ts
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:
```ts
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:
```ts
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