Skip to content

CloudEvents Specification Evaluation

Status: Evaluated — Partial Adoption Created: February 2026 Scope: Forma 3D Connect — Cross-Service Event Format Outcome: Added eventId and source fields to ServiceEvent; full CloudEvents adoption deferred

Table of Contents

  1. Executive Summary
  2. What is CloudEvents?
  3. Current Event System
  4. Gap Analysis
  5. Benefits of Full Adoption
  6. Costs of Full Adoption
  7. Recommendation
  8. What We Adopted
  9. When to Revisit
  10. References

1. Executive Summary

We evaluated the CloudEvents specification (v1.0.2, CNCF Graduated project) to determine whether our cross-service event format should adopt it. CloudEvents standardises event metadata for interoperability between independent systems.

Conclusion: Full CloudEvents compliance is not justified for our current architecture. Our events are purely internal, transported over BullMQ (not HTTP/Kafka), and all services share a common TypeScript library (@forma3d/service-common). The interoperability problem CloudEvents solves does not exist in our system.

However, two CloudEvents concepts — unique event ID and source identifier — address real gaps in our current ServiceEvent interface. We adopted these as eventId (UUID v4) and source (service name string), providing the practical benefits without the full specification overhead.


2. What is CloudEvents?

CloudEvents is a CNCF Graduated specification (since January 2024) for describing event data in a common way. It defines a minimal set of required attributes that every event must carry:

Attribute Type Required Purpose
specversion String Yes CloudEvents spec version (e.g. "1.0")
id String Yes Unique event identifier
source URI-reference Yes Producer identity (e.g. "/order-service")
type String Yes Event type (e.g. "com.forma3d.order.created")
time Timestamp No When the event was produced (ISO 8601)
subject String No Subject of the event in context of the source
datacontenttype String No Content type of the data field
data Any No The event payload

Key design principles: - Domain-specific payload goes inside data, not at the top level - Protocol bindings exist for HTTP, Kafka, AMQP, MQTT, NATS, WebSockets - SDKs available for Go, JavaScript, Java, C#, Ruby, PHP, Python, Rust, PowerShell - JSON format uses media type application/cloudevents+json

A CloudEvents-compliant version of our order.created event would look like:

{
  "specversion": "1.0",
  "id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
  "source": "/order-service",
  "type": "com.forma3d.order.created",
  "time": "2026-02-17T20:00:00Z",
  "datacontenttype": "application/json",
  "tenantid": "tenant-abc",
  "correlationid": "corr-xyz",
  "data": {
    "orderId": "order-123",
    "lineItems": [
      { "lineItemId": "li-1", "productSku": "GRID-2x3", "quantity": 1 }
    ]
  }
}

3. Current Event System

Architecture

The system uses a two-layer event architecture:

Layer Transport Scope Library
Cross-service BullMQ (Redis) Between microservices @forma3d/service-common BullMQEventBus
Internal NestJS EventEmitter2 Within a single service process @nestjs/event-emitter

Cross-service event format (before this evaluation)

interface ServiceEvent {
  eventType: string;       // Queue name (e.g. 'order.created')
  tenantId: string;        // Multi-tenancy
  timestamp: string;       // ISO 8601
  correlationId?: string;  // Distributed tracing
}

Domain-specific fields are flattened into the event body (not nested under data):

interface OrderCreatedEvent extends ServiceEvent {
  eventType: 'order.created';
  orderId: string;
  lineItems: Array<{ lineItemId: string; productSku: string; quantity: number }>;
}

Scale

  • 4 microservices (Order, Print, Shipping, GridFlock)
  • 11 cross-service event types
  • ~30 internal event types
  • All services share @forma3d/service-common for event interfaces
  • BullMQ handles JSON serialization automatically

4. Gap Analysis

CloudEvents field Our equivalent Gap Impact
specversion (none) Missing None — irrelevant for internal events
id (none) Missing High — no way to deduplicate replayed events without checking business state
source BaseEvent.source (internal only) Missing on cross-service events Medium — harder to trace event origin in logs and dead letter queues
type eventType Equivalent None
time timestamp Equivalent None
subject (implicit in payload) Not explicit Low — our events carry explicit IDs (orderId, printJobId)
datacontenttype (implicit — always JSON) Not needed None — BullMQ always serializes as JSON
data (nested) (flattened) Structural difference Low — flat structure is simpler for internal use
(none) tenantId Would be CE extension N/A
(none) correlationId Would be CE extension N/A

Key finding: The two real gaps are id (unique event identifier) and source (producer identity on cross-service events). Both are independently useful regardless of CloudEvents.


5. Benefits of Full Adoption

Would benefit us

  • Unique event ID — enables idempotency checks without business-logic queries
  • Source field — improves tracing and debugging in multi-service flows
  • Industry standard — familiar to new developers who've used CloudEvents elsewhere

Would NOT benefit us (currently)

  • Protocol bindings — we use BullMQ, not HTTP/Kafka/AMQP. No CE binding exists for BullMQ
  • SDK interoperability — all our services are TypeScript sharing a common library
  • Cross-organization events — our events are purely internal, no external consumers
  • Schema registry — we already have TypeScript interfaces as our schema contract
  • Media type (application/cloudevents+json) — BullMQ serialization handles this
  • specversion — no value for internal events where we control both sides

6. Costs of Full Adoption

Code changes required

