ADR-001: Pure reducers and the effect system
Date: 2025-01-15 (Phase 1) Status: Accepted Supersedes: — Superseded by: —
Context
Every IRC daemon must decide where side effects happen. A traditional ircd threads mutable state and IO through command handlers — the handler reads a channel roster, sends messages, mutates a user record, and returns. That makes handlers hard to unit-test (they require a live socket, a real timer, a populated data store) and hard to reason about (the handler's observable behaviour is inseparable from its IO).
ServerlessIRCd targets two cloud adapters (Cloudflare Workers, AWS Lambda) that share one protocol core. If the core performed IO directly, each adapter would need its own mock surface for every core test — or worse, the core would grow adapter-specific code paths.
Decision
Every command handler is a pure function with the signature:
type Reducer<S> = (state: S, msg: IrcMessage, ctx: Ctx) => { state: S; effects: Effect[] };
- No IO in the reducer. No
await, nofetch, nosetTimeout, noDate.now(), noMath.random(). The reducer readsctx.clock.now()andctx.ids.nonce()instead (see ADR-007). - Side effects are values. The reducer returns an
Effect[]— a discriminated union (Send,Broadcast,Disconnect,ReserveNick,ChangeNick,ReleaseNick,ApplyChannelDelta,SendToNick, …). Each effect tag maps one-to-one to anIrcRuntimemethod. - A single
dispatch(effects, runtime)function inirc-serverinterprets the effects against the bound runtime. Onlydispatchawaits. - Reducer purity is enforced by convention and testability, not by a
sandbox. The type system carries the contract; the 100% coverage gate on
irc-coreverifies it empirically.
Consequences
Positive:
- Every reducer is a trivial unit test: arrange state, apply message, assert
stateandeffects. No mocks, no fakes beyond an injectedClockandIdFactory. - The same reducer code runs unchanged on in-memory, CF, and AWS runtimes. Adapter work is "wire the runtime", not "rewrite the handler".
- Reducer behaviour is deterministic and reproducible — critical for the parametrized contract suite (TICKET-032) that runs identical scenarios across all three runtimes.
- Mutation testing (Stryker, TICKET-048) is meaningful because reducers have no hidden IO to mask a mutation's effect.
Negative:
- The
Effectunion grows with every new side-effect type. Adding a novel effect (e.g.QueryHistoryfor chathistory) requires touchingeffects.ts,dispatch.ts, and every runtime implementation. This is by design (the type system makes missing-runtime-support a compile error) but adds ceremony. - Reducers cannot directly observe the result of a side effect they emit (e.g. "did the broadcast actually reach anyone?"). Cross-effect coordination is deferred to the actor layer, which can pre-fetch runtime state before invoking the reducer.
References
- PLAN §2.1 — "The key idea: pure reducers + location-of-authority"
- PLAN §2.2 —
IrcRuntimeport packages/irc-core/src/effects.ts—Effectunionpackages/irc-core/src/types.ts—Reducer<S>signaturepackages/irc-server/src/dispatch.ts—dispatch(effects, runtime)