Acta
Lightweight event-driven and event-sourced primitives for Rails.
What it is
A small, opinionated set of primitives for Rails applications that want an audit log, an event-driven architecture, or event sourcing — without taking on a heavyweight framework. Apps compose the primitives à la carte:
- Plain event-driven with a persistent audit log
- Event-sourced aggregates with readonly projections
- Hybrid — some aggregates event-sourced, others conventional
What the library ships:
| Primitive | Role |
|---|---|
Acta::Event |
ActiveModel-backed event classes with typed payloads |
Acta::Handler |
Base primitive — "on event X, run this" |
Acta::Projection |
Sync + transactional + replayable (for ES aggregates) |
Acta::Reactor |
After-commit + async via ActiveJob (for side effects) |
Acta::Command |
Recommended write path with param validation & optimistic concurrency |
Acta::Testing |
RSpec matchers, given-when-then DSL, replay-determinism assertions |
Adapters: SQLite and Postgres, both first-class.
Installation
# Gemfile
gem "acta"
Tested against Ruby 3.2, 3.3, 3.4 and Rails 7.2, 8.0, 8.1. Pre-1.0 —
the API is still settling through real-world consumer integration.
Pin a minor ("~> 0.2") if you need stability across bundle update.
Generate the events table migration:
bin/rails generate acta:install
bin/rails db:migrate
For multi-database apps, target a specific database with --database:
bin/rails generate acta:install --database=events
bin/rails db:migrate:events
The migration is written to that database's migrations_paths
(typically db/<database>_migrate/).
Usage
The five sections below introduce the primitives in isolation. For end-to-end walkthroughs of specific scenarios, see the cookbook.
1. Define an event
# app/events/order_placed.rb
class OrderPlaced < Acta::Event
stream :order, key: :order_id
attribute :order_id, :string
attribute :customer_id, :string
attribute :total_cents, :integer
validates :order_id, :customer_id, :total_cents, presence: true
end
2. Emit it
Set the actor once at the request boundary:
# ApplicationController
before_action do
Acta::Current.actor = Acta::Actor.new(
type: "user",
id: current_user.id,
source: "web"
)
end
# somewhere in your code
Acta.emit(OrderPlaced.new(order_id: "o_1", customer_id: "c_1", total_cents: 4200))
That's the minimum viable Acta app — you now have an append-only audit log keyed by actor (who) and source (through what surface). Actor types and sources are open strings; pick the vocabulary that fits your app.
3. React to events (event-driven)
For side effects that should happen after each event is durably written:
# app/reactors/confirmation_email_reactor.rb
class ConfirmationEmailReactor < Acta::Reactor
on OrderPlaced do |event|
OrderMailer.confirmation(event.order_id).deliver_later
end
end
Reactors run after-commit and default to async via ActiveJob. Use sync!
to run in the caller's thread (mostly useful for tests).
Pin a specific ActiveJob queue per class with queue_as:
class ConfirmationEmailReactor < Acta::Reactor
queue_as :fast
on OrderPlaced do |event|
OrderMailer.confirmation(event.order_id).deliver_later
end
end
Or set a global default for every reactor that doesn't declare its own:
# config/initializers/acta.rb
Acta.reactor_queue = :fast
Per-class declarations beat the global default; with neither set,
ActiveJob's :default queue is used. sync! reactors bypass ActiveJob
entirely, so the queue setting is ignored for them.
4. Project state (event-sourced)
For aggregates where the event log is the source of truth and AR tables are a derived view:
# app/projections/order_projection.rb
class OrderProjection < Acta::Projection
truncates Order
on OrderPlaced do |event|
Order.create!(
id: event.order_id,
customer_id: event.customer_id,
total_cents: event.total_cents,
status: "placed"
)
end
on OrderShipped do |event|
Order.find(event.order_id).update!(status: "shipped", shipped_at: event.occurred_at)
end
end
truncates Order declares the AR classes this projection owns. Acta uses
the declaration both as the default truncate! (delete_all on each in
order) and as input to cross-projection ordering: when one projection's
tables FK-reference another's, the children are truncated first
regardless of registration order. List multiple in safe within-projection
order (children before parents):
class CatalogProjection < Acta::Projection
truncates Trail, Zone # Trail.zone_id → Zone.id, so Trail first
end
Override truncate! directly when the default isn't enough — truncates
still drives global FK ordering, while the override provides whatever
custom teardown the projection needs.
Projections run synchronously inside the emit transaction. If they raise, the entire emit rolls back — the event row isn't written, reactors don't fire, base handlers don't fire.
Projections register themselves with Acta the first time their class is
loaded (via Class.inherited). Acta's Railtie eagerly loads everything
under app/projections, app/handlers, and app/reactors on each
config.to_prepare, so subscribers are wired up before the first request
— including in dev mode where Zeitwerk would otherwise wait until
something explicitly references the constant. If your subscribers live
elsewhere, point Acta at them:
# config/application.rb
config.acta.projection_paths = %w[app/projections app/read_models]
config.acta.handler_paths = %w[app/handlers]
config.acta.reactor_paths = %w[app/reactors]
Set a path list to [] to disable auto-loading and manage subscriber
lifecycle yourself.
Replay at any time:
Acta.rebuild!
Each projection's truncate! runs in FK-safe order (derived from the
truncates declarations), then the log is replayed through projections.
Reactors are skipped during replay (replay is a state operation, not a
notification one).
Guarding projection-owned tables
Once a model is maintained by a projection, every other write path
(controllers, console one-offs, rake tasks, callbacks on other models)
silently breaks the event log as the source of truth. Opt into a runtime
guard with acta_managed!:
class Order < ApplicationRecord
acta_managed! # writes outside an Acta::Projection raise ProjectionWriteError
end
Inside an Acta::Projection on EventClass do |e| ... end block (and
during Acta.rebuild!'s truncate phase), Acta::Projection.applying?
is true and writes pass through. From a controller, console, or
unrelated callback, they raise:
Order.update_all(status: "cancelled")
# raise: Acta::ProjectionWriteError — Order is acta_managed!
# Emit an event so the projection can update the row, or wrap
# intentional out-of-band writes in
# `Acta::Projection.applying! { ... }` (fixtures, migrations,
# backfills).
For incremental migration, demote violations to warnings:
acta_managed! on_violation: :warn
Test fixtures, data migrations, and one-off backfills can wrap
intentional out-of-band writes in Acta::Projection.applying! { ... }
to bypass the safety net explicitly.
Atomicity and replay
Three related semantics that are easy to conflate:
Per-emit atomicity. Acta.emit opens a requires_new: true
transaction that wraps both the event row insert and every projection's
on EventClass block. If any projection raises, the entire emit rolls
back: the event row isn't persisted, other projections' writes don't
commit, and async reactors never fan out (they're enqueued only on
successful commit). Sync reactors also run inside this transaction —
but their side effects (mailers sent, HTTP calls made) can't be undone
by a rollback if they've already happened, which is why sync! should
be reserved for follow-up DB writes or cases where "fired but rolled
back" is acceptable.
Acta::Projection.applying! is not the transaction. It's a separate
concept: a thread-local flag that gates acta_managed! writes,
distinguishing "projection code is running" from "someone called
Order.create! from a controller." Acta sets the flag automatically
inside projection blocks and during the truncate phase of rebuild!.
Apps set it explicitly with Acta::Projection.applying! { ... } to
bypass the acta_managed! guard for fixtures, migrations, and one-off
backfills. Toggling the flag does not open or join a transaction —
the per-emit transaction does that work, and rebuild! uses one
implicit transaction per delete_all plus one per replayed event.
Acta.rebuild! partial failure. Rebuild truncates every projected
table first (inside one applying! block, in FK-safe order), then
replays the log event-by-event through projections. If an event raises
mid-replay, the truncate has already happened and the rebuild halts at
that event — projected tables are in a partially-rebuilt state, not
the pre-rebuild state and not the fully-replayed state. Treat any
rebuild failure as needing investigation; once the underlying
projection bug is fixed, re-running rebuild! re-truncates and starts
over from the beginning of the log. There is no resume-from-event-N
mode.
5. Commands for validated writes
# app/commands/place_order.rb
class PlaceOrder < Acta::Command
param :order_id, :string
param :customer_id, :string
param :total_cents, :integer
validates :order_id, :customer_id, :total_cents, presence: true
validates :total_cents, numericality: { greater_than: 0 }
def call
emit OrderPlaced.new(order_id:, customer_id:, total_cents:)
end
end
order_id = "order_#{SecureRandom.uuid_v7}"
PlaceOrder.call(order_id:, customer_id: "c_1", total_cents: 4200)
# `order_id` is in scope — use it for the redirect, response, etc.
The default pattern is caller generates the ID, command takes it as a
param. Simplest possible thing — the ID is in scope at the call site,
the command is a thin validate-and-emit shell, and there's no question
about what .call returns.
What Command.call returns
Acta::Command.call returns the command instance. The instance
carries the params, an emitted_events array (every event emitted
during #call, in order), and any state the command exposed via
attr_reader. The return value of #call is discarded — see the
pitfall below.
Most callers ignore the return value:
PlaceOrder.call(order_id:, customer_id: "c_1", total_cents: 4200)
When the command should generate the ID
If the ID prefix or shape is a domain concern that belongs with the
command (and the caller would always do the same thing if you forced
it to generate), expose the generated ID via attr_reader:
class PlaceOrder < Acta::Command
attr_reader :order_id
param :customer_id, :string
param :total_cents, :integer
def call
@order_id = "order_#{SecureRandom.uuid_v7}"
emit OrderPlaced.new(order_id: @order_id, customer_id:, total_cents:)
end
end
cmd = PlaceOrder.call(customer_id: "c_1", total_cents: 4200)
cmd.order_id # => "order_018f2…"
This reads naturally at the call site — cmd.order_id, not
cmd.emitted_events.first.order_id — and gives the value a stable,
semantic name regardless of how many events the command emits or in
what order.
Inspecting the events
When you genuinely need the event objects (for further dispatch,
logging, or because the command emits multiple events with no single
"primary" one), read emitted_events:
cmd = PlaceOrder.call(...)
cmd.emitted_events # => [#<OrderPlaced ...>]
cmd.emitted_events.first # the only event in this case
Commands can emit zero, one, or many events. The framework does not
invent a "primary" event — when there are several, the caller (who
knows the domain) picks what matters from emitted_events.
Pitfall: don't return from #call
def call
order_id = "order_#{SecureRandom.uuid_v7}"
emit OrderPlaced.new(order_id:, …)
order_id # ← this is silently discarded by Acta::Command.call
end
A trailing expression in #call looks like the obvious way to surface
the generated ID, but Command.call always returns the command
instance — the body's return value is dropped. Use attr_reader (or
let the caller pass the ID in) instead.
Optimistic locking (high-water mark)
Every stream has a high-water mark — the stream_sequence of its most
recent event. Acta.version_of reads it; Acta.emit(..., if_version: N)
asserts it. Use the pair when you need optimistic locking against
concurrent writers to the same aggregate:
class RenameOrder < Acta::Command
param :order_id, :string
param :new_name, :string
def call
version = Acta.version_of(stream_type: :order, stream_key: order_id)
# ... do work that depends on the current state ...
emit OrderRenamed.new(order_id:, new_name:), if_version: version
end
end
If another writer has appended to the stream between version_of and
emit, the emit raises Acta::VersionConflict — callers retry with
fresh state or surface the collision instead of silently clobbering it.
if_version: 0 asserts a fresh stream (no events yet). Most commands
don't need this; reach for it when concurrent writes to the same
aggregate are realistic and lost-update would be a bug.
Identity: IDs originate at the write boundary, never in projections
For event-sourced aggregates, aggregate IDs (typically UUIDs) must be
stable across Acta.rebuild! and must not drift if the projected
tables are truncated. The rule: the ID is generated once at the
write boundary, the event carries it in its payload, and the
projection reads it back out.
"Write boundary" means either the caller of the command, or the command itself — whichever owns the ID's shape. Both are correct; pick by where the prefix/format convention lives.
# Pattern A — caller generates (default; what you'll usually want):
class CreateOrder < Acta::Command
param :order_id, :string
param :customer_id, :string
param :total_cents, :integer
def call
emit OrderCreated.new(order_id:, customer_id:, total_cents:)
end
end
order_id = "order_#{SecureRandom.uuid_v7}"
CreateOrder.call(order_id:, customer_id: "c_1", total_cents: 4200)
# Pattern B — command generates (when the prefix/shape is a domain
# concern of the command itself):
class CreateOrder < Acta::Command
attr_reader :order_id
param :customer_id, :string
param :total_cents, :integer
def call
@order_id = "order_#{SecureRandom.uuid_v7}"
emit OrderCreated.new(order_id: @order_id, customer_id:, total_cents:)
end
end
cmd = CreateOrder.call(customer_id: "c_1", total_cents: 4200)
cmd.order_id # => "order_018f2…"
Either way, the event carries order_id in its payload, and the
projection reads it back:
class OrderCreated < Acta::Event
stream :order, key: :order_id
attribute :order_id, :string
attribute :customer_id, :string
attribute :total_cents, :integer
end
class OrderProjection < Acta::Projection
on OrderCreated do |event|
Order.insert!(id: event.order_id, customer_id: event.customer_id, ...)
end
end
When Acta.rebuild! runs, it calls OrderProjection.truncate!
(wiping the orders table) and replays every event. The projection
reads event.order_id — which was written at the original write —
and re-inserts the row with the same ID. Rebuild never regenerates
IDs.
What to avoid
- Generating IDs in projection code. Non-deterministic — every
rebuild produces new IDs, orphaning any foreign references.
SecureRandom/Time.current/ anything stateful has no place in a projection. - Generating IDs in the event class's
initialize. Same problem: if the event assigns a default ID when reconstructed from a row, old events would decode with fresh IDs. Events should take an explicitorder_id:attribute and require it in the payload. - Dropping the events table. The event log is the primary source of IDs. Purging it regenerates all IDs on next write. Back it up and treat it as production-critical — even more so if other systems (a separate user DB, external services) reference your aggregates' IDs.
Why this matters
If anything outside the event-sourced aggregate references an ID —
ratings.wine_id in a separate user database, a webhook payload sent to
a third party, a URL that users have bookmarked — that reference must
stay valid across rebuilds. Keeping IDs in the event payload guarantees
it without any special deterministic-UUID schemes.
Event payloads with nested models
Payloads can carry arbitrary nested structures — either payload-only
Acta::Model classes or ActiveRecord classes that include
Acta::Serializable.
# payload-only class
class LineItem < Acta::Model
attribute :sku, :string
attribute :quantity, :integer
attribute :price_cents, :integer
end
# existing AR class — opt in as a payload type
class Address < ApplicationRecord
include Acta::Serializable
acta_serialize except: [:created_at, :updated_at]
end
class OrderSubmitted < Acta::Event
stream :order, key: :order_id
attribute :order_id, :string
attribute :shipping_address, Address # AR + Serializable
attribute :items, array_of: LineItem # Array<Acta::Model>
attribute :tags, array_of: String
end
When embedded, AR instances are snapshots: event.shipping_address.street
returns the value at emit time, regardless of later changes. For the
current row, call Address.find(event.shipping_address.id).
Encrypted attributes
Some events carry secrets — OAuth tokens, API keys, password reset
tokens — that shouldn't sit in events.payload as plaintext. Acta
supports per-attribute encryption that piggybacks on Rails'
ActiveRecord::Encryption (same keys, same rotation story).
class StravaCredentialIssued < Acta::Event
stream :strava_credential, key: :user_id
attribute :user_id, :string
attribute :access_token, :encrypted_string # encrypted in payload
attribute :refresh_token, :encrypted_string
attribute :expires_at, :datetime
end
In memory the event behaves normally — event.access_token is the
plaintext you wrote. The encrypted form only appears in the serialized
payload that's written to the events table:
events.payload → { "access_token": "{\"p\":\"…\",\"h\":{\"iv\":\"…\",\"at\":\"…\"}}", "user_id": "u_1", … }
Encryption is per-attribute: leave queryable fields like user_id
plaintext, encrypt only the secrets.
Setup
Encrypted attributes use the same configuration as Rails AR encryption.
If you're already using encrypts on AR models, you have nothing to do.
Otherwise:
bin/rails db:encryption:init
Then store the printed keys in Rails.application.credentials:
active_record_encryption:
primary_key: ...
deterministic_key: ...
key_derivation_salt: ...
Key rotation
To rotate, append a new primary key (Rails keeps the old keys for decryption indefinitely):
active_record_encryption:
primary_key:
- new_primary_key # new writes use this
- old_primary_key # old payloads still decrypt
deterministic_key: ...
key_derivation_salt: ...
Existing event rows stay decryptable. New emits use the new key. No re-encryption migration is required — the audit log accretes ciphertext under whichever key was current at write time.
Testing
# spec_helper.rb (or equivalent)
require "acta/testing"
require "acta/testing/matchers"
RSpec.configure do |config|
Acta::Testing.default_actor!(config)
config.include Acta::Testing::DSL
config.around(:each, :active_record) do |example|
Acta::Testing.test_mode { example.run }
end
end
Default actor
Acta.emit requires Acta::Current.actor to be set — every event needs
a known author. Acta::Testing.default_actor!(config) adds a
before(:each) that sets a default system / rspec / test actor and an
after(:each) that resets it, so specs (and the commands they call)
don't trip Acta::MissingActor. Override any attribute to match your
project's vocabulary:
Acta::Testing.default_actor!(config, type: "user", id: "test-user-1", source: "spec")
For an individual example that needs to attribute emissions to a
specific actor, scope an override with with_actor:
include Acta::Testing::DSL
it "records the user as the actor" do
with_actor(type: "user", id: user.id, source: "web") do
PlaceOrder.call(...)
end
expect(Acta::Record.last.actor_id).to eq(user.id)
end
with_actor restores the surrounding actor when the block returns or
raises.
Writing to acta_managed! models in setup
Tests that need to seed acta_managed! AR models directly — without
going through a command + event + projection chain — would otherwise
trip Acta::ProjectionWriteError. Pull in the with_projection_writes
helper:
RSpec.configure do |config|
Acta::Testing.projection_writes_helper!(config)
end
# in any spec:
with_projection_writes do
zone = Zone.create!(name: "Cheakamus")
Trail.create!(zone:, name: "Crank It Up")
end
The helper forwards to Acta::Projection.applying!, which is the same
flag projections use internally — so writes inside the block pass the
guard. Outside the block, the guard is back in force. Use this for
factories, fixtures, and one-off setup; for production code paths, emit
events instead.
RSpec matchers
expect { PlaceOrder.call(order_id: "o_1", customer_id: "c_1", total_cents: 4200) }
.to emit(OrderPlaced).with(total_cents: 4200)
expect { some_noop }.not_to emit_any_events
expect { batched_import }
.to emit_events([OrderPlaced, OrderPlaced, OrderPlaced])
Given/when/then DSL
include Acta::Testing::DSL
it "ships an order" do
given_events do
Acta.emit(OrderPlaced.new(order_id: "o_1", customer_id: "c_1", total_cents: 4200))
end
when_command ShipOrder.new(order_id: "o_1", tracking: "TRK123")
then_emitted OrderShipped, order_id: "o_1"
then_emitted_nothing_else
end
Fixtures become narratives — prior state is declared as events, which mirrors how state actually accumulates in an event-sourced system.
Replay determinism check
it "projects deterministically" do
# ... emit some events ...
ensure_replay_deterministic { Order.all.pluck(:id, :status) }
end
Catches the common projection bugs (Time.current, rand, external API calls) better than code review ever will.
Observability
Hook into ActiveSupport::Notifications for metrics, tracing, and
request correlation:
acta.event_emitted—{ event, event_type }acta.projection_applied—{ event, projection_class }acta.reactor_invoked—{ event, reactor_class, sync: true }acta.reactor_enqueued—{ event, reactor_class }
Development
bin/setup # install dependencies
bundle exec rspec # run the test suite (SQLite + Postgres if available)
bundle exec rake # tests + rubocop
The Postgres adapter tests run if a local Postgres instance is reachable. Configure via environment variables:
ACTA_PG_DATABASE=acta_test
ACTA_PG_HOST=localhost
ACTA_PG_PORT=5432
ACTA_PG_USER=$USER
ACTA_PG_PASSWORD=
License
MIT. See LICENSE.