Skip to content

Events

Domain events are the core building block of Event Sourcing. They represent facts — things that happened in your domain. Once stored, events are immutable and permanent.

An event records a state change that already occurred. RoomBooked means a room was booked. BookingCancelled means a booking was cancelled. Events are always named in the past tense because they describe history.

Events differ from commands: a command is a request that might be rejected (“please book this room”), while an event is a fact that already happened (“the room was booked”). Commands are input, events are output.

In Event Sourcing, events are the source of truth. The current state of any entity is derived by replaying its events. Events are also used to communicate between parts of the system — subscriptions react to events for projections, notifications, and cross-system integration.

Eventuous Go does not require events to implement any interface or embed any base type. Events are simple structs:

type RoomBooked struct {
BookingID string `json:"bookingId"`
RoomID string `json:"roomId"`
CheckIn string `json:"checkIn"`
CheckOut string `json:"checkOut"`
Price float64 `json:"price"`
}
type PaymentRecorded struct {
BookingID string `json:"bookingId"`
Amount float64 `json:"amount"`
}
type BookingCancelled struct {
BookingID string `json:"bookingId"`
Reason string `json:"reason"`
}

Always add JSON tags to your event fields. Events are serialized to JSON for storage and the tags control the field names in the JSON representation. Without tags, Go uses the uppercase field names (BookingID instead of bookingId), which is unconventional for JSON and can cause interoperability issues if you have consumers written in other languages.

Every event type must be registered in a TypeMap with a stable string name before it can be serialized or deserialized.

import "github.com/eventuous/eventuous-go/core/codec"
types := codec.NewTypeMap()
codec.Register[RoomBooked](types, "RoomBooked")
codec.Register[PaymentRecorded](types, "PaymentRecorded")
codec.Register[BookingCancelled](types, "BookingCancelled")

The string name "RoomBooked" is what gets written to the event store alongside the event data. This is the persistent identity of the event type. It must remain stable forever.

If you rename the Go struct from RoomBooked to HotelRoomBooked, the stored events still have "RoomBooked" as their type name. The TypeMap maps the new Go struct to the old name, so deserialization continues to work:

// Even after renaming the struct, the stored name stays the same
codec.Register[HotelRoomBooked](types, "RoomBooked")

Without explicit names, the system would have to derive names from Go types using reflection. That would mean renaming a struct is a breaking change that corrupts your ability to read existing events. Explicit registration prevents this class of bugs entirely.

Registering the same type with the same name is idempotent — calling Register twice with the same arguments does nothing. However, registering the same type with a different name, or a different type with the same name, returns an error:

// OK: idempotent
codec.Register[RoomBooked](types, "RoomBooked")
codec.Register[RoomBooked](types, "RoomBooked") // no error
// Error: type already registered under a different name
codec.Register[RoomBooked](types, "DifferentName")
// Error: name already registered for a different type
codec.Register[SomeOtherEvent](types, "RoomBooked")

The TypeMap is safe for concurrent use. It uses a sync.RWMutex internally, so you can register types from multiple goroutines during initialization without additional synchronization.

The Codec interface handles encoding events to bytes and decoding bytes back to events:

type Codec interface {
Encode(event any) (data []byte, eventType string, contentType string, err error)
Decode(data []byte, eventType string) (event any, err error)
}
  • Encode takes an event struct, returns the serialized bytes, the registered type name, and the content type (e.g., "application/json").
  • Decode takes serialized bytes and the type name, returns the deserialized event struct.

The built-in JSON codec uses encoding/json and a TypeMap:

jsonCodec := codec.NewJSON(types)

The encoding flow:

  1. Look up the event’s Go type in the TypeMap to get the string name
  2. Marshal the event to JSON using json.Marshal
  3. Return the JSON bytes, type name, and "application/json"

The decoding flow:

  1. Look up the string name in the TypeMap to find the Go type
  2. Create a new zero-value instance of that type
  3. Unmarshal the JSON bytes into it using json.Unmarshal
  4. Return the deserialized event as a value (not a pointer)

If you need a different serialization format (Protocol Buffers, MessagePack, etc.), implement the Codec interface. You still need a TypeMap for type resolution, but the encoding/decoding is your own logic.

Best practice: one registration function per bounded context

Section titled “Best practice: one registration function per bounded context”

Group all event registrations for a bounded context in a single function. This makes it easy to find all event types, prevents accidental omissions, and gives you a single call site for initialization:

func RegisterBookingEvents(tm *codec.TypeMap) {
must(codec.Register[RoomBooked](tm, "RoomBooked"))
must(codec.Register[PaymentRecorded](tm, "PaymentRecorded"))
must(codec.Register[BookingCancelled](tm, "BookingCancelled"))
}
func must(err error) {
if err != nil {
panic(err)
}
}

Call this at application startup, before creating the codec or any services:

types := codec.NewTypeMap()
RegisterBookingEvents(types)
jsonCodec := codec.NewJSON(types)