Skip to content

Reference

EventStore

Reference for go-sequent's EventStore: the Store interface, memory and Postgres backends, errors, per-aggregate SHA-256 hash chain with canonical input, and verification.

EventStore

Package: eventstore

The event store persists commands and aggregate streams. It enforces optimistic concurrency and unique keys on append, and it maintains a hash chain over stored events. Applications usually reach it through the Repository, not directly.

Interfaces

type Store interface {
    Append(ctx context.Context, cmd CommandRecord, streams []StreamAppend) error
    LoadStream(ctx context.Context, aggregateID uuid.UUID) ([]event.Event, error)
    LoadStreams(ctx context.Context, ids []uuid.UUID) (map[uuid.UUID][]event.Event, error)
}

type TxManager interface {
    WithinTx(ctx context.Context, fn func(ctx context.Context) error) error
}

Both backends also implement VerifyChain(ctx, aggregateID) error and the snapshot store interface.

Append takes a CommandRecord (id, type, tenant, user, idempotency key, time, JSON payload) and one StreamAppend per aggregate:

type StreamAppend struct {
    AggregateID     uuid.UUID
    AggregateType   string
    PartitionKey    string
    ExpectedVersion int64 // last committed sequence; 0 for a new stream
    Events          []event.Event
    UniqueKeys      map[string]any
}

The command row and every stream are written atomically. The first new event must have sequence ExpectedVersion + 1.

Backends

Backend Use
eventstore/memory Unit tests and integration tests. memory.New(registry).
eventstore/postgres Production. postgres.Open(ctx, dsn, registry), then Migrate. Also provides TxManager and SnapshotStore.

PostgreSQL is the only production backend. The interfaces leave room for others, but none ship.

store, err := postgres.Open(ctx, dsn, app.Events)
if err != nil { /* ... */ }
if err := store.Migrate(ctx); err != nil { /* ... */ }
app.Repo.Store = store
app.Repo.Snapshots = store
app.Service.Tx = store

Migrate is idempotent. It takes an advisory lock, records applied versions in go_sequent_schema_migrations, and applies the SQL steps 001 through 006. See Migrations and CLI.

The Postgres store takes the aggregate row lock (SELECT ... FOR NO KEY UPDATE), reads the last sequence, requires the first new event to be last plus one, and inserts. A primary key collision is a conflict. Integrity failures map to ErrOptimisticConcurrency or ErrUniqueKeyViolation.

Errors

Error Meaning
ErrOptimisticConcurrency Stream tip did not match ExpectedVersion
ErrUniqueKeyViolation A unique key scope and value is already used
ErrStreamNotFound No events for that aggregate id
ErrHashChainTamper Chain verification failed
ErrHashChainIncomplete Hash columns are missing

Test with errors.Is.

Hash chain

Integrity is a per-aggregate SHA-256 hash chain. Each stored event keeps prev_hash and event_hash. The first event (sequence 1) uses a genesis prev_hash of 32 zero bytes.

The canonical input is UTF-8 lines joined by \n, with no trailing newline:

  1. event_type
  2. aggregate_id (UUID string)
  3. sequence_number (decimal)
  4. created_at (UTC, truncated to microseconds, RFC3339Nano)
  5. event_json (the exact stored payload bytes)
  6. prev_hash as 64 lowercase hex characters
event_hash = SHA-256(input)

The formula lives in eventstore/hashchain. The Postgres column for the payload is BYTEA, not JSONB, so the stored bytes are the bytes that were hashed.

LoadStream verifies the chain as it reads. You can call VerifyChain(ctx, aggregateID) for an explicit check. A broken chain returns ErrHashChainTamper.

What the hash chain gives you

It detects edits, deletions and reordering inside a stream. Changing any stored field breaks every later hash.

What it does not give you

  • No event signatures. The chain proves internal consistency, not who wrote an event. Someone with full database write access could recompute a whole stream. Combine it with database ACLs, encrypted backups and optional WORM storage. See Threat model.
  • Per-aggregate only. There is no global chain across streams.
  • A cost. Appends compute a hash and loads verify one.

Encryption of payload fields

crypto.PayloadProtector (Protect and Unprotect, with a Noop implementation) is an optional interface for encrypting selected fields of event JSON. You wire it around the event JSON in your store path; the default store does not apply it for you. It is not crypto-shredding and does not imply event authenticity.

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