crystal-es v0.9.0
crystal-es
An event sourcing library for Crystal
Installation
-
Add the dependency to your
shard.yml:dependencies: crystal-es: github: tristanholl/crystal-es -
Run
shards install
Overview
crystal-es is an event sourcing foundation for Crystal applications. It is optimized for PostgreSQL as event store, queue, and projection database, and was originally extracted from an open-source core-banking project.
The library provides:
- Aggregates — reconstruct domain state by replaying events
- Commands — pure data records expressing an intent to change state
- Command Handlers — enforce business logic and emit events
- Reactors — consume events off the bus and trigger the next command
- Events — immutable facts with a type-safe DSL
- Projections — read models built from event streams, with a schema DSL and schema drift detection
- Event Bus — fan-out published events to reactors and projections
- Adapters — PostgreSQL and in-memory implementations for event stores and queues
A complete working example lives in ./examples/financial-transaction.
Components
Event
ES::Event is the base class for all domain events. Each event carries a Header (metadata) and a Body (event-specific payload).
class OrderPlaced < ES::Event
aggregate_type "order"
event_handle "order_placed"
class Body
include JSON::Serializable
getter order_id : UUID
getter amount : Int64
def initialize(@order_id, @amount); end
end
end
Event DSL
The define_event macro removes the boilerplate. It generates the full event class, the Body struct with JSON serialization, and registers the handle automatically.
define_event("order", "order_placed") do
attribute :order_id, UUID
attribute :amount, Int64
attribute :currency, String, "EUR" # optional default
end
attribute(name, type, default?) — declare a typed field on the event body. A default value makes the field optional at construction time.
Aggregate
ES::Aggregate reconstructs domain state from its event history. Each aggregate defines a State struct and a set of apply overloads — one per event type.
class Order < ES::Aggregate
struct State
property placed : Bool = false
property amount : Int64 = 0_i64
end
def apply(event : OrderPlaced)
@state.placed = true
@state.amount = event.body.amount
end
end
Call Order.hydrate(aggregate_id, event_store) to reconstruct an aggregate from persisted events.
Command
ES::Command is a pure data record expressing an intent to change state. Following strict event sourcing, it carries only the target aggregate and its payload — it has no behavior.
struct PlaceOrder < ES::Command
getter amount : Int64
def initialize(@aggregate_id : UUID, @amount : Int64)
end
end
Command Handler
ES::CommandHandler(C) encapsulates a single business operation. It is generic over the command type C it processes, so handle(command : C) is checked by the compiler. It hydrates the relevant aggregate, enforces state invariants, and appends new events.
A handler is a plain object — construct it and call handle directly. From an API endpoint that call is synchronous, so an invariant violation raises right where you can turn it into a response:
class PlaceOrderHandler < ES::CommandHandler(PlaceOrder)
def handle(command : PlaceOrder)
order = Order.new(command.aggregate_id, event_store: @event_store)
order.hydrate
raise ES::Exception::InvalidState.new("already placed") if order.state.placed
@event_store.append(OrderPlaced.new(
aggregate_id: command.aggregate_id,
body: OrderPlaced::Body.new(amount: command.amount)
))
end
end
# In an API controller — invariant failures surface synchronously
PlaceOrderHandler.new.handle(PlaceOrder.new(aggregate_id: id, amount: 500_i64))
Reactor
ES::Reactor consumes events from the EventBus and reacts to them — typically by constructing a command and calling its handler directly. It is the one bridge from an event back to a command; each step of a workflow is a named reactor. Declare the events it handles by defining a typed call for each, the same way a projection declares its events with typed apply overloads:
class OnOrderPlaced < ES::Reactor
def call(event : OrderPlaced)
ReserveStockHandler.new(event_store: @event_store).handle(
ReserveStock.new(aggregate_id: event.header.aggregate_id)
)
end
end
A reactor may declare several events by defining one call per event. Routing itself stays in the EventBus wiring, so the full fan-out of an event across workflows is readable in a single place; subscribe checks this declaration and refuses a subscription the reactor cannot serve.
Reaching a reactor with an event it declares no call for raises ES::Exception::InvalidState. A projection may legitimately ignore an event, but a reactor doing so means a workflow step was silently dropped.
Projection
ES::Projection maintains a read model by consuming events in order. It can be replayed from scratch at any time.
Projection DSL
define_projection generates the full projection class — table creation, column definitions, index setup, and event handlers — from a concise block.
class Postings < ES::Projection
include ES::ProjectionDSL
define_projection "ledger.postings" do
column :id, UUID, primary_key: true
column :account_id, UUID
column :amount, Int64
column :posted_at, Time
index [:account_id]
index [:id], unique: true
end
# Event handlers are declared on the class, not inside the block —
# `define_projection` reads only `column` and `index` declarations.
apply(OrderPlaced) do
# insert into postings table
end
end
column(name, type, **options) maps Crystal types to PostgreSQL column types:
| Crystal type | PostgreSQL type |
|---|---|
String |
TEXT |
Int64 |
BIGINT |
UUID |
UUID |
Time |
TIMESTAMPTZ |
Bool |
BOOLEAN |
index(columns, unique: false, name: nil) — add an index to the projection table.
apply(EventClass) { ... } — handle an event to update the read model. Declared on the class body, alongside define_projection rather than inside it. Within the block, header, aggregate_id, aggregate_version, created_at and body are pre-bound. A plain def apply(event : EventClass) works identically.
A projection consumes only the events it declares an apply for; EventBus#subscribe rejects a subscription to any other event. To consume everything, override the catch-all apply(event : ES::Event) instead.
Schema Drift Detection
Every projection schema is immutable. When setup_table is called, the library computes a SHA-256 fingerprint of the compiled schema (columns, types, nullability, defaults, indexes) and compares it against the fingerprint stored in _crystal_es_projection_metadata. If they diverge, a ES::Exception::SchemaDrift exception is raised before the projection can run.
Breaking changes (raise SchemaDrift):
- Column added, removed, or reordered
- Column type or Crystal type changed
- Nullability or default value changed
- Primary key changed
Non-breaking changes (logged as a warning, metadata updated):
- Index added, removed, or modified
The error message tells you exactly what changed:
Schema drift detected for 'Ledger' (table: finance.ledger).
Stored fingerprint: abc123...
Compiled fingerprint: def456...
Changes:
breaking column_type_changed: column "amount" type changed from TEXT to BIGINT
Projection schemas are immutable. Define a new projection class with a new table
name, populate it from the event store, then rewire the application to the new projection.
To evolve a projection, create a new projection class targeting a new table name, replay the event store into it, then cut the application over. There is no in-place migration path — this is by design.
You can also inspect drift status without triggering an exception:
status = Ledger.drift_status(db)
# => ES::ProjectionMeta::DriftStatus with fingerprints and list of SchemaChange objects
Event Bus
ES::EventBus fans out published events to all registered subscribers (reactors and projections). It never runs business logic itself and never touches a command handler — reactors reach handlers by a direct call.
bus = ES::EventBus(ES::Reactor.class | ES::Projection.class).new
bus.subscribe(OrderPlaced, OnOrderPlaced) # a reactor
bus.subscribe(OrderPlaced, OrdersProjection) # a projection
bus.publish(event)
This wiring is the single place where workflows are defined — one event may fan out to many handlers, and that fan-out is visible nowhere else. subscribe raises ES::Exception::InvalidState if the handler declares no call/apply for the event, so a wiring mistake surfaces at boot rather than as a silently swallowed event.
bus.routes returns the full routing table when the wiring file has grown long:
bus.routes # => {OrderPlaced => [OnOrderPlaced, OrdersProjection], ...}
Event Store
ES::EventStore is the persistence layer for events. Two implementations are provided:
ES::Adapters::EventStores::Postgres— stores events as JSONB rows with unique(aggregate_id, version)constraints. Provides a flattened view for stream queries and cursor-based pagination for batch replay.ES::Adapters::EventStores::InMemory— lightweight implementation for tests.
Queue
ES::Queue provides asynchronous command processing. Two implementations:
ES::Adapters::Queues::Postgres— durable queue backed by a PostgreSQL table.ES::Adapters::Queues::InMemory— for tests and simple scenarios.
Payload Encryption
Event bodies can be encrypted at rest, per event, under a key that can later be destroyed to erase that data permanently.
Three pieces:
ES::ApplicationEncryptionKeyManagerholds the one application encryption key (the KEK). Passed in through the constructor — the library never reads it from the environment itself.ES::KeyStorestores data encryption keys (DEKs), each wrapped under the application key.ES::KeyStoreAdapters::Postgresand::InMemoryare provided.ES::EncryptionKeyManagerties them together and is handed to the event store.
application_key = ES::ApplicationEncryptionKeyManager.new(Base64.decode(ENV["APPLICATION_ENCRYPTION_KEY"]))
key_store = ES::KeyStoreAdapters::Postgres.new(db)
key_store.setup
ES::Config.encryption = ES::EncryptionKeyManager.new(key_store, application_key)
ES::Config.event_store = ES::EventStoreAdapters::Postgres.new(db)
Where the key comes from and how it's encoded (the example above assumes base64) is up to the application; ES::ApplicationEncryptionKeyManager only ever sees the raw key bytes.
Declaring an encrypted event
encrypted: true makes encryption_key_id a required constructor argument:
define_event("customer", "customer_registered", encrypted: true) do
attribute :name, String
attribute :iban, String
end
The key is chosen when the event is constructed, not derived from it, so one aggregate's events can sit under several keys — events carrying a customer's data can be keyed by that customer even when the aggregate is an order:
key_id = ES::Config.encryption.create_key
@event_store.append(CustomerRegistered.new(
actor_id: actor, command_handler: "RegisterCustomer",
encryption_key_id: key_id, name: name, iban: iban
))
Encryption is opt-in per application
Declaring encrypted: true on an event class has no effect on an application that never configures ES::Config.encryption — encrypted and unencrypted event types can coexist freely. Using an encrypted event without the matching configuration fails loudly rather than writing something unencrypted or unreadable:
- No encryption configured, appending an encrypted event — raises
ES::Exception::InvalidState. - No encryption configured, reading back an encrypted envelope — raises
ES::Exception::DependencyUnavailable. - Encryption configured, but the referenced key doesn't exist (never created, destroyed, or from a different environment) — raises
ES::Exception::NotFound.
Erasure
# Everything about one aggregate
store.encryption_key_ids(aggregate_id).each { |id| ES::Config.encryption.destroy_key(id) }
# Or a key the application tracked itself
ES::Config.encryption.destroy_key(key_id)
The key table holds no reference to any business entity — references run one way only, from an event header to a key. encryption_key_ids looks up the keys for a single aggregate; a data subject spanning several aggregates is yours to map. destroy_key deletes the row — deletion is the erasure, with no tombstone or soft-delete flag.
Reading a stream after an erasure
Once a key is deleted, reading a body that names it raises ES::Exception::NotFound. Aggregates and projections handle that differently:
ES::Aggregate#hydratepropagates it —fetch_events/fetch_eventraise straight through.ES::Projection#replay/#initdo not.EventStore#each_eventskips an event it can't decrypt and logs a warning before it reaches yourapply. Ifapplyitself hits a destroyed key indirectly (e.g. hydrating a related aggregate), the sameNotFoundis caught, logged, and skipped so replay continues.
This is unconditional — there is nothing to configure. Note that the library reuses one NotFound for "missing row" and "key destroyed" alike, so a projection's apply cannot raise NotFound for an unrelated reason without it also being treated as a shredded key.
What this does and does not protect
Protected: event bodies at rest — dumps, backups, replicas, a DBA with SELECT — and erasure by key destruction.
Not protected: the event header (stays plaintext, since it drives the flattened view), application memory, logs, and projections — a projection built from an encrypted event stores whatever it extracted in the clear, so purging or rebuilding read models after an erasure is the application's job.
What is not offered
No key rotation, for either the application key or a data key, in this first iteration. The application key must stay fixed for the lifetime of the store, the same as any other irreplaceable secret.
Notes
Bodies are sealed with AES-256-CBC and an encrypt-then-MAC HMAC-SHA256 tag. An encrypted body is stored as {"iv": "<b64>", "ct": "<b64>", "tag": "<b64>"} — the header's encryption_key_id is what marks a body as encrypted, not a field on the body itself, so encrypted and plaintext bodies coexist in one store with no migration needed to turn encryption on for new events. Each ciphertext is bound to its own event and its own key, so an envelope can't be moved to another row or opened under a header naming a different key.
Configuration
ES::Config is a global singleton that wires dependencies together:
ES::Config.configure do |c|
c.event_store = ES::Adapters::EventStores::Postgres.new(db)
c.queue = ES::Adapters::Queues::Postgres.new(db)
c.event_bus = ES::EventBus(ES::Reactor.class | ES::Projection.class).new
c.encryption = ES::EncryptionKeyManager.new(key_store, application_key) # optional
end
encryption is optional; leaving it unset behaves exactly as it did before payload encryption existed.
Project Structure
For larger projects, the following vertical-slice layout works well:
src/
domains/
orders/
aggregates/
order.cr
commands/
place_order.cr
cancel_order.cr
events/
order_placed.cr
order_cancelled.cr
projections/
orders_list.cr
payments/
...
shared/
...
Example: Financial Transaction
Below is an abridged version of the financial-transaction example that shows the full event sourcing flow.
1. Define Events
# events/transaction_initiated.cr
define_event("transaction", "transaction_initiated") do
attribute :amount, Int64
attribute :creditor_account, UUID
attribute :debtor_account, UUID
end
# events/transaction_accepted.cr
define_event("transaction", "transaction_accepted") do
end
# events/transaction_rejected.cr
define_event("transaction", "transaction_rejected") do
end
2. Define the Aggregate
# aggregates/transaction.cr
class Transaction < ES::Aggregate
struct State
property amount : Int64 = 0_i64
property creditor_account : UUID? = nil
property debtor_account : UUID? = nil
property accepted : Bool = false
property rejected : Bool = false
end
def apply(event : TransactionInitiated)
@state.amount = event.body.amount
@state.creditor_account = event.body.creditor_account
@state.debtor_account = event.body.debtor_account
end
def apply(event : TransactionAccepted)
@state.accepted = true
end
def apply(event : TransactionRejected)
@state.rejected = true
end
end
3. Define a Command and its Handler
# commands/process_transaction.cr
struct ProcessTransaction < ES::Command
end
class ProcessTransactionHandler < ES::CommandHandler(ProcessTransaction)
LIMIT = 10_000_i64
def handle(command : ProcessTransaction)
tx = Transaction.new(command.aggregate_id, event_store: @event_store)
tx.hydrate
if tx.state.amount <= LIMIT
@event_store.append(TransactionAccepted.new(aggregate_id: command.aggregate_id))
else
@event_store.append(TransactionRejected.new(aggregate_id: command.aggregate_id))
end
end
end
4. Define a Reactor
The reactor turns an incoming event into the next command. This is where the workflow lives — TransactionInitiated triggers ProcessTransaction.
# reactors/on_transaction_initiated.cr
class OnTransactionInitiated < ES::Reactor
def call(event : TransactionInitiated)
ProcessTransactionHandler.new(event_store: @event_store).handle(
ProcessTransaction.new(aggregate_id: event.header.aggregate_id)
)
end
end
5. Define a Projection
# projections/ledger.cr
define_projection("finance", "ledger") do
column :id, UUID, primary_key: true
column :transaction_id, UUID
column :creditor_account, UUID
column :debtor_account, UUID
column :amount, Int64
column :accepted_at, Time, nullable: true
column :rejected_at, Time, nullable: true
index [:transaction_id], unique: true
apply(TransactionInitiated) do |event|
db.exec(
"INSERT INTO finance.ledger (id, transaction_id, creditor_account, debtor_account, amount)
VALUES ($1, $2, $3, $4, $5)",
UUID.random, event.header.aggregate_id,
event.body.creditor_account, event.body.debtor_account, event.body.amount
)
end
apply(TransactionAccepted) do |event|
db.exec(
"UPDATE finance.ledger SET accepted_at = $1 WHERE transaction_id = $2",
Time.utc, event.header.aggregate_id
)
end
apply(TransactionRejected) do |event|
db.exec(
"UPDATE finance.ledger SET rejected_at = $1 WHERE transaction_id = $2",
Time.utc, event.header.aggregate_id
)
end
end
6. Wire Everything Together
require "crystal-es"
require "db"
require "pg"
db = DB.open(ENV["DATABASE_URL"])
ES::Config.configure do |c|
c.event_store = ES::Adapters::EventStores::Postgres.new(db)
c.queue = ES::Adapters::Queues::Postgres.new(db)
c.event_bus = ES::EventBus(ES::Reactor.class | ES::Projection.class).new
c.event_handlers = ES::EventHandlers.new
end
# Register event types for deserialization
ES::Config.event_handlers.register(TransactionInitiated)
ES::Config.event_handlers.register(TransactionAccepted)
ES::Config.event_handlers.register(TransactionRejected)
# Subscribe reactors and projections to events
bus = ES::Config.event_bus
bus.subscribe(TransactionInitiated, OnTransactionInitiated)
bus.subscribe(TransactionInitiated, Ledger)
bus.subscribe(TransactionAccepted, Ledger)
bus.subscribe(TransactionRejected, Ledger)
# Initiate a transaction
aggregate_id = UUID.random
event = TransactionInitiated.new(
aggregate_id: aggregate_id,
body: TransactionInitiated::Body.new(
amount: 5_000_i64,
creditor_account: UUID.random,
debtor_account: UUID.random
)
)
ES::Config.event_store.append(event)
bus.publish(event)
Development
Start the development environment with Docker:
docker-compose up -d # starts PostgreSQL
make test # run the spec suite
Contributing
- Fork it (https://github.com/tristanholl/crystal-es/fork)
- Create your feature branch (
git checkout -b my-new-feature) - Commit your changes (
git commit -am 'Add some feature') - Push to the branch (
git push origin my-new-feature) - Create a new Pull Request
Contributors
- Tristan Holl - creator and maintainer
crystal-es
- 6
- 0
- 2
- 0
- 2
- about 1 hour ago
- October 10, 2024
MIT License
Tue, 18 Aug 2026 10:28:34 GMT