Files
aris/docs/backend-service-architecture-spec.md
kenneth 699155e0d8 feat: add actions to FeedSource interface
Add listActions() and executeAction() to FeedSource for write
operations back to external services. Actions use arktype schemas
for input validation via StandardSchemaV1.

- ActionDefinition type with optional input schema
- FeedEngine routes actions with existence and ID validation
- Source IDs use reverse-domain format (aris.location, aris.tfl)
- LocationSource: update-location action with schema validation
- TflSource: set-lines-of-interest action with lineId validation
- No-op implementations for sources without actions

Co-authored-by: Ona <no-reply@ona.com>
2026-02-15 12:53:10 +00:00

11 KiB

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<userId, SourceInstance>. 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<userId, LocationSource>
  ├── new WeatherService(creds)      ← owns Map<userId, WeatherSource>
  ├── new TflService(api)            ← owns Map<userId, TflSource>
  └── FeedEngineService([loc, weather, tfl])
        └── owns Map<userId, FeedEngine>
            └── 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.

class UserSession {
  readonly userId: string
  readonly engine: FeedEngine
  private sources: Map<string, FeedSource>

  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<T extends FeedSource>(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<string, UserSession>()

  getOrCreate(userId: string): UserSession { ... }
  remove(userId: string): void { ... }  // single cleanup point
}

Source-specific operations use typed accessors:

const session = manager.getOrCreate(userId)
const location = session.getSource<LocationSource>("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<T>("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.

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<string, FeedEngine>()
  private userSources = new Map<string, Map<string, FeedSource>>()

  constructor(private readonly factories: SourceFactory[]) {}

  engineForUser(userId: string): FeedEngine { ... }
  getSourceForUser<T extends FeedSource>(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

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.

// Source handle: typed wrapper for source-specific operations
interface SourceHandle<T extends FeedSource = FeedSource> {
	readonly source: T
}

class UserSession {
	readonly engine: FeedEngine
	private handles = new Map<string, SourceHandle>()

	register<T extends FeedSource>(source: T): SourceHandle<T> {
		this.engine.register(source)
		const handle: SourceHandle<T> = { 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:

// 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:

Callers get a typed reference to the source and call methods directly. This is what all three options above use in different ways.

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.

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.

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:

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?