Code Generation¶
Overview¶
The eventmap-gen tool generates type-safe mappings between your domain event structs and the event store's store.Event / store.PersistedEvent types. It bridges your domain layer and your persistence layer without coupling them.
Why code generation?
- Domain events stay pure. Your event structs are plain Go types with no pupsourcing imports. They live in your domain layer and depend on nothing.
- No reflection at runtime. The generated code is explicit, readable, and fast. You can inspect exactly what happens during serialization and deserialization.
- Type safety. Generic helpers catch mismatches at compile time. Per-event helpers give you exhaustive, named functions for every event and version.
- Versioning built in. The generator understands versioned subdirectories (
v1/,v2/, …) and produces dispatchers that route on event type and version automatically.
Installation¶
eventmap-gen is distributed as a Go tool inside the store module. With Go 1.24+ you can add it to your module's tool dependencies:
go get -tool github.com/pupsourcing/store/cmd/eventmap-gen
This records the tool in your go.mod so every developer on the team gets the same version.
Run the generator with:
go tool eventmap-gen \
-input domain/user/events \
-output infrastructure/persistence \
-package persistence
go generate Integration¶
Add a //go:generate directive in your persistence package so the mapping stays up to date automatically:
//go:generate go tool eventmap-gen -input domain/user/events -output infrastructure/persistence -package persistence
Then run:
go generate ./...
Quick Start¶
1. Define Domain Events¶
Create versioned directories under your domain package:
domain/user/events/
└── v1/
├── user_registered.go
└── user_email_changed.go
Each file contains a plain Go struct:
// domain/user/events/v1/user_registered.go
package v1
import "time"
type UserRegistered struct {
Email string `json:"email"`
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
}
// domain/user/events/v1/user_email_changed.go
package v1
type UserEmailChanged struct {
OldEmail string `json:"old_email"`
NewEmail string `json:"new_email"`
}
2. Run the Generator¶
go tool eventmap-gen \
-input domain/user/events \
-output infrastructure/persistence \
-package persistence
This produces infrastructure/persistence/event_mapping.gen.go (and a corresponding test file).
3. Use the Generated Code¶
Appending events (command handler):
import (
"github.com/pupsourcing/store"
v1 "myapp/domain/user/events/v1"
"myapp/infrastructure/persistence"
)
func (h *Handler) RegisterUser(ctx context.Context, cmd RegisterUserCommand) error {
domainEvents := []any{
v1.UserRegistered{
Email: cmd.Email,
Name: cmd.Name,
CreatedAt: time.Now(),
},
}
storeEvents, err := persistence.ToESEvents("User", cmd.UserID, domainEvents)
if err != nil {
return err
}
_, err = h.store.AppendEvents(ctx, "User", cmd.UserID, storeEvents, store.AppendCondition{})
return err
}
Reading events (projection handler):
func (p *UserProjection) Handle(ctx context.Context, pe store.PersistedEvent) error {
domainEvent, err := persistence.FromESEvent(pe)
if err != nil {
return err
}
switch e := domainEvent.(type) {
case v1.UserRegistered:
return p.onUserRegistered(ctx, pe, e)
case v1.UserEmailChanged:
return p.onUserEmailChanged(ctx, pe, e)
default:
return nil // unknown event type — skip
}
}
Domain Events¶
Domain events are plain Go structs that describe something that happened in your domain.
Rules¶
- No pupsourcing imports. Domain events belong to your domain layer and must not depend on infrastructure packages.
- Exported structs only. The generator scans for exported (capitalized) struct types.
- JSON tags for serialization. The generated code marshals events to JSON for storage. Add
jsonstruct tags to control field names. - One struct per file is recommended but not required. The generator finds all exported structs in the scanned packages.
Example¶
package v1
import "time"
type OrderPlaced struct {
CustomerID string `json:"customer_id"`
Items []Item `json:"items"`
Total int64 `json:"total"`
PlacedAt time.Time `json:"placed_at"`
}
type Item struct {
ProductID string `json:"product_id"`
Quantity int `json:"quantity"`
Price int64 `json:"price"`
}
Nested types (like Item above) are serialized as part of the parent event. Only top-level structs in versioned directories become event types.
Versioned Events¶
Event schemas evolve over time. Instead of mutating existing structs (which would break deserialization of historical events), you create a new version.
When to Create a New Version¶
- A required field is added or removed.
- A field's type or semantics change.
- The event's structure is significantly reorganized.
You do not need a new version for additive, backward-compatible changes like adding an optional field with a zero-value default.
Directory Structure¶
domain/user/events/
├── v1/
│ ├── user_registered.go // type UserRegistered struct { Email, Name }
│ └── user_email_changed.go // type UserEmailChanged struct { OldEmail, NewEmail }
└── v2/
└── user_registered.go // type UserRegistered struct { Email, Name, PhoneNumber, Locale }
Both v1.UserRegistered and v2.UserRegistered coexist. Old events in the store deserialize to v1.UserRegistered; new events use v2.UserRegistered.
How the Generator Resolves Versions¶
The generator maps each struct to an event type name (the struct name) and a version (the directory number). For the structure above it produces:
| Event Type | Version | Go Type |
|---|---|---|
UserRegistered |
1 | v1.UserRegistered |
UserRegistered |
2 | v2.UserRegistered |
UserEmailChanged |
1 | v1.UserEmailChanged |
Consuming Mixed Versions¶
Use a type switch in your projection handler to handle both versions:
func (p *UserProjection) Handle(ctx context.Context, pe store.PersistedEvent) error {
domainEvent, err := persistence.FromESEvent(pe)
if err != nil {
return err
}
switch e := domainEvent.(type) {
case v1.UserRegistered:
return p.onUserRegisteredV1(ctx, pe, e)
case v2.UserRegistered:
return p.onUserRegisteredV2(ctx, pe, e)
case v1.UserEmailChanged:
return p.onUserEmailChanged(ctx, pe, e)
}
return nil
}
Generated Functions¶
Type Resolution¶
func EventTypeOf(e any) (string, error)
Returns the event type name for a domain event struct. Useful when you need the string representation without converting to a full store.Event.
eventType, err := persistence.EventTypeOf(v1.UserRegistered{})
// eventType == "UserRegistered"
Domain → Store Events (Batch)¶
func ToESEvents[T any](aggregateType, aggregateID string, events []T, opts ...Option) ([]store.Event, error)
Converts a slice of domain events into []store.Event ready for appending. The generic parameter lets you pass []any or a typed slice.
storeEvents, err := persistence.ToESEvents("User", userID, []any{
v1.UserRegistered{Email: "alice@example.com", Name: "Alice"},
v1.UserEmailChanged{OldEmail: "alice@example.com", NewEmail: "alice@newdomain.com"},
})
Store Events → Domain (Batch)¶
func FromESEvents[T any](events []store.PersistedEvent) ([]T, error)
Converts a slice of store.PersistedEvent back into domain structs.
domainEvents, err := persistence.FromESEvents[any](persistedEvents)
Store Event → Domain (Single)¶
func FromESEvent(pe store.PersistedEvent) (any, error)
Converts a single store.PersistedEvent into the corresponding domain struct. This is the primary function used in projection handlers. It dispatches on EventType and EventVersion:
"UserRegistered"+ version1→v1.UserRegistered"UserRegistered"+ version2→v2.UserRegistered
Per-Event Type-Safe Helpers¶
For every event type and version, the generator produces a pair of named functions:
// Convert domain struct → store.Event
func ToUserRegisteredV1(aggregateType, aggregateID string, e v1.UserRegistered, opts ...Option) (store.Event, error)
func ToUserRegisteredV2(aggregateType, aggregateID string, e v2.UserRegistered, opts ...Option) (store.Event, error)
func ToUserEmailChangedV1(aggregateType, aggregateID string, e v1.UserEmailChanged, opts ...Option) (store.Event, error)
// Convert store.PersistedEvent → domain struct
func FromUserRegisteredV1(pe store.PersistedEvent) (v1.UserRegistered, error)
func FromUserRegisteredV2(pe store.PersistedEvent) (v2.UserRegistered, error)
func FromUserEmailChangedV1(pe store.PersistedEvent) (v1.UserEmailChanged, error)
Use these when you know the exact event type at compile time and want full type safety without a type assertion.
Options¶
Options inject metadata fields into the generated store.Event:
func WithCausationID(id string) Option
func WithCorrelationID(id string) Option
func WithTraceID(id string) Option
func WithMetadata(metadata []byte) Option
Pass options to ToESEvents or any ToXxxVN helper:
storeEvents, err := persistence.ToESEvents("User", userID, domainEvents,
persistence.WithCorrelationID(correlationID),
persistence.WithCausationID(commandID),
persistence.WithTraceID(traceID),
)
Usage in Projections¶
Projections (consumers) receive store.PersistedEvent values and need to convert them back to domain structs. The FromESEvent function handles dispatching:
type UserReadModel struct {
db *sql.DB
}
func (p *UserReadModel) Handle(ctx context.Context, pe store.PersistedEvent) error {
domainEvent, err := persistence.FromESEvent(pe)
if err != nil {
return err
}
switch e := domainEvent.(type) {
case v1.UserRegistered:
_, err = p.db.ExecContext(ctx,
`INSERT INTO users (id, email, name, created_at) VALUES ($1, $2, $3, $4)`,
pe.AggregateID, e.Email, e.Name, e.CreatedAt,
)
case v1.UserEmailChanged:
_, err = p.db.ExecContext(ctx,
`UPDATE users SET email = $1 WHERE id = $2`,
e.NewEmail, pe.AggregateID,
)
default:
return nil
}
return err
}
Usage in Command Handlers¶
Command handlers produce domain events and need to convert them to store.Event for appending:
func (h *UserCommandHandler) Handle(ctx context.Context, cmd ChangeEmailCommand) error {
// Load current state
events, err := h.store.LoadEvents(ctx, "User", cmd.UserID, nil)
if err != nil {
return err
}
domainEvents, err := persistence.FromESEvents[any](events)
if err != nil {
return err
}
// Apply business logic
user := rebuildUser(domainEvents)
if user.Email == cmd.NewEmail {
return errors.New("email unchanged")
}
// Produce new events
newEvents := []any{
v1.UserEmailChanged{
OldEmail: user.Email,
NewEmail: cmd.NewEmail,
},
}
storeEvents, err := persistence.ToESEvents("User", cmd.UserID, newEvents,
persistence.WithCorrelationID(cmd.CorrelationID),
)
if err != nil {
return err
}
_, err = h.store.AppendEvents(ctx, "User", cmd.UserID, storeEvents, store.AppendCondition{
ExpectedVersion: &user.Version,
})
return err
}
CLI Reference¶
Usage: eventmap-gen [flags]
Flags:
-input string
Directory containing domain event structs.
Must contain versioned subdirectories (v1/, v2/, etc.).
-output string
Directory where generated code will be written.
-package string
Go package name for the generated file.
-module string
Override the Go module path used for imports.
By default, the module path is detected from go.mod.
-filename string
Override the output filename.
Default: event_mapping.gen.go
Examples¶
# Basic usage
go tool eventmap-gen \
-input domain/user/events \
-output infrastructure/persistence \
-package persistence
# Custom filename
go tool eventmap-gen \
-input domain/order/events \
-output internal/order/mapping \
-package mapping \
-filename order_events.gen.go
# Explicit module path
go tool eventmap-gen \
-input domain/user/events \
-output infrastructure/persistence \
-package persistence \
-module github.com/myorg/myapp
Best Practices¶
-
Keep domain events simple. Plain structs with JSON tags. No methods, no interfaces, no external dependencies.
-
Version when the schema changes. If you add a required field, rename a field, or change a field's type, create a new version. Don't modify existing versions — historical events depend on them.
-
Don't delete old versions. Old events in the store are serialized using the old schema. Removing a version breaks deserialization.
-
Use
go generatefor automation. Add//go:generatedirectives to your persistence packages. Rungo generate ./...as part of your CI pipeline to catch stale mappings. -
Prefer the generic
ToESEvents/FromESEvents. They handle mixed event types cleanly. Use the per-event helpers (ToXxxVN,FromXxxVN) only when you need compile-time type safety for a single known event. -
Always handle unknown events gracefully. In projection handlers, add a
defaultcase that returnsnil(skip). This prevents your consumer from crashing when new event types are added before the projection is updated. -
Propagate trace context. Use
WithTraceID,WithCorrelationID, andWithCausationIDto maintain observability across commands and events.
Troubleshooting¶
"no exported structs found"¶
The generator scans the -input directory for versioned subdirectories (v1/, v2/, etc.) containing exported Go structs. Check that:
- Your structs are exported (start with an uppercase letter).
- They are in a versioned subdirectory, not in the root of the input directory.
- The files compile successfully (
go build ./domain/user/events/...).
"module path detection failed"¶
The generator reads go.mod to determine import paths. If your project structure is unusual, pass -module explicitly:
go tool eventmap-gen -input events -output mapping -package mapping -module github.com/myorg/myapp
Generated code doesn't compile¶
- Ensure the
-packageflag matches the package of the output directory. - Ensure all domain event packages compile cleanly before running the generator.
- Run
go mod tidyafter generating to resolve any missing dependencies.
Stale generated code¶
If you've added new events but the generated code doesn't include them, re-run the generator:
go generate ./...
Check that your //go:generate directive points to the correct -input directory.
Duplicate event type names across versions¶
This is expected and correct. v1.UserRegistered and v2.UserRegistered both map to the event type "UserRegistered" but with different version numbers. The generated code dispatches on both type and version.