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— theevent_idof the single event that directly caused this one. Walkingcausation_idbackwards reconstructs the parent chain; walkingcorrelation_idcollects 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_hashorevent_ididempotency 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
- NATS Subject Taxonomy wiring a new consumer to the right stream
- Onboarding Lifecycle Events wiring an analytics consumer to onboarding signals
- ProtocolMessage Envelope adding a new wire message type
- Dual Emitter Contract vendoring an emitter into a sister repo