Lookups
projection/lookup is the built-in pattern for event-driven reverse lookups and uniqueness checks.
If you need to answer questions like "which aggregate owns this email?" or "is this slug already in use?", start here instead of building a custom map from scratch.
When to use a lookup
Use lookup.Lookup when:
- the index is derived from events
- the index should rebuild itself on startup from the event store
- you need reverse lookups by a field other than aggregate UUID
- you want a reusable component that other services or handlers can depend on
Use a custom in-memory map when the data is private to one projection or handler and you do not need the lookup behavior as a reusable application component.
The core pieces
The main types are:
lookup.Lookup- the running lookup projectionlookup.Data- implemented by event data that wants to update the lookuplookup.Provider- used insideProvideLookup(...)to set or remove keyslookup.Contains(...)- convenience helper for "does this aggregate have a value for this key?"
The lookup listens to a set of events, applies any lookup.Data payloads it sees, and keeps an in-memory index up to date.
Minimal example
Imagine a user aggregate that needs a unique email address:
package auth
import (
"context"
"errors"
"strings"
"github.com/google/uuid"
"github.com/modernice/goes/aggregate"
"github.com/modernice/goes/event"
"github.com/modernice/goes/projection/lookup"
)
const (
UserAggregate = "auth.user"
UserRegistered = "auth.user.registered"
UserEmailChanged = "auth.user.email_changed"
UserDeleted = "auth.user.deleted"
UserEmail = "email"
)
type UserRegisteredData struct {
Email string
}
func (data UserRegisteredData) ProvideLookup(p lookup.Provider) {
p.Provide(UserEmail, strings.ToLower(data.Email))
}
type UserEmailChangedData struct {
Email string
}
func (data UserEmailChangedData) ProvideLookup(p lookup.Provider) {
p.Provide(UserEmail, strings.ToLower(data.Email))
}
type UserDeletedData struct{}
func (UserDeletedData) ProvideLookup(p lookup.Provider) {
p.Remove(UserEmail)
}
type User struct {
*aggregate.Base
Email string
}
func NewUser(id uuid.UUID) *User {
u := &User{Base: aggregate.New(UserAggregate, id)}
event.ApplyWith(u, u.registered, UserRegistered)
event.ApplyWith(u, u.emailChanged, UserEmailChanged)
event.ApplyWith(u, u.deleted, UserDeleted)
return u
}
func (u *User) Register(ctx context.Context, emails *lookup.Lookup, email string) error {
email = strings.ToLower(email)
if id, ok := emails.Reverse(ctx, UserAggregate, UserEmail, email); ok && id != u.AggregateID() {
return errors.New("email already in use")
}
aggregate.Next(u, UserRegistered, UserRegisteredData{Email: email})
return nil
}
func (u *User) registered(evt event.Of[UserRegisteredData]) {
u.Email = evt.Data().Email
}
func (u *User) emailChanged(evt event.Of[UserEmailChangedData]) {
u.Email = evt.Data().Email
}
func (u *User) deleted(event.Of[UserDeletedData]) {
u.Email = ""
}Running the lookup
Create the lookup from the event store, event bus, and the event names that can update it:
emails := lookup.New(store, bus, []string{
UserRegistered,
UserEmailChanged,
UserDeleted,
})
errs, err := emails.Run(ctx)
if err != nil {
return err
}
go logErrors(errs)
<-emails.Ready()Run(...) starts a continuous projection internally. Ready() closes after the first catch-up job has been applied.
TIP
Lookup(...), Reverse(...), and Contains(...) wait for readiness internally. Still, if your service should be fully warm before it serves requests, wait on <-l.Ready() during startup instead of letting the first request block.
Querying the lookup
Use Reverse(...) to find the aggregate by value:
id, ok := emails.Reverse(ctx, UserAggregate, UserEmail, "alice@example.com")Use Lookup(...) to fetch the value stored for a specific aggregate and key:
value, ok := emails.Lookup(ctx, UserAggregate, UserEmail, userID)Use lookup.Contains(...) when you only care whether a key exists for a specific aggregate:
if lookup.Contains[string](ctx, emails, UserAggregate, UserEmail, userID) {
// user currently has an indexed email
}Update and delete behavior
Provide(...) replaces the previous value for the same key before storing the new one. That makes update events simple: just provide the new value.
func (data UserEmailChangedData) ProvideLookup(p lookup.Provider) {
p.Provide(UserEmail, strings.ToLower(data.Email))
}Remove(...) clears one or more keys for the current aggregate:
func (UserDeletedData) ProvideLookup(p lookup.Provider) {
p.Remove(UserEmail)
}Two details matter in practice:
- reverse lookups only work for comparable values
- non-comparable values can still be stored and retrieved with
Lookup(...), but not indexed byReverse(...)
Advanced example: multi-key uniqueness
This pattern shows up often in application services: one aggregate needs multiple unique identifiers, all maintained from a single event stream.
package catalog
import (
"context"
"fmt"
"github.com/google/uuid"
"github.com/gosimple/slug"
"github.com/modernice/goes/event"
"github.com/modernice/goes/projection/lookup"
)
const (
PropertyAggregate = "catalog.property"
PropertyUpdated = "catalog.property.updated"
LookupSlug = "slug"
LookupInternalName = "internal_name"
)
type PropertyUpdatedData struct {
Name string
InternalName string
}
func (data PropertyUpdatedData) ProvideLookup(p lookup.Provider) {
p.Provide(LookupSlug, slug.Make(data.Name))
p.Provide(LookupInternalName, data.InternalName)
}
type PropertyFactory struct {
*lookup.Lookup
}
func NewPropertyFactory(store event.Store, bus event.Bus) *PropertyFactory {
return &PropertyFactory{
Lookup: lookup.New(store, bus, []string{PropertyUpdated}),
}
}
func (pf *PropertyFactory) TestNameUpdate(propertyID uuid.UUID, name, internalName string) error {
<-pf.Ready()
if id, ok := pf.Reverse(context.Background(), PropertyAggregate, LookupSlug, slug.Make(name)); ok && id != propertyID {
return fmt.Errorf("slug already in use")
}
if id, ok := pf.Reverse(context.Background(), PropertyAggregate, LookupInternalName, internalName); ok && id != propertyID {
return fmt.Errorf("internal name already in use")
}
return nil
}This is a good fit for lookup.Lookup because:
- the rules are derived from events, not an external SQL uniqueness constraint
- the lookup is reusable from multiple workflows
- startup replay reconstructs the index without a custom bootstrap path
Lookup vs custom event handler
You can implement the same behavior with event/handler, but lookup.Lookup should be your default when the component is fundamentally a reverse index.
Choose lookup.Lookup when:
- you want keyed lookups by aggregate ID and reverse lookups by value
- you want update and removal semantics packaged for you
- you want a small, reusable abstraction that other code can depend on
Choose a custom event handler when:
- the state shape is not really a key/value lookup
- you need custom in-memory structures or side effects
- the component is more observer than index
Next steps
- For general read-model patterns, see Projections.
- For lower-level in-memory observers and caches, see Event Handlers.