5. Repositories
We can create products and raise events, but those events only exist in memory on the aggregate. To persist them — and later reconstruct the aggregate by replaying its events — we need a repository.
Set Up the Repository
In cmd/main.go, create a typed repository for products:
import (
// ... existing imports ...
"github.com/modernice/goes/aggregate/repository"
"github.com/yourname/shop"
)
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
eventReg := codec.New()
shop.RegisterProductEvents(eventReg)
store := eventstore.New()
bus := eventbus.New()
store = eventstore.WithBus(store, bus)
// Create the base aggregate repository.
repo := repository.New(store)
// Create a typed repository for products.
products := repository.Typed(repo, shop.NewProduct)
_ = products
// ...
}Save an Aggregate
id := uuid.New()
product := shop.NewProduct(id)
product.Create("Wireless Mouse", 2999, 50) // raises ProductCreated event
// Save persists the uncommitted events to the event store.
if err := products.Save(ctx, product); err != nil {
log.Fatal(err)
}After Save, the events are written to the event store and the aggregate's changes are cleared.
Fetch an Aggregate
// Fetch reads the aggregate's events from the store and replays them.
fetched, err := products.Fetch(ctx, id)
if err != nil {
log.Fatal(err)
}
fmt.Println(fetched.Name) // "Wireless Mouse"
fmt.Println(fetched.Price) // 2999
fmt.Println(fetched.Stock) // 50Fetch does the following:
- Creates a new
Productusing theNewProductfunction you passed torepository.Typed - Queries the event store for all events belonging to this aggregate
- Replays them in order, calling the registered event appliers
- Returns the fully reconstructed aggregate
The Use Pattern
The most common pattern is fetch → modify → save. The Use method wraps this in a single call:
err := products.Use(ctx, id, func(p *shop.Product) error {
return p.Rename("Ergonomic Wireless Mouse")
})Use does:
- Fetch — reconstruct the aggregate from its events
- Call your function — modify the aggregate
- Save — if your function returns
nil, persist the new events
If your function returns an error, the aggregate is not saved.
How Repositories Work
┌─────────────────────────────────┐
│ products.Save(ctx, product) │
│ │
│ 1. Get uncommitted changes │
│ 2. Insert events into store │
│ 3. Clear changes on aggregate │
└─────────────────────────────────┘
┌─────────────────────────────────┐
│ products.Fetch(ctx, id) │
│ │
│ 1. NewProduct(id) │
│ 2. Query events from store │
│ 3. Replay events (ApplyEvent) │
│ 4. Return aggregate │
└─────────────────────────────────┘Why repository.Typed?
repository.New returns a base repository that works with the generic aggregate.Aggregate interface. You have to create the aggregate yourself and pass it in:
repo := repository.New(store)
// Save — pass the aggregate in.
repo.Save(ctx, product)
// Fetch — create the aggregate yourself, then pass it in to be populated.
p := shop.NewProduct(id)
repo.Fetch(ctx, p)
// p is now populated with replayed events.
fmt.Println(p.Name)repository.Typed wraps this with type safety. It uses the NewProduct function you pass in to create aggregates internally, and returns the concrete type:
products := repository.Typed(repo, shop.NewProduct)
// Save — accepts *Product.
products.Save(ctx, product)
// Fetch — creates the aggregate for you and returns *Product.
p, err := products.Fetch(ctx, id)
fmt.Println(p.Name)Throughout this tutorial, we'll always use the typed repository.
Next
We have persistence. In the next chapter, we'll add a command system to decouple intent from execution.