CloudEvents Specification Evaluation¶
Status: Evaluated — Partial Adoption Created: February 2026 Scope: Forma 3D Connect — Cross-Service Event Format Outcome: Added
eventIdandsourcefields toServiceEvent; full CloudEvents adoption deferred
Table of Contents¶
- Executive Summary
- What is CloudEvents?
- Current Event System
- Gap Analysis
- Benefits of Full Adoption
- Costs of Full Adoption
- Recommendation
- What We Adopted
- When to Revisit
- 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-commonfor 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+sourceonly: ~2 hours
7. Recommendation¶
Do not adopt full CloudEvents compliance. Instead, cherry-pick the two useful concepts:
eventId(UUID v4) — unique event identifier enabling idempotency checkssource(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