Architecture Overview
This page is a high-level map of Estoria: how its components fit together, which one owns which responsibility, and where the extension points are.
Core Concepts
Estoria maps event sourcing concepts onto a small set of Go types:
- Entity state — a plain struct of your own (
Account,Order, …) plus a factory function that produces its initial state. Estoria imposes no interface on it. estoria.DomainEvent[S]— a state-changing event.ApplyTo(state S) Sis total: a persisted event is a fact, and applying one cannot fail.eventstore.Store— persistent storage for event streams (StreamReader+StreamWriter).aggregatestore.Store[S]— the high-level interface applications use to create, load, and save aggregates.aggregatestore.New(...)— constructs the event-sourced store, the primary implementation, which reconstructs aggregates by replaying their events.
Component Relationships
┌──────────────────────────────────────────────────────────┐
│ Application Layer │
│ (commands, queries, domain logic) │
└─────────────┬────────────────────────────────────────────┘
│ New / Load / Save
┌─────────────▼────────────────────────────────────────────┐
│ Aggregate Store (Store[S]) │
│ │
│ optional decorators, stackable in any order: │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │Snapshotting│ │ Cached │ │ App-owned │ │
│ └────────────┘ └────────────┘ └────────────┘ │
│ │
│ core implementation: │
│ ┌──────────────────────────────────────────────┐ │
│ │ Event-sourced store (aggregatestore.New) │ │
│ │ • replays events to load aggregates │ │
│ │ • appends events to save them │ │
│ │ • owns domain event <-> bytes (codec) │ │
│ │ • owns the aggregate type name │ │
│ └──────────────────────────────────────────────┘ │
└─────────────┬────────────────────────────────────────────┘
│ ReadStream / AppendStream
┌─────────────▼────────────────────────────────────────────┐
│ Event Store (eventstore.Store) │
└─────────────┬────────────────────────────────────────────┘
│
┌──────────┼──────────┬───────────┬───────────┐
│ │ │ │ │
┌──▼─────┐ ┌──▼───┐ ┌────▼────┐ ┌────▼─────┐ ┌───▼────┐
│Postgres│ │SQLite│ │ MongoDB │ │KurrentDB │ │ Memory │
└────────┘ └──────┘ └─────────┘ └──────────┘ └────────┘Data Flow
Read Path: Loading an Aggregate
- The application calls
store.Load(ctx, id, nil). - The store composes the aggregate’s typed ID from its configured type name and the given UUID, creates the initial state with the factory, and hydrates it.
- Hydration calls
eventStore.ReadStream(...)and, for each event, decodes the payload with the store’sDomainEventCodecand applies it withApplyTo, advancing the aggregate’s version.
With decorators in the stack: a snapshotting store first loads the latest snapshot and replays only the events after it; a cached store returns a cache hit without touching the event store at all.
Write Path: Saving an Aggregate
- The application queues events on the aggregate with
Append(nothing is persisted or applied yet). store.Save(ctx, aggregate, nil)encodes each queued event with theDomainEventCodecand callseventStore.AppendStream(...), which enforces optimistic concurrency against the aggregate’s loaded version.AppendStreamreturns the written events exactly as a subsequent read would — store-assigned IDs, versions, timestamps, and global positions — and the store applies them to the in-memory state.
With decorators: a snapshotting store consults its policy after the save and captures a snapshot when due; a cached store refreshes its entry.
Ownership Rules
Estoria assigns each serialization and naming fact exactly one owner:
- Domain events → bytes is owned by the aggregate store, through its
estoria.DomainEventCodec[S](JSON by default, overridable withWithDomainEventCodec). Event store backends persist those bytes verbatim, alongside the codec’s declared content type — no backend re-encodes a payload it was handed. - Entity state → bytes (for snapshots and caches) is owned by the component doing the storing, through an
estoria.StateCodec[S](JSON by default). - The aggregate type name is owned by the aggregate store, supplied once at construction (
aggregatestore.New(eventStore, "account", NewAccount, ...)). It becomes the type component of every aggregate ID the store composes, which is how streams are addressed in storage.
Store Decorators
Decorators wrap any aggregatestore.Store[S] and can be stacked:
- Snapshotting — loads state from snapshots instead of full replay; captures snapshots per a
SnapshotPolicy. - Cached — serves loads from an
AggregateCache[S]backend, falling back to the inner store on a miss.
store, _ := aggregatestore.New(eventStore, "account", NewAccount,
aggregatestore.WithEventTypes(AccountOpened{}, FundsDeposited{}))
cached, _ := aggregatestore.NewCachedStore(store, cache)
snapshotting, _ := aggregatestore.NewSnapshottingStore(cached, snapshotStore,
snapshotstore.EventCountSnapshotPolicy{N: 100})Application-owned behavior — reacting to saves, injecting ambient metadata — composes the same way: write a small decorator over any store in the stack.
Extension Points
Vendor-specific implementations live in estoria-contrib; the Component Library catalogs them.
- Event stores implement
eventstore.Store: PostgreSQL, SQLite, MongoDB, KurrentDB. The core library ships an in-memory store for testing. Optional capabilities — global reads and stream deletion — are discovered by type assertion. - Snapshot stores implement
snapshotstore.SnapshotStore. Both current implementations are in core:snapshotstore/memoryandsnapshotstore/eventstream(snapshots stored as events). - Aggregate caches implement
aggregatestore.AggregateCache[S]: Redis, Valkey, Freecache, BigCache. - Transactional outboxes ride the PostgreSQL and MongoDB event stores’ transaction hooks for reliable delivery to external consumers.
- Checkpoint stores implement
checkpointstore.Store, persisting projection progress: PostgreSQL, SQLite, MongoDB. Core shipscheckpointstore/memory; custom implementations over the read model’s own database are verified by thecheckpointstore/storetestsuite. - Cutover setters and routers implement
lifecycle.CutoverSetterandlifecycle.Router, converging read routing on recorded promotions and rollbacks. - Telemetry wrappers (OpenTelemetry, Datadog) instrument event, aggregate, and snapshot stores without changing their behavior.
Acceptance suites in eventstore/storetest, snapshotstore/storetest, and checkpointstore/storetest verify implementations against the behaviors Estoria’s components rely on; every contrib backend runs them in CI.
Key Interfaces
| Interface | Purpose | Key methods |
|---|---|---|
estoria.DomainEvent[S] | State-changing event | EventType(), New(), ApplyTo(S) S |
estoria.DomainEventCodec[S] | Domain event serialization | MarshalDomainEvent, UnmarshalDomainEvent, ContentType |
estoria.StateCodec[S] | Entity state serialization | MarshalState, UnmarshalState, ContentType |
eventstore.Store | Event persistence | ReadStream, AppendStream |
eventstore.StreamIterator | Event stream traversal | Next, Close |
aggregatestore.Store[S] | Aggregate operations | New, Load, Hydrate, Save |
aggregatestore.AggregateCache[S] | Aggregate caching | GetAggregate, PutAggregate |
snapshotstore.SnapshotStore | Snapshot persistence | ReadSnapshot, WriteSnapshot |
projection.EventHandler | Read-model event handling | Handle |
checkpointstore.Store | Projection progress | Load, Save, Delete |
lifecycle.Router | Live-version routing | Live |
Further Reading
- Getting Started — build a first Estoria application
- Components — detailed documentation for each component
- Integrations — setup guides for the contrib backends
- CQRS — read models, projections, and the transactional outbox
- Projection Lifecycle — versioned read-model rebuilds, cutover, and retirement