| ← Back to Design Docs | ← Documentation Home |
Track future due times and emit DueTimeReached events when time arrives.
Why Timer exists as a separate module:
What Timer does NOT do:
TimerEntry: (tenantId, serviceCallId, dueAt, registeredAt, status)
Why this structure:
(tenantId, serviceCallId) composite key ensures idempotency (upsert semantics)dueAt enables efficient polling queries (WHERE dueAt <= now AND status = 'Scheduled')registeredAt provides audit trail and latency metrics (reachedAt - registeredAt)status field enables two-operation pattern (find due → mark fired) for at-least-once deliveryIDs Generated:
IDs Received (from ScheduleTimer command):
Pattern:
```typescript ignore // Receive IDs from ScheduleTimer command const { tenantId, serviceCallId, dueAt } = command
// Store TimerEntry keyed by (tenantId, serviceCallId) await db.upsert({ tenantId, serviceCallId, dueAt, status: ‘Scheduled’ })
// When firing: construct domain event (validated via Schema) const event = new DueTimeReached({ tenantId, serviceCallId, reachedAt: firedAt, // DateTime.Utc (not ISO string) })
// Publish with MessageMetadata Context (workflow provides) yield * eventBus.publishDueTimeReached(event).pipe( Effect.provideService(MessageMetadata, { correlationId: timer.correlationId, // From timer aggregate causationId: Option.none(), // Time-triggered }), )
**Real Implementation** (timer-event-bus.adapter.ts):
```typescript ignore
// Adapter extracts MessageMetadata from Context
const metadata = yield * MessageMetadata
// Generate envelope ID (UUID v7)
const envelopeId = yield * EnvelopeId.makeUUID7()
// Construct envelope via Schema class (direct instantiation)
const envelope: MessageEnvelope.Type = new MessageEnvelope({
id: envelopeId,
type: dueTimeReached._tag,
payload: dueTimeReached, // Domain event (already validated)
tenantId: dueTimeReached.tenantId,
timestampMs: yield * clock.now(),
correlationId: metadata.correlationId, // From Context
causationId: metadata.causationId, // From Context
aggregateId: Option.some(dueTimeReached.serviceCallId),
})
yield * eventBus.publish([envelope])
Rationale — Why Timer is Stateless Regarding Identity:
Timer doesn’t own the ServiceCall aggregate—it’s an infrastructure service that signals when time elapses. All meaningful identities (TenantId, ServiceCallId, CorrelationId) flow through Timer from Orchestration.
Why EnvelopeId generation happens in adapter, not workflow:
Why causationId is None for timer events:
See ADR-0010 for identity generation strategy, ADR-0011 for schema patterns, and ADR-0013 for MessageMetadata Context pattern.
On ScheduleTimer command received:
(tenantId, serviceCallId) with dueAtScheduler loop (polling worker):
dueAt <= now and status == Scheduledstatus = ReachedSee ADR-0003 for detailed polling strategy rationale and ADR-0006 for idempotency guarantees.
Timer depends on these port abstractions:
Why Timer has minimal ports:
Sequence (Schedule Due Publish)
sequenceDiagram
autonumber
participant TIMER as Timer
participant CLOCK as Clock
participant BUS as EventBus
link TIMER: Doc @ ./timer.md
link BUS: Port @ ../ports.md#eventbusport
TIMER->>CLOCK: now()
alt now >= dueAt
TIMER->>BUS: publish DueTimeReached
else wait
TIMER-->>TIMER: no-op
end