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 explicit order_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.