# Backend Service Architecture: Per-User Refactor ## Problem Statement The current backend uses a **per-source service** pattern: each source type (Location, Weather, TFL) has its own `XxxService` class that manages a `Map`. Adding a new source requires: 1. A new `XxxService` class with identical boilerplate (~30-40 lines: Map, get-or-create, removeUser) 2. Wiring it into `server.ts` constructor 3. Passing it to `FeedEngineService` 4. Optionally adding source-specific tRPC routes With 3 sources this is manageable. With 10+ (calendar, music, transit, news, etc.) it becomes: - **Repetitive**: Every service class repeats the same Map + get-or-create + removeUser pattern - **Fragmented lifecycle**: User cleanup requires calling `removeUser` on every service independently - **No user-level config**: No unified place to store which sources a user has enabled or their per-source settings - **Hard to reason about**: User state is scattered across N independent Maps ### Current Flow ``` server.ts ├── new LocationService() ← owns Map ├── new WeatherService(creds) ← owns Map ├── new TflService(api) ← owns Map └── FeedEngineService([loc, weather, tfl]) └── owns Map └── on create: asks each service for feedSourceForUser(userId) ``` 4 independent Maps for 3 sources. Each user's state lives in 4 different places. ## Scope **Backend only** (`apps/aris-backend`). No changes to `aris-core` or source packages (`packages/aris-source-*`). The `FeedSource` interface and source implementations remain unchanged. ## Architectural Options ### Option A: UserSession Object A single `UserSession` class owns everything for one user. A `UserSessionManager` is the only top-level Map. ```typescript class UserSession { readonly userId: string readonly engine: FeedEngine private sources: Map constructor(userId: string, sourceFactories: SourceFactory[]) { this.engine = new FeedEngine() this.sources = new Map() for (const factory of sourceFactories) { const source = factory.create() this.sources.set(source.id, source) this.engine.register(source) } this.engine.start() } getSource(id: string): T | undefined { return this.sources.get(id) as T | undefined } destroy(): void { this.engine.stop() this.sources.clear() } } class UserSessionManager { private sessions = new Map() getOrCreate(userId: string): UserSession { ... } remove(userId: string): void { ... } // single cleanup point } ``` **Source-specific operations** use typed accessors: ```typescript const session = manager.getOrCreate(userId) const location = session.getSource("location") location?.pushLocation({ lat: 51.5, lng: -0.1, ... }) ``` **Pros:** - Single Map, single cleanup point - All user state co-located - Easy to add TTL/eviction at one level - Source factories are simple functions, no service classes needed **Cons:** - `getSource("id")` requires callers to know the source ID string and cast type - Shared resources (e.g., TFL API client) need to be passed through factories ### Option B: Source Registry with Factories Keep `FeedEngineService` but replace per-source service classes with a registry of factory functions. No `XxxService` classes at all. ```typescript interface SourceFactory { readonly sourceId: string create(userId: string): FeedSource } // Weather factory — closure over shared credentials function weatherSourceFactory(creds: WeatherKitCredentials): SourceFactory { return { sourceId: "weather", create: () => new WeatherSource({ credentials: creds }), } } // TFL factory — closure over shared API client function tflSourceFactory(api: ITflApi): SourceFactory { return { sourceId: "tfl", create: () => new TflSource({ client: api }), } } class FeedEngineService { private engines = new Map() private userSources = new Map>() constructor(private readonly factories: SourceFactory[]) {} engineForUser(userId: string): FeedEngine { ... } getSourceForUser(userId: string, sourceId: string): T | undefined { ... } removeUser(userId: string): void { ... } // cleans up engine + all sources } ``` **Pros:** - Minimal change from current structure — `FeedEngineService` evolves, services disappear - Factory functions are 5-10 lines each, no classes - Shared resources handled naturally via closures **Cons:** - `FeedEngineService` grows in responsibility (engine + source tracking + source access) - Still two Maps (engines + userSources), though co-located ### Option C: UserSession + Typed Source Handles (Recommended) Combines Option A's co-location with type-safe source access. `UserSession` owns everything. Source-specific operations go through **source handles** — thin typed wrappers registered at setup time. ```typescript // Source handle: typed wrapper for source-specific operations interface SourceHandle { readonly source: T } class UserSession { readonly engine: FeedEngine private handles = new Map() register(source: T): SourceHandle { this.engine.register(source) const handle: SourceHandle = { source } this.handles.set(source.id, handle) return handle } destroy(): void { this.engine.stop() this.handles.clear() } } // In setup code — handles are typed at creation time function createSession(userId: string, deps: SessionDeps): UserSession { const session = new UserSession(userId) const locationHandle = session.register(new LocationSource()) const weatherHandle = session.register(new WeatherSource(deps.weatherCreds)) const tflHandle = session.register(new TflSource({ client: deps.tflApi })) return session } ``` **Source-specific operations** use the typed handles returned at registration: ```typescript // In the tRPC router or wherever source-specific ops happen: // The handle is obtained during session setup and stored where needed locationHandle.source.pushLocation({ ... }) tflHandle.source.setLinesOfInterest(["northern"]) ``` **Pros:** - Single Map, single cleanup - Type-safe source access without string-based lookups or casts - No boilerplate service classes - Handles can be extended later (e.g., add per-source config, metrics) - Shared resources passed directly to constructors **Cons:** - Handles need to be threaded to where they're used (tRPC routers, etc.) - Slightly more setup code in the factory function ## Source-Specific Operations: Approaches Orthogonal to the session model, there are three ways to handle operations like `pushLocation` or `setLinesOfInterest`: ### Approach 1: Direct Source Access (Recommended) Callers get a typed reference to the source and call methods directly. This is what all three options above use in different ways. ```typescript locationSource.pushLocation(location) tflSource.setLinesOfInterest(lines) ``` **Why this works:** Source packages already define these methods. The backend just needs to expose the source instance to the right caller. No new abstraction needed. ### Approach 2: Command Dispatch A generic `dispatch(command)` method on the session routes typed commands to sources. ```typescript session.dispatch({ type: "location.update", payload: { lat: 51.5, ... } }) ``` **Tradeoff:** Adds indirection and a command type registry. Useful if sources are dynamically loaded plugins, but over-engineered for the current case where sources are known at compile time. ### Approach 3: Context-Only All input goes through `FeedEngine` context updates. Sources react to context changes. ```typescript engine.pushContext({ [LocationKey]: location }) // LocationSource picks this up via onContextUpdate ``` **Tradeoff:** Location already works this way (it's a context provider). But not all operations map to context — `setLinesOfInterest` is configuration, not context. Would require stretching the context concept. ## User Source Configuration (DB-Persisted) Regardless of which option is chosen, user source config needs a storage model: ```sql CREATE TABLE user_source_config ( user_id TEXT NOT NULL REFERENCES users(id), source_id TEXT NOT NULL, -- e.g., "weather", "tfl", "location" enabled BOOLEAN NOT NULL DEFAULT true, config JSONB NOT NULL DEFAULT '{}', -- source-specific settings PRIMARY KEY (user_id, source_id) ); ``` On session creation: 1. Load `user_source_config` rows for the user 2. Only create sources where `enabled = true` 3. Pass `config` JSON to the source factory/constructor New users get default config rows inserted on first login. ## Recommendation **Option C (UserSession + Typed Source Handles)** with **Approach 1 (Direct Source Access)**. Rationale: - Eliminates all per-source service boilerplate - Single user lifecycle management point - Type-safe without string-based lookups in hot paths - Minimal new abstraction — `UserSession` is a thin container, not a framework - Handles are just typed references, not a new pattern to learn - Natural extension point for per-user config loading from DB ## Acceptance Criteria 1. **No per-source service classes**: `LocationService`, `WeatherService`, `TflService` are removed 2. **Single user state container**: All per-user state (engine, sources) lives in one object 3. **Single cleanup**: Removing a user requires one call, not N 4. **Type-safe source access**: Source-specific operations don't require string-based lookups or unsafe casts at call sites 5. **Existing tests pass**: `FeedEngineService` tests are migrated to the new structure 6. **tRPC routes work**: Location update route works through the new architecture 7. **DB config table**: `user_source_config` table exists; session creation reads from it 8. **Default config**: New users get default source config on first session ## Implementation Steps 1. Create `user_source_config` DB table and migration 2. Create `UserSession` class with `register()`, `destroy()`, typed handle return 3. Create `UserSessionManager` with `getOrCreate()`, `remove()`, config loading 4. Create `createSession()` factory that reads DB config and registers enabled sources 5. Refactor `server.ts` to use `UserSessionManager` instead of individual services 6. Refactor tRPC router to receive session/handles instead of individual services 7. Delete `LocationService`, `WeatherService`, `TflService` classes 8. Migrate existing tests to new structure 9. Add tests for session lifecycle (create, destroy, config loading) ## Open Questions - **TTL/eviction**: Should `UserSessionManager` handle idle session cleanup? (Currently deferred in backend-spec.md) - **Hot reload config**: If a user changes their source config, should the session be recreated or patched in-place? - **Shared source instances**: Some sources (e.g., TFL) share an API client. Should the factory receive shared deps, or should there be a DI container?