CRUMB a card from devarno-cloud

EventEnvelope Wire Wrapper

choco intermediate 4 min read

ELI5

Every NATS message is a parcel with a shipping label on the outside and the actual gift inside. The label tells you what kind of gift it is, who sent it, when, and which earlier parcel triggered this one. Consumers read the label first, then unwrap the payload field only if they care.

Technical Deep Dive

Defined in proto/events/envelope/v1/envelope.proto:14. All producers MUST wrap domain events in this shell; consumers resolve the inner type via the event_type string against payload (google.protobuf.Any).

Field Layout

classDiagram
class EventEnvelope {
string event_id "UUIDv7, time-ordered"
string event_type "domain.action"
string aggregate_type
string aggregate_id
Timestamp created_at
string source_service
string correlation_id "trace-wide"
string causation_id "parent event_id"
string schema_version "semver"
string content_hash "SHA-256 hex of payload bytes"
Any payload
}

Why UUIDv7

event_id is UUIDv7 (envelope.proto:16 comment) so the ID itself sorts by creation time. That removes the need for a separate sort key when replaying a stream chronologically and lets ID-based pagination be monotonic across writers.

Correlation vs Causation

  • correlation_id — same value for every event in the trace (set once at the request boundary).
  • causation_id — the event_id of the single event that directly caused this one. Walking causation_id backwards reconstructs the parent chain; walking correlation_id collects every event in the request.

content_hash

SHA-256 hex of the serialised payload bytes (envelope.proto:43). Lets consumers dedupe at the payload level even when envelope metadata (timestamps, IDs) differs across redelivery — essential because NATS JetStream guarantees at-least-once.

schema_version

Semver string per envelope, governing the inner payload’s schema. Producers bump on payload changes; consumers SHOULD branch on it before deserialising into a generated struct from a specific gen/go/events/<domain>/v1 package.

Key Terms

  • google.protobuf.Any → self-describing wrapper carrying a type URL plus opaque bytes; consumers match the URL against generated message descriptors to deserialise.
  • aggregate_type / aggregate_id → DDD aggregate identifying which entity this event mutates; ordering guarantees apply per-aggregate, not globally.
  • at-least-once → JetStream’s redelivery guarantee; consumers handle duplicates via content_hash or event_id idempotency keys.

Q&A

Q: What goes in aggregate_id for an OnboardingStarted? A: The session_id — the lifecycle proto comment at proto/events/onboarding/v1/lifecycle.proto:159 documents Aggregate: site_id for site-scoped events; per-domain proto comments name the aggregate.

Q: Can a consumer skip content_hash when deduping? A: Only if it dedupes by event_id. content_hash covers the rarer case where the same logical fact is republished with a fresh ID (e.g. cross-region replay).

Q: Where does the type URL for payload come from? A: The proto-generated descriptor for the concrete message; the event_type string is the human-readable form (onboarding.started) and is the index consumers route on.

Examples

A SiteProvisionStateChanged envelope: event_id = UUIDv7 minted at transition time, aggregate_id = the site_id, causation_id = the event_id of the prior SiteProvisionStateChanged (or SiteProvisionRequested for the first hop), correlation_id = whatever the gateway stamped on the inbound HTTP request that began the saga.

neighbors on the map