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"
}
package domain
import (
"encoding/json"
"time"
)
type Envelope struct {
AggregateID string
OccurredAt time.Time
Event DomainEvent
}
func (e Envelope) MarshalJSON() ([]byte, error) {
payload, err := json.Marshal(e.Event)
if err != nil {
return nil, err
}
frame := struct {
Type string `json:"type"`
AggregateID string `json:"aggregate_id"`
OccurredAt time.Time `json:"occurred_at"`
Data json.RawMessage `json:"data"`
}{
Type: e.Event.EventName(),
AggregateID: e.AggregateID,
OccurredAt: e.OccurredAt,
Data: payload,
}
return json.Marshal(frame)
}
package main
import (
"encoding/json"
"fmt"
"os"
"time"
"example.com/orders/domain"
)
func main() {
now := time.Now().UTC()
stream := []domain.Envelope{
{
AggregateID: "order-42",
OccurredAt: now,
Event: domain.OrderPlaced{
OrderID: "order-42",
CustomerID: "cust-7",
TotalCents: 12900,
PlacedAt: now,
},
},
{
AggregateID: "order-42",
OccurredAt: now.Add(time.Hour),
Event: domain.OrderShipped{
OrderID: "order-42",
Carrier: "dhl",
TrackingNo: "JD0002",
},
},
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(stream); err != nil {
fmt.Fprintln(os.Stderr, "encode failed:", err)
os.Exit(1)
}
}
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
package api
import (
"net/http"
"runtime/debug"
)
Expose build metadata for debugging deploys
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
package files
import (
"context"
"time"
Presigned S3 upload URLs (AWS SDK v2)
package deps
import (
"net"
"net/http"
"time"
HTTP client tuned for production: timeouts, transport, and connection reuse
package api
import (
"io"
"net/http"
"os"
Safe multipart uploads using temp files (bounded memory)
package pools
import (
"bytes"
"sync"
)
sync.Pool for bytes.Buffer to reduce allocations in hot paths
Share this code
Here's the card — post it anywhere.