Appearance
internal/platform/domainevent
internal/platform/domainevent is the single home for the three mechanics that every internal/**/events package previously duplicated: the event header (event_id + occurred_at), the outbox JSON marshalling, and the constructor invariant-error format. It is a nested module (internal/platform/domainevent/go.mod) so a consuming context can require it without pulling in the whole internal/platform module, and it is deliberately pure standard library, so it adds no transitive dependency to any consumer.
This document is the authoritative reference for:
See also:
- docs/contributing/layout.md — the module map and the
internal/platform/rationale. - docs/contexts/actions/events.md, docs/contexts/bridge/events.md, docs/contexts/policy/events.md — per-context event surfaces that now source these mechanics from the kit.
The embeddable header
Header[ID] carries the two fields common to every domain event and is embedded as the first field of each event struct, so the promoted event_id and occurred_at keys lead the marshalled JSON object in that order:
go
type Header[ID any] struct {
EventID ID `json:"event_id"`
OccurredAt time.Time `json:"occurred_at"`
}The event-id type is a parameter — not a fixed type — because bounded contexts encode the id differently on the wire and the kit must preserve whatever a context already ships. A value object with a MarshalText method (for example tenancy.ID or access.SessionID) renders as a UUID string, while a raw [16]byte renders as a JSON number array. Making Header generic keeps both encodings byte-identical to the inline fields it replaces.
NewHeader mints a fresh event id through a context-supplied constructor and normalises the timestamp:
go
func NewHeader[ID any](newID func() (ID, error), occurredAt time.Time) (Header[ID], error)A zero occurredAt defaults to time.Now().UTC(); a non-zero value is coerced to UTC so payloads are comparable without timezone ambiguity. The newID error is returned unwrapped, so a context can wrap it in its own namespace if it wants. Per-event validation stays in the constructors — NewHeader only builds the envelope.
The marshal helper
Every event keeps its Marshal method, but the body is a one-line delegation:
go
func (e SomeEvent) Marshal() ([]byte, error) { return domainevent.Marshal(e) }The method is retained because it is the adapter for the unexported outboxEvent interface (EventType() ... ; Marshal() ([]byte, error)) that repository and reconcile code depends on when it appends a typed event to the outbox. Only the encoding body is shared, so the wire form of every event lives in exactly one place while each package keeps its own EventType() discriminator.
The invariant-error helper
Invariants binds a namespace once and returns the event, field, reason helper each package already calls:
go
var errInvariant = domainevent.Invariants("tenancy/events")
// errInvariant("DomainCreated", "DomainID", "must not be zero")
// → "tenancy/events: DomainCreated.DomainID must not be zero"The <namespace>: <event>.<field> <reason> format lives in the single Invariant implementation, so a bus consumer hitting a rejection can grep the originating constructor without re-deriving the context.
The byte-identity guarantee
Adopting the kit is a wire-neutral refactor: event-type literals and JSON shapes stay byte-identical. That guarantee is enforced by golden-payload fixtures under each package's internal/**/events/testdata/ directory — one JSON file per event type, asserted byte-for-byte against the marshalled output. The fixtures were captured from the pre-kit code and continue to pass unchanged after adoption, which is what proves that embedding the generic Header preserves both the header encoding and the top-level field order. Regenerate them with UPDATE_GOLDEN=1 go test ./... in the owning module.