Event Service Agent Kata

Timer Module

← Back to Design Docs ← Documentation Home

Responsibility

Track future due times and emit DueTimeReached events when time arrives.

Why Timer exists as a separate module:

What Timer does NOT do:

Model

TimerEntry: (tenantId, serviceCallId, dueAt, registeredAt, status)

Why this structure:

Identity & Context

IDs 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.

Policies

On ScheduleTimer command received:

Scheduler loop (polling worker):

See ADR-0003 for detailed polling strategy rationale and ADR-0006 for idempotency guarantees.

Ports

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

Inputs/Outputs