Reference
Projector
Reference for go-sequent projectors: the Projector interface with ManagedTables, SyncInTxn balance projections, the Persistor for replay-safe SQL, EventRouter, and rebuild rules.
Projector
Package: projector
A projector turns events into a read model. In go-sequent the ledger read models, such as balances, are projections that update inside the write transaction.
The interface
type Projector interface {
publish.Handler // Handles() []string; Handle(ctx, event.Event) error
ManagedTables() []string // tables this projector owns
}
ManagedTables names the tables the projector owns. The replay pipeline uses it to build, fill and swap those tables.
Register on the façade:
app.RegisterProjector(NewBalanceProjector())
The projector runs on SyncInTxn: after Append, before commit. A returned error rolls back the whole write. See Publisher.
A projector from the bank example
func (p *BalanceProjector) ManagedTables() []string { return []string{"account_balances"} }
func (p *BalanceProjector) Handles() []string {
return []string{
EventAccountOpened, EventMoneyDeposited, EventMoneyWithdrawn,
EventTransferDebited, EventTransferCredited,
}
}
func (p *BalanceProjector) Handle(ctx context.Context, e event.Event) error {
switch ev := e.(type) {
case *AccountOpened:
p.balances[ev.AggregateID()] = ev.Opening
case *MoneyDeposited:
return p.add(ev.AggregateID(), ev.Amount)
case *TransferDebited:
return p.sub(ev.AggregateID(), ev.Amount)
// ...
}
return nil
}
This demo keeps balances in memory. For production, use SQL.
Persistor
projector.Persistor is the only I/O surface a projector should use:
type Persistor interface {
Exec(ctx context.Context, sql string, args ...any) error
QueryRow(ctx context.Context, sql string, args ...any) Row
}
The Postgres persistor joins the transaction already open for the write. Writes go through the same connection as the event append, so a rollback undoes the projection too. It also makes replay possible, because the rebuild pipeline can point the same projector at a different target.
Always use parameterized SQL. Never build statements by string concatenation from event data.
examples/bankpg shows SQLBalanceProjector writing to bank_account_balances this way. See Bank example.
EventRouter
For projectors that dispatch by event type, projector.NewEventRouter(tables) avoids a type switch:
r := projector.NewEventRouter([]string{"account_balances"})
r.On("bank.MoneyDeposited", func(ctx context.Context, e event.Event) error {
// update row
return nil
})
app.RegisterProjector(r)
Handles() is derived from the types you register with On.
Rules for ledger projectors
- One table, one projector. Each managed table is written by exactly one projector.
- Failure must fail the write. Do not swallow errors. A balance that silently skipped an event is a wrong balance.
- Deterministic. The same events must produce the same rows, whether live or in replay.
- Tenant-scoped. Projection tables carry
tenant_id. Filter queries withWHERE tenant_id = $1. - No cross-aggregate foreign keys in projection tables. Only per-aggregate event order is guaranteed.
- No external calls. Anything that leaves the database goes to a workflow hook or the outbox.
Rebuilding
Projection tables are disposable. You can rebuild them from the event store. The pipeline is prepare, replay, catchup, optimize, golive, with abort available. Replay uses PublishProjectorsOnly, so workflows do not run. See Projection rebuild.
After rebuilding, balances stay on SyncInTxn as the source of truth for the live path.
Related
Docs track the go-sequent library. When APIs drift, the source wins.