go 104 lines · 3 tabs

Custom MarshalJSON for Domain Events With a Type Discriminator Envelope

Shared by codesnips Aug 2026
3 tabs
package domain

import "time"

type DomainEvent interface {
	EventName() string
}

type OrderPlaced struct {
	OrderID    string    `json:"order_id"`
	CustomerID string    `json:"customer_id"`
	TotalCents int64     `json:"total_cents"`
	PlacedAt   time.Time `json:"placed_at"`
}

func (OrderPlaced) EventName() string {
	return "order.placed"
}

type OrderShipped struct {
	OrderID    string `json:"order_id"`
	Carrier    string `json:"carrier"`
	TrackingNo string `json:"tracking_no"`
}

func (OrderShipped) EventName() string {
	return "order.shipped"
}
3 files · go Explain with highlit

Serializing a set of related domain events to JSON is deceptively tricky in Go because encoding/json produces a flat object with no notion of which concrete type it came from. When several event types share a stream and later need to be decoded, the JSON must carry a type discriminator so a reader can dispatch to the right struct. This snippet shows how to attach that discriminator with a custom MarshalJSON while keeping the per-event payloads clean.

In events.go, each event is an ordinary struct — OrderPlaced and OrderShipped — with normal json tags. What unifies them is the DomainEvent interface, whose EventName() method returns a stable string identifier such as order.placed. Keeping the name on the type rather than a field means the discriminator can never drift out of sync with the struct, and it stays out of the business fields entirely.

The envelope.go tab holds the marshaling logic. The Envelope struct wraps a DomainEvent along with metadata like AggregateID and OccurredAt. Its MarshalJSON builds the discriminator envelope by hand: it marshals the inner event first, then constructs an anonymous struct with a type field set from EventName() and a data field of json.RawMessage holding the already-encoded payload. Using json.RawMessage is the key trick — it prevents double-encoding and lets the nested bytes pass through verbatim rather than being escaped into a string.

A common pitfall is to embed the event directly and let the type field be computed in the same struct, which risks infinite recursion if the method is defined on the wrong receiver. Marshaling the inner value through a distinct call avoids that. The main.go tab wires it together, marshaling a slice of envelopes to show that heterogeneous events serialize into one uniform, self-describing shape.

This pattern is what most event stores and message buses rely on: a thin, uniform outer frame plus an opaque payload. It trades a little manual code for forward compatibility, since new event types only need to implement DomainEvent and pick a name. The symmetric UnmarshalJSON, reading type and dispatching into a registry, is the natural next step.


Related snips

Share this code

Here's the card — post it anywhere.

Custom MarshalJSON for Domain Events With a Type Discriminator Envelope — share card
Link copied