Skip to content

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 with WHERE 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.

Docs track the go-sequent library. When APIs drift, the source wins.