Introduce domain events to formalize the telemetry-to-consumer pipeline
Opened by stack72 · 4/9/2026· GitHub #255
Context
From the DDD analysis of #253 (comment).
Current architecture
The codebase already has a working event pipeline:
track() → telemetry service → telemetry_events collection
↓
┌─────┴──────┐
│ consumers │
├─────────────┤
│ stats │ → username_metrics
│ discord bot │ → Discord channel
└─────┬───────┘
↓
swamp_club_events queue
↓
score refresh + badge claimsThis works and is decoupled at the infrastructure level — the telemetry service, Discord bot, and score pipeline are all separate services with independent polling loops. The Discord bot registers handlers per event name and uses cursor-based pagination to avoid duplicate messages.
The gap
The coupling is implicit at the domain modeling level:
Producers are ad-hoc. Route handlers (and BetterAuth hooks) decide what to track by calling
track(eventName, ...)with string event names and untyped property bags. There's no contract between what producers emit and what consumers expect.Consumers match on strings. The Discord bot registers handlers for
"extension_published"and"sign_up"by name. If a producer renames an event or changes its properties, consumers break silently.No domain concept of "something happened." The domain layer (
lib/domain/) has no notion of events. Business-significant occurrences (collective created, member removed, extension published) are only captured as a side effect in the adapter layer, not as an explicit part of the domain model.
Proposal
Introduce a lightweight domain events layer:
1. Typed event definitions
// lib/domain/events.ts
interface CollectiveCreated {
type: "collective_created";
collectiveId: string;
slug: string;
name: string;
createdByUserId: string;
createdByUsername: string;
}
interface ExtensionPublished {
type: "extension_published";
extensionName: string;
version: string;
userId: string;
username: string;
collectiveSlug?: string;
}
type DomainEvent = CollectiveCreated | ExtensionPublished | ...;This gives producers and consumers a shared, typed contract.
2. Emit from app services / domain operations
Application services (or aggregates, where it makes sense) emit domain events as part of use case execution, rather than route handlers calling track() directly. This addresses #254 as well.
3. Dispatch to existing infrastructure
A thin adapter maps DomainEvent → track() call, preserving the existing telemetry service pipeline. The Discord bot, score system, and Mixpanel all continue to work — they just consume from a formalized source.
Benefits
- Typed contract between producers and consumers — rename an event field and get a compile error, not a silent break
- Domain-level visibility into what events the system considers significant
- Easy to add consumers without touching producing code
- Natural fit for the existing infrastructure — the telemetry service already acts as an event broker
Non-goals
- Not proposing to replace the telemetry service or Discord bot architecture
- Not proposing full event sourcing
- Not proposing async event buses or message queues beyond what already exists
Open questions
- Should events be emitted from aggregates (pure DDD) or app services (pragmatic)? Aggregates keep events closest to invariants; app services have access to cross-aggregate context.
- Should the shared contract live in
lib/domain/events.tsorshared/(since the telemetry service and Discord bot also need the types)? - Migration path: can we introduce typed events incrementally alongside existing
track()calls?
Related
- #253 — Collective lifecycle telemetry (adds more events to the current ad-hoc pattern)
- #254 — Move telemetry from routes to app services (prerequisite or co-requisite)
Open
No activity in this phase yet.
Sign in to post a ripple.