Change Files affected Effort
Wrap all payloads in data 11 event interfaces + all publishers/subscribers High
Add specversion, id, source to every event 4 EventPublisherService files Medium
Update all @OnEvent() handlers that destructure event fields ~20 handlers across 4 services High
Update Socket.IO event broadcasting EventsGateway Medium
Update EventLog serialization + Zod schemas event-metadata.schema.ts + EventLogService Medium
Add CloudEvents SDK dependency package.json Low
Update all tests Scattered across services High

Structural drawback

With CloudEvents, accessing event data requires an extra level of nesting:

// Current (flat — simple)
handler(event: OrderCreatedEvent) {
  const orderId = event.orderId;
}

// CloudEvents (nested — more ceremony)
handler(event: CloudEvent<OrderCreatedData>) {
  const orderId = event.data.orderId;
}

For purely internal events where we control both producer and consumer, the flat structure is simpler and more ergonomic.

Estimated effort

  • Full CloudEvents adoption: 3–5 days (refactoring + testing + docs)
  • Adding eventId + source only: ~2 hours

7. Recommendation

Do not adopt full CloudEvents compliance. Instead, cherry-pick the two useful concepts:

  1. eventId (UUID v4) — unique event identifier enabling idempotency checks
  2. source (string) — service name for tracing (e.g. 'order-service')

This gives us the practical benefits at ~2% of the adoption cost, with no structural refactoring.


8. What We Adopted

Updated ServiceEvent interface

interface ServiceEvent {
  eventId: string;           // UUID v4 — unique event identifier for idempotency
  eventType: string;         // Queue name (e.g. 'order.created')
  source: string;            // Source service (e.g. 'order-service')
  tenantId: string;          // Tenant identifier for multi-tenancy
  timestamp: string;         // ISO 8601
  correlationId?: string;    // For distributed tracing
}

Changes made

File Change
libs/service-common/src/lib/events/event-types.ts Added eventId and source to ServiceEvent interface
libs/service-common/src/lib/events/bullmq-event-bus.ts Enhanced logging to include eventId and source
apps/order-service/src/events/event-publisher.service.ts Populate eventId (via crypto.randomUUID()) and source ('order-service')
apps/print-service/src/events/event-publisher.service.ts Populate eventId and source ('print-service')
apps/shipping-service/src/events/event-publisher.service.ts Populate eventId and source ('shipping-service')
apps/gridflock-service/src/events/event-publisher.service.ts Populate eventId and source ('gridflock-service')
docs/03-architecture/events/README.md Updated Base Event Interface section
docs/_internal/prompts/done/prompt-eventcatalog-architecture-documentation.md Updated schema reference

How eventId enables idempotency

Before: handlers had to check business state to detect duplicates (e.g., "is this order already marked as completed?").

After: handlers can optionally check eventId against a processed-events set/table:

async handlePrintJobCompleted(event: PrintJobCompletedEvent): Promise<void> {
  // Quick idempotency check using eventId
  if (await this.isEventProcessed(event.eventId)) {
    this.logger.debug(`Event ${event.eventId} already processed, skipping`);
    return;
  }

  // Process the event...
  await this.markEventProcessed(event.eventId);
}

This is optional — existing business-logic idempotency checks remain valid. The eventId provides an additional, more efficient deduplication mechanism.

How source improves debugging

Log output before:

Published event order.created for tenant tenant-abc
Processing event order.created (job 123)

Log output after:

Published event order.created [f47ac10b-...] from order-service for tenant tenant-abc
Processing event order.created [f47ac10b-...] from order-service (job 123)

Dead letter queue investigation is also easier — you can immediately see which service produced a failed event.


9. When to Revisit

Re-evaluate full CloudEvents adoption if any of these conditions become true:

Trigger Why CloudEvents would help
External event consumers (partners, webhooks) Standard format reduces integration effort for third parties
Migration to Kafka or event mesh CloudEvents protocol bindings for Kafka add real value (headers, schema registry)
CNCF ecosystem integration (Knative, Argo Events, Azure Event Grid) These tools natively speak CloudEvents
Public event API / marketplace Standard format is expected by external developers
Multi-language services (non-TypeScript consumers) CloudEvents SDKs provide cross-language interoperability

At that point, migration from our current format would be straightforward since the base fields already align conceptually with CloudEvents:

Current field CloudEvents equivalent
eventId id
eventType type (with reverse-DNS prefix, e.g. com.forma3d.order.created)
source source (as URI-reference, e.g. /order-service)
timestamp time
tenantId Custom extension attribute
correlationId Custom extension attribute (or traceparent from Distributed Tracing extension)

The main structural change would be moving domain fields into a data envelope.


10. References

  • CloudEvents Specification v1.0.2: https://github.com/cloudevents/spec/blob/v1.0.2/cloudevents/spec.md
  • CloudEvents JSON Format: https://github.com/cloudevents/spec/blob/v1.0.2/cloudevents/formats/json-format.md
  • CloudEvents Primer: https://github.com/cloudevents/spec/blob/v1.0.2/cloudevents/primer.md
  • CloudEvents Website: https://cloudevents.io
  • CloudEvents JavaScript SDK: https://github.com/cloudevents/sdk-javascript
  • CNCF Graduation Announcement (Jan 2024): https://www.cncf.io/projects/cloudevents/
  • Internal Event Catalog: docs/03-architecture/events/README.md
  • Event Type Definitions: libs/service-common/src/lib/events/event-types.ts