Domain Events
This document explains the domain events system in morph - what it is, how it works, and how it differs from related concepts like operation replay.
Overview
Domain events represent significant occurrences in your domain - things that happened that other parts of the system might care about. They enable:
- Audit trails - record of what happened and when
- Decoupled features - add notifications, analytics, etc. without modifying core operations
- Cross-aggregate coordination - react to changes in one aggregate from another
- Testability - assert that the right events were emitted
Event Shape
Events are automatically derived from operations. When an operation executes successfully and has an emits declaration, an event is created with this shape:
interface DomainEvent {
readonly _tag: string; // Event name, e.g. "TodoCreated"
readonly aggregateId: string; // Entity ID from result.id (or "" if no entity)
readonly version: number; // Monotonic per aggregate
readonly occurredAt: string; // ISO timestamp
readonly params: unknown; // Original operation parameters
readonly result: unknown; // Operation result
}
For example, when createTodo({ title: "Buy milk", userId: "user-1" }) succeeds:
{
_tag: "TodoCreated",
aggregateId: "todo-1",
version: 1,
occurredAt: "2024-01-15T10:30:00.000Z",
params: { title: "Buy milk", userId: "user-1" },
result: { id: "todo-1", title: "Buy milk", completed: false, ... }
}
The aggregateId is extracted from result.id (the root entity’s ID). The version is computed by counting existing events for that aggregate and incrementing.
The Three Event Services
The system has three distinct services for different purposes:
1. EventEmitter
Purpose: In-memory event collection for testing and introspection.
interface EventEmitter {
readonly emit: (event: DomainEvent) => Effect.Effect<void>;
}
Events are collected in memory and can be inspected. Useful for tests that want to verify events were emitted.
2. EventSubscriber
Purpose: React to events with handlers. Fire-and-forget semantics.
interface EventSubscriber {
// Typed subscribe methods per event type
readonly subscribeToTodoCreated: (handler: (event: TodoCreatedEvent) => Effect.Effect<void>) => Effect.Effect<void>;
readonly subscribeToTodoCompleted: (handler: ...) => Effect.Effect<void>;
// ...
// Publish an event to all subscribers
readonly publish: (event: DomainEvent) => Effect.Effect<void>;
}
Subscribers are defined in the schema and wired up at application startup. Errors in subscribers are logged but don’t fail the operation (fire-and-forget).
3. EventStore
Purpose: Durable, append-only event storage with query capabilities.
interface EventStore {
readonly append: (event: DomainEvent) => Effect.Effect<void, EventStoreError>;
readonly getAll: () => Effect.Effect<readonly DomainEvent[], EventStoreError>;
readonly getByTag: (tag: string) => Effect.Effect<readonly DomainEvent[], EventStoreError>;
readonly getByAggregateId: (aggregateId: string) => Effect.Effect<readonly DomainEvent[], EventStoreError>;
readonly getAfter: (timestamp: string) => Effect.Effect<readonly DomainEvent[], EventStoreError>;
}
Three backends are available:
- memory - In-memory, lost on restart (testing)
- jsonfile - Append to
.events.json(local development) - redis - Redis sorted set by timestamp (production)
Event Flow
When an operation with emits executes successfully:
Operation executes
↓
Handler returns result
↓
Event created: { _tag, occurredAt, params, result }
↓
┌──────┴──────┬────────────────┐
↓ ↓ ↓
EventEmitter EventSubscriber EventStore
(collect) (notify) (persist)
All three happen automatically - you don’t need to call them manually.
CLI Flags
The CLI provides two independent flags for backend selection:
| Flag | Purpose | Default | Options |
|---|---|---|---|
--storage |
Entity repository backend | memory |
memory, jsonfile, redis, eventsourced |
--event-store |
Event persistence backend | memory |
memory, jsonfile, redis |
These are independent - you can mix and match:
# Entities in Redis, events in local file
todo --storage redis --event-store jsonfile create-todo ...
# Both in memory (testing)
todo create-todo ...
# Both in Redis (production)
todo --storage redis --event-store redis create-todo ...
Schema Declaration
Events are declared inline on operations:
{
"commands": {
"createTodo": {
"description": "Create a new todo for a user.",
"emits": [
{
"name": "TodoCreated",
"description": "Emitted when a new todo is created"
}
],
"input": { ... },
"output": { ... }
}
}
}
Subscribers are declared at the context level:
{
"subscribers": {
"logTodoEvents": {
"description": "Log todo events for debugging",
"events": ["TodoCreated", "TodoCompleted", "TodoDeleted"]
}
}
}
Important Distinction: Domain Events vs Operation Replay
These are two separate concepts that can be confused in simple applications:
Domain Events (what we have)
Events that represent significant domain occurrences. They are part of your domain model.
- “A todo was created”
- “A user signed up”
- “An order was shipped”
One operation might emit multiple domain events, and events can represent facts that go beyond “an operation was called.”
Example of divergence:
Operation: TransferMoney(from: A, to: B, amount: 100)
Domain Events:
- AccountDebited(account: A, amount: 100)
- AccountCredited(account: B, amount: 100)
- TransferCompleted(from: A, to: B, amount: 100)
Operation Replay (separate concept, not implemented)
The ability to re-execute commands from a log. This is about operations/commands, not domain events.
- Store:
createTodo({ title: "Buy milk", userId: "user-1" }) - Replay: Execute the same operation again
This would use the DSL to describe operations as data structures that can be stored and replayed. It’s a separate track of work, deferred for now.
Why the distinction matters
In a simple todo app, these overlap (1 operation → 1 event). But in richer domains:
| Aspect | Domain Events | Operation Replay |
|---|---|---|
| Represents | What happened in the domain | What commands were issued |
| Granularity | Can be finer than operations | 1:1 with operations |
| Use case | Audit, subscribers, CQRS | Migrations, recovery, testing |
| Current status | ✅ Implemented (Phases 1-4) | Deferred (Phase 7) |
Event Sourcing
The eventsourced storage backend derives entity state from event history. When configured, the storage transport reconstructs entities by reading events from the event store rather than maintaining separate entity storage.
extensions {
storage [eventsourced] default eventsourced
eventStore [memory, jsonfile] default memory
}
How it works:
- Writes:
repo.save()updates a snapshot cache; events are appended by the wrapper - Reads:
repo.findById()reads events viagetByAggregateId(), returnsresultfrom the latest event; falls back to snapshot cache - No user code needed: Handlers remain unchanged. Event sourcing is a pure infrastructure concern.
Since events carry result (the full entity state after the handler ran), reconstruction is trivially event.result. No user-written apply/fold function is needed.
See the ledger example for a working event-sourced domain.
Future Directions
Testing Support (Phase 6 - not started)
Better testing utilities:
TestEventEmitterwithgetEvents()for assertions- Example patterns for event-based testing
DSL Operation Replay (Phase 7 - deferred)
Storing and replaying DSL operation calls. Would enable:
- Command sourcing
- Scenario replay for testing
- Migration scripts
Generated Files
For an app with events, these files are generated in services/:
| File | Purpose |
|---|---|
event-emitter.ts |
EventEmitter interface |
event-emitter-inmemory.ts |
In-memory implementation |
event-subscriber.ts |
EventSubscriber interface with typed methods |
event-subscriber-registry.ts |
Registry implementation |
subscriber-bootstrap.ts |
Wiring layer for startup |
event-store.ts |
EventStore interface |
event-store-inmemory.ts |
In-memory implementation |
event-store-jsonfile.ts |
JSON file implementation |
event-store-redis.ts |
Redis implementation |
event-store-registry.ts |
Runtime backend selection |
Subscribers are generated in subscribers/{name}/:
| File | Purpose |
|---|---|
index.ts |
Subscriber interface |
impl.ts |
Scaffold implementation (hand-edit this) |