Timebox is a small, opinionated event sourcing library for Go with pluggable persistence backends including memory, Redis/Valkey, PostgreSQL, and Raft. It provides an append-only event log, optimistic concurrency, snapshotting, and append-time indexing so multiple instances can coordinate through the same store.
See the documentation for usage guides and production patterns.
Timebox currently ships with:
memoryfor tests and single-process useredisfor Redis or Valkey deploymentspostgresfor PostgreSQL-backed persistenceraftfor multi-node consensus
Store: event-store semantics over aBackendAggregateID: an aggregate's type and key, as in("order", "123")Executor: loads aggregate state, runs a command, persists raised events, and retries on optimistic conflictsTransaction: groups commands over several aggregates into one atomic appendAggregator: accumulates events and exposes the current aggregate view during a commandIndexer: optional append-time hook that derives status and tag updates from an appended event batchSnapshot: cached aggregate state plus the sequence it represents
An AggregateID is a comparable struct of a Type and a Key:
id := timebox.NewAggregateID("order", "ORD-12345")
catalog := timebox.NewAggregateType("catalog")Callers supply both components. NewAggregateType builds the ID of an aggregate that is the only one of its type, such as a cluster or catalog aggregate, filling Key with timebox.SingletonKey ("_").
Listing takes a type rather than an ID, since an aggregate ID always names one aggregate:
orders, err := store.ListAggregates("order") // every order
all, err := store.ListAggregates("") // every aggregateEvents marshal their IDs to JSON as a two-element array, so ("order", "123") encodes as ["order","123"]. Applications validate IDs at uncontrolled input boundaries, such as HTTP requests. Backend codecs read IDs written by Timebox and do not enforce application input rules.
timebox.Config controls store behavior regardless of backend:
TrimEvents: whether saving a snapshot trims older stored eventsSnapshotRatio: when anExecutorshould opportunistically refresh a snapshot while loading stateMaxRetries: optimistic concurrency retry limitCacheSize: executor projection cache sizeIndexer: optional function that derives status and tag updates from an appended event batch
Open the backend with its own settings, then create a Store over it with the Timebox configuration:
backend, err := postgres.Open(postgres.Config{
URL: "postgres://localhost:5432/postgres?sslmode=disable",
})
defer func() { _ = backend.Close() }()
store, err := backend.NewStore(timebox.Config{
MaxRetries: 8,
})NewStore accepts any number of timebox.Config values, overlaid on the defaults in order. One Backend opens as many Stores as an application needs, each with its own configuration, over the same underlying storage. Memory, Redis, and Raft use the same construction pattern.
Executors save snapshots automatically while loading aggregates when no snapshot exists yet or when trailing event data grows past SnapshotRatio.
Store.Transact runs a function whose commands over any number of aggregates commit as a single atomic append. Every backend applies the whole batch or none of it, and each aggregate keeps its own optimistic concurrency check.
err := store.Transact(func(t *timebox.Transaction) error {
if _, err := t.Exec(orders, orderID, placeOrder); err != nil {
return err
}
_, err := t.Exec(accounts, accountID, debitAccount)
return err
})Transaction.Exec(executor, id, cmd)runs a command and enlists its events. Executors must belong to the sameStore, otherwise it returnsErrStoreMismatch.- Calling
Execagain for an aggregate already joined continues the sameAggregator, so its later events append to the same staged batch. Joining one aggregate under two different state types returnsErrAggregateTypeConflict. - Values returned from
Execonly hold if the transaction commits. - An error returned from the function discards the transaction. A version conflict on any aggregate re-runs the whole function, up to
MaxRetries, then returnsErrMaxRetriesExceeded. - Executor caches and
SuccessActioncallbacks run only after a successful commit. Aggregator.Transaction()returns the enclosingTransaction, so a command holding only anAggregatorcan enlist further aggregates.
Executor.Exec is a single-aggregate transaction, so its behavior is unchanged.
postgres.Config adds:
URL: connection URLPrefix: logical store namespaceMaxConns: pgx pool size cap
The Postgres backend stores:
- aggregate status and tags in backend-specific indexes
- snapshots in
timebox_snapshot - events in
timebox_events
redis.Config adds:
Addr: Redis or Valkey host:portPassword: optional passwordPrefix: logical store namespaceShard: optional hash-tag value for cluster slot affinityDB: logical database index
raft.Config fields:
LocalID: stable local Raft node IDAddress: node address used for Raft trafficDataDir: durable local state directoryLogTailSize: hot retained WAL suffix cache size, default20480Servers: bootstrap voter setPublisher: optional callback for committed events after they are durably applied
Config.Indexer lets you derive indexed metadata from an appended event batch. Index currently supports:
Status: aggregate status plus the time it entered that statusTags: aggregate tag additions and removals
Read paths exposed by the store:
Store.GetAggregateStatus(id)Store.ListAggregatesByStatus(status)Store.ListAggregatesByTag(tag)
Archiving moves an aggregate's snapshot and event history into backend-specific archive storage and clears the live records. It is a one-way operation. The memory, redis, and raft backends support archiving, while postgres does not.
Call Store.Archive(id).
To consume archived records, call Store.ConsumeArchive(ctx, handler). It blocks until one record is processed or the context is done. Use context.WithTimeout to poll with a deadline.
Handlers must be idempotent because processing is at-least-once.
examples/order.goshows a simple order lifecycle over Timebox
Work in progress. Not ready for production use.
