2. Your First Aggregate
An aggregate is the central building block in event sourcing. It's a consistency boundary — a cluster of domain objects that are treated as a single unit. All state changes go through the aggregate, and all changes are recorded as events.
The Product Aggregate
Create product.go. We'll start with a single event — ProductCreated.
package shop
import (
"fmt"
"github.com/google/uuid"
"github.com/modernice/goes/aggregate"
"github.com/modernice/goes/event"
)
// Aggregate name — used to identify this aggregate type in the event store.
const ProductAggregate = "shop.product"
// Event name — past tense, describes what happened.
const ProductCreated = "shop.product.created"
// Event data — the payload carried by the event.
type ProductCreatedData struct {
Name string
Price int // in cents
Stock int
}
// Event type alias — gives a readable name to the typed event.
type ProductCreatedEvent = event.Of[ProductCreatedData]
// ProductDTO holds the read state of a product.
// Including the ID makes the DTO self-contained — when you pass it to
// templates, APIs, or other packages, the ID is always available.
type ProductDTO struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Price int `json:"price"`
Stock int `json:"stock"`
}
// Created reports whether the product has been initialized.
func (dto ProductDTO) Created() bool {
return dto.Name != ""
}
// Product is our first aggregate.
type Product struct {
*aggregate.Base
ProductDTO
}
// NewProduct creates a new Product aggregate with the given ID.
func NewProduct(id uuid.UUID) *Product {
p := &Product{
Base: aggregate.New(ProductAggregate, id),
ProductDTO: ProductDTO{
ID: id,
},
}
// Register event appliers.
event.ApplyWith(p, p.created, ProductCreated)
return p
}
// Create initializes a new product. This is the business method.
func (p *Product) Create(name string, price, stock int) error {
if p.Created() {
return fmt.Errorf("product already created")
}
if name == "" {
return fmt.Errorf("product name is required")
}
if price <= 0 {
return fmt.Errorf("price must be positive")
}
if stock < 0 {
return fmt.Errorf("stock cannot be negative")
}
// Raise the event. This:
// 1. Creates the event with the correct aggregate metadata
// 2. Calls the event applier (p.created)
// 3. Records the event as an uncommitted change
aggregate.Next(p, ProductCreated, ProductCreatedData{
Name: name,
Price: price,
Stock: stock,
})
return nil
}
// Event applier — updates the aggregate's state.
// This method is called both when raising new events AND when replaying
// events from the store.
func (p *Product) created(evt ProductCreatedEvent) {
data := evt.Data()
p.Name = data.Name
p.Price = data.Price
p.Stock = data.Stock
}Key Concepts
aggregate.Base
Every aggregate embeds *aggregate.Base. This provides:
- A UUID identifier
- A name (like
"shop.product") - Version tracking
- Event recording and replay mechanics
p := &Product{
Base: aggregate.New(ProductAggregate, id),
ProductDTO: ProductDTO{
ID: id,
},
}The DTO Pattern
The aggregate embeds a DTO struct (ProductDTO) that holds the actual state. This keeps your state organized and makes the DTO easy to pass around independently — for example, to serialize as a JSON API response or to use in a template.
Including the ID in the DTO means you always have access to the aggregate's identity when working with the DTO directly.
event.ApplyWith
Registers a typed event handler on the aggregate. When an event with the matching name is applied (either from aggregate.Next or from replaying stored events), the handler function is called with full type safety.
event.ApplyWith(p, p.created, ProductCreated)
// ^ ^ ^
// | | event name to match
// | handler function (typed)
// the aggregate (implements event.Registerer)aggregate.Next
Raises a new event on the aggregate. This does three things:
- Creates an
event.Evt[T]with the correct aggregate ID, name, and version - Calls
ApplyEventon the aggregate (which dispatches to your registered handler) - Records the event as an uncommitted change via
RecordChange
aggregate.Next(p, ProductCreated, ProductCreatedData{...})Business Methods vs. Event Appliers
This is a critical distinction:
- Business methods (like
Create) validate input and raise events. They contain your business rules. - Event appliers (like
created) update state. They contain no validation — they just apply the data from the event.
Why? Because event appliers are called when replaying events from the store. Those events already happened — validation would be nonsensical. Appliers must be pure state transitions.
Next
The product can only be created so far. In the next chapter, we'll add more events to manage prices and stock.