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

Not published to RubyGems. Install from git:

# Gemfile
gem "acta", git: "https://github.com/whoojemaflip/acta.git"

Requires Rails 8.1+ and Ruby 3.4+.

Generate the events table migration:

bin/rails generate acta:install
bin/rails db:migrate

Usage

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).

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.

5. Commands for validated writes

# app/commands/place_order.rb
class PlaceOrder < Acta::Command
  param :customer_id, :string
  param :total_cents, :integer

  validates :customer_id, :total_cents, presence: true
  validates :total_cents, numericality: { greater_than: 0 }

  def call
    order_id = "order_#{SecureRandom.uuid}"
    emit OrderPlaced.new(order_id:, customer_id:, total_cents:)
  end
end

cmd = PlaceOrder.call(customer_id: "c_1", total_cents: 4200)
cmd.emitted_events.first.order_id   # => "order_…"

Acta::Command.call returns the command instance. The instance carries the params, the emitted_events array (every event emitted during #call, in order), and any state the command exposed via attr_reader. Callers that don't care about the events ignore the return value:

PlaceOrder.call(customer_id: "c_1", total_cents: 4200)

Commands can emit zero, one, or many events. The framework does not invent a "primary" event — when a command emits more than one, the caller (who knows the domain) picks what matters from cmd.emitted_events.

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: generate IDs in commands, 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 command generates the ID once, the event carries it in its payload, and the projection reads it back out.

class CreateOrder < Acta::Command
  param :customer_id, :string
  param :total_cents, :integer

  def call
    order_id = "order_#{SecureRandom.uuid}"    # generated here, once, forever
    emit OrderCreated.new(order_id:, customer_id:, total_cents:)
  end
end

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 command call — 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.

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.