Event Service Agent Kata

ADR-0012: Package Structure - Schemas and Platform Split


Problem

During PL-14 schema migration, we discovered that the @event-service-agent/contracts package has conflicting responsibilities:

  1. Foundational schemas (TenantId, ServiceCallId, etc.) - Effect Schema definitions with runtime validation
  2. Domain message schemas (DueTimeReached, ServiceCallScheduled, etc.) - Would create circular dependencies if imported
  3. Port interfaces (EventBusPort, UUID7Port) - Hexagonal architecture boundaries
  4. Routing configuration (Topics) - Broker subject naming
  5. Shared services (UUID7 implementation)

Core Issue: Attempting to define MessageEnvelope with typed DomainMessage union exposed architectural problems:

Option 1: Define DTO shapes in contracts (REJECTED)

```typescript ignore // contracts defines plain DTO shape import { Effect, Schema } from ‘effect’

export const DomainMessage = Schema.Union( Schema.Struct({ _tag: Schema.Literal(‘DueTimeReached’), tenantId: Schema.String, // ← Lost TenantId brand! }), )

Effect.gen(function* () { // Consumer can’t use decoded payload: const envelope = yield* MessageEnvelope.decodeJson(json) yield* workflow.handle(envelope.payload) // ^^^^^^^^^^^^^^^^ // ❌ TYPE MISMATCH: plain object vs branded types })


**Fatal DX flaw**: Decoded payload loses type information (brands, validation), defeating the purpose of Effect Schema.

### Option 2: Import schemas from modules (REJECTED)

```typescript ignore
// contracts imports from timer
import { DueTimeReached } from '@event-service-agent/timer/domain'

Circular dependency: Timer already imports contracts for TenantId → contracts imports timer → circular.

Violates architecture: Contracts (shared kernel) should not depend on modules (concrete implementations).


Decision

Split contracts into two packages:

1. @event-service-agent/schemas - All Effect Schemas (Foundation)

Purpose: Single source of truth for all Effect Schema definitions (foundational types + domain messages).

Contents:

Dependencies: effect only (self-contained)

Structure:

packages/schemas/
  src/
    shared/
      uuid7.schema.ts
      tenant-id.schema.ts
      service-call-id.schema.ts
      correlation-id.schema.ts
      envelope-id.schema.ts
      iso8601-datetime.schema.ts
    uuid7.service.ts
    messages/
      timer/events.schema.ts
      orchestration/
        events.schema.ts
        commands.schema.ts
      execution/events.schema.ts
      api/commands.schema.ts
    envelope/
      domain-message.schema.ts
      message-envelope.schema.ts
    http/
      request-spec.schema.ts

2. @event-service-agent/platform - Infrastructure Abstractions

Purpose: Port interfaces, routing configuration, and infrastructure abstractions (hexagonal architecture boundaries).

Contents:

Dependencies: effect, @event-service-agent/schemas

Structure:

packages/platform/
  src/
    ports/
      event-bus.port.ts
      uuid.port.ts
    routing/
      topics.ts

Rationale

Why Schemas Package?

  1. Consistency: All Effect Schemas (foundational + domain) in one place
  2. No circular dependencies: Self-contained, only depends on effect
  3. Single source of truth: Domain messages defined once, imported by all modules
  4. Full type power: Branded types preserved through encode/decode
  5. DX Excellence: Pattern matching works perfectly after decode
  6. Clear purpose: “Integration Contracts” bounded context in DDD terms

Why Platform Package?

  1. Separation of concerns: Interfaces separate from implementations
  2. Hexagonal architecture: Ports define boundaries, adapters live in modules
  3. Thin layer: Minimal logic, just contracts and configuration
  4. Depends on schemas: Port signatures use schema types

Why Not Keep Everything in Contracts?

Problem: Name confusion after splitting

Alternative considered: Keep “contracts” name with narrowed scope


Dependency Graph

effect (external)
  ↓
schemas (foundational + domain schemas)
  ↓
platform (ports + routing)
  ↓
modules (timer, orchestration, execution, api)

Key properties:


Migration Impact

Package Renaming

New Package

Files Moved to schemas/

From contracts/src/types/:

From contracts/src/services/:

From timer/src/domain/:

Files Remaining in platform/

Files Removed

Import Path Changes

Before:

```typescript ignore import { TenantId } from ‘@event-service-agent/contracts/types’ import { EventBusPort } from ‘@event-service-agent/contracts/ports’ import { Topics } from ‘@event-service-agent/contracts/routing’


**After**:

```typescript ignore
import { TenantId } from '@event-service-agent/schemas/shared'
import { DueTimeReached } from '@event-service-agent/schemas/messages/timer'
import { MessageEnvelope } from '@event-service-agent/schemas/envelope'
import { EventBusPort } from '@event-service-agent/platform/ports'
import { Topics } from '@event-service-agent/platform/routing'

Module Dependencies Update

Before (timer/package.json):

{
	"dependencies": {
		"@event-service-agent/contracts": "workspace:*"
	}
}

After:

{
	"dependencies": {
		"@event-service-agent/schemas": "workspace:*",
		"@event-service-agent/platform": "workspace:*"
	}
}

Documentation Updates Required

ADRs Referencing “contracts”

Design Documents

Plan Documents


Consequences

Positive

Negative

Neutral


Migration Strategy

Phase 1: Create schemas package

  1. Create packages/schemas/ with package.json
  2. Copy relevant files from contracts (keep originals)
  3. Update imports within schemas package
  4. Verify schemas package builds independently

Phase 2: Rename contracts → platform

  1. Rename directory: contracts/platform/
  2. Update package.json name
  3. Remove files moved to schemas
  4. Update imports within platform
  5. Verify platform builds (depends on schemas)

Phase 3: Update modules

  1. Update timer package.json dependencies
  2. Update timer imports (contracts → schemas + platform)
  3. Repeat for orchestration, execution, api (when implemented)
  4. Verify all tests pass

Phase 4: Update documentation

  1. Update ADRs (especially ADR-0011)
  2. Update design docs (ports.md, messages.md, etc.)
  3. Update module design docs (import examples)
  4. Update plan documents

Phase 5: Cleanup

  1. Remove deprecated files (messages.ts, old interfaces)
  2. Update root package.json workspace config
  3. Verify clean build from scratch
  4. Update README if applicable

Alternatives Considered

A. Keep contracts, define DTO shapes (REJECTED)

B. Move schemas to contracts (REJECTED)

C. Type-only circular imports (REJECTED)

typescript ignore import type { DueTimeReached } from '@event-service-agent/timer/domain'

D. Schemas + Platform Split (ACCEPTED)


References