Skip to content

Event Handlers

event/handler is the lightweight option for long-lived in-memory observers.

Use it when you want to subscribe to events, keep some process-local state warm, or run event-driven side effects without building a full projection model.

When to use event/handler

event/handler is a good fit for:

  • in-memory lookup tables and caches
  • side-effect processors that react to events
  • lightweight observers that should replay history on startup
  • application components that do not need persistence, progress tracking, or projection scheduling

Use a projection instead when you need:

  • persisted read models
  • projection.Progressor
  • debounce or periodic schedules
  • resettable or triggerable projection jobs

If the component is specifically a reverse lookup or uniqueness index, start with Lookups.

And when the component must drive a process rather than observe it — dispatch commands, wait for future events, enforce deadlines, compensate partial progress — use a workflow instead: unlike an event handler, a workflow persists its state and pending effects durably and survives restarts.

Minimal setup

Create a handler from an event bus, register typed handlers with event.HandleWith(...), then run it.

go
package audit

import (
	"sync/atomic"

	"github.com/modernice/goes/event"
	evhandler "github.com/modernice/goes/event/handler"
)

const UserRegistered = "auth.user.registered"

type UserRegisteredData struct {
	Email string
}

type Counter struct {
	*evhandler.Handler
	total atomic.Int64
}

func NewCounter(bus event.Bus) *Counter {
	c := &Counter{
		Handler: evhandler.New(bus),
	}

	event.HandleWith(c, c.userRegistered, UserRegistered)

	return c
}

func (c *Counter) Total() int64 {
	return c.total.Load()
}

func (c *Counter) userRegistered(event.Of[UserRegisteredData]) {
	c.total.Add(1)
}

Then run it:

go
counter := NewCounter(bus)

errs, err := counter.Run(ctx)
if err != nil {
	return err
}
go logErrors(errs)

Startup replay

By default, the handler only sees new events from the bus. Add handler.Startup(store) when the handler should rebuild state from the event store first.

go
h := evhandler.New(bus, evhandler.Startup(store))

This is the key difference between a transient subscriber and an application component that can recover after a restart.

Main example: profile lookup

This pattern is common in applications that need fast in-memory lookups derived from events.

go
package catalog

import (
	"sync"

	"github.com/google/uuid"
	"github.com/modernice/goes/event"
	evhandler "github.com/modernice/goes/event/handler"
	"github.com/modernice/goes/helper/pick"
)

const ProfileCreated = "catalog.profile.created"

type ProfileCreatedData struct {
	PropertyID uuid.UUID
	Language   string
}

type ProfileLookup struct {
	*evhandler.Handler

	mux    sync.RWMutex
	lookup map[uuid.UUID]map[string]uuid.UUID
}

func NewProfileLookup(bus event.Bus, store event.Store) *ProfileLookup {
	pl := &ProfileLookup{
		Handler: evhandler.New(bus, evhandler.Startup(store)),
		lookup:  make(map[uuid.UUID]map[string]uuid.UUID),
	}

	event.HandleWith(pl, pl.profileCreated, ProfileCreated)

	return pl
}

func (pl *ProfileLookup) Profile(propertyID uuid.UUID, language string) (uuid.UUID, bool) {
	pl.mux.RLock()
	defer pl.mux.RUnlock()

	profiles, ok := pl.lookup[propertyID]
	if !ok {
		return uuid.Nil, false
	}

	id, ok := profiles[language]
	return id, ok
}

func (pl *ProfileLookup) profileCreated(evt event.Of[ProfileCreatedData]) {
	pl.mux.Lock()
	defer pl.mux.Unlock()

	data := evt.Data()
	profiles, ok := pl.lookup[data.PropertyID]
	if !ok {
		profiles = make(map[string]uuid.UUID)
		pl.lookup[data.PropertyID] = profiles
	}

	profiles[data.Language] = pick.AggregateID(evt)
}

Why this is a good event/handler use case:

  • the state is process-local and in-memory
  • rebuilding from history is enough; no separate persistence layer is needed
  • the component wants typed event callbacks, not a scheduled projection pipeline

Startup(...) vs StartupQuery(...)

Startup(store, opts...) is the simple option. It tells the handler to query the store before processing new events.

go
import (
	"github.com/modernice/goes/event"
	"github.com/modernice/goes/event/query"
)

h := evhandler.New(bus, evhandler.Startup(
	store,
	query.Name(ProfileCreated),
	query.SortByTime(),
))

When you pass query options directly to Startup(...), they replace the default startup query.

Use StartupQuery(...) when you want to modify the default query instead:

go
h := evhandler.New(
	bus,
	evhandler.Startup(store),
	evhandler.StartupQuery(func(q event.Query) event.Query {
		return query.Merge(q, query.New(query.AggregateName("catalog.profile")))
	}),
)

The rule of thumb:

  • Startup(store, ...) when you know the full startup query you want
  • StartupQuery(...) when you want to tweak the default query shape

Workers

Handlers process events with one worker by default. Increase this with handler.Workers(n) when event callbacks are independent and your state is safe for concurrent access.

go
h := evhandler.New(
	bus,
	evhandler.Startup(store),
	evhandler.Workers(4),
)

Use multiple workers carefully. If handlers mutate shared maps or slices, protect them with a mutex or keep the worker count at 1.

How this differs from projections

event/handler gives you:

  • direct bus subscription
  • typed event callbacks
  • optional startup replay
  • simple process-local state

Projections give you:

  • scheduled jobs
  • Apply(...), Aggregates(...), and other job helpers
  • debounce and periodic execution
  • progress tracking and reset semantics

If you catch yourself needing persistent progress, batch jobs, or projection orchestration, move the component to Projections.