Funes
Event sourcing for Ruby on Rails — append-only events as your source of truth, with flexible projections for reads.
Event Sourcing?
Traditional Rails apps update state in place. You update! a record and the previous value is gone. Event sourcing takes a different approach: store what happened as immutable events, then derive current state by replaying them.
This gives you:
- Complete audit trail — every state change is recorded, forever
- Temporal queries — "what was the balance on December 1st?"
- Multiple read models — same events, different projections for different use cases
- Safer refactoring — rebuild any projection from the event log
Installation
Add to your Gemfile:
gem "funes-rails"
Run the installation:
$ bin/bundle install
$ bin/rails generate funes:install
$ bin/rails db:migrate
Three-Tier Consistency Model
Funes gives you fine-grained control over when and how projections run:
| Tier | When it runs | Use case |
|---|---|---|
| Consistency Projection | Before event is persisted | Validate business rules against resulting state |
| Transactional Projections | Same DB transaction as event | Critical read models needing strong consistency |
| Async Projections | Background job (ActiveJob) | Reports, analytics, non-critical read models |
Consistency Projection
Runs before the event is saved. If the resulting state is invalid, the event is rejected:
class InventoryEventStream < Funes::EventStream
consistency_projection InventorySnapshotProjection
end
class InventorySnapshot
include ActiveModel::Model
include ActiveModel::Attributes
attribute :quantity_on_hand, :integer, default: 0
validates :quantity_on_hand, numericality: { greater_than_or_equal_to: 0 }
end
Now if someone tries to ship more than available:
event = stream.append!(Inventory::ItemShipped.new(quantity: 9999))
event.valid? # => false
event.errors[:quantity_on_hand] # => ["must be greater than or equal to 0"]
The event is never persisted. Your invariants are protected.
Transactional Projections
Update read models in the same database transaction. If anything fails, everything rolls back:
add_transactional_projection InventoryLedgerProjection
Async Projections
Schedule background jobs with full ActiveJob options:
add_async_projection ReportingProjection, queue: :low, wait: 5.minutes
add_async_projection AnalyticsProjection, wait_until: Date.tomorrow.midnight
Controlling the as_of Timestamp
By default, async projections use the creation time of the last event. You can customize this behavior:
# Use job execution time instead of event time
add_async_projection RealtimeProjection, as_of: :job_time
# Custom logic with a proc
add_async_projection EndOfDayProjection,
as_of: ->(last_event) { last_event.created_at.beginning_of_day }
Available as_of strategies:
:last_event_time(default) — Uses the creation time of the last event:job_time— UsesTime.currentwhen the job executesProc/Lambda— Custom logic that receives the last event and returns aTimeobject
Temporal Queries
Every event is timestamped. Query your stream at any point in time:
Current state
stream = InventoryEventStream.for("sku-12345")
stream.events # => all events
State as of last month
stream = InventoryEventStream.for("sku-12345", 1.month.ago)
stream.events # => events up to that timestamp
Projections receive the as_of parameter, so you can build point-in-time snapshots:
# in your projection
interpretation_for(Inventory::ItemReceived) do |state, event, as_of|
# as_of is available if you need temporal logic
state.quantity_on_hand += event.quantity
state
end
Concurrency
Funes uses optimistic concurrency control. Each event in a stream gets an incrementing version number with a unique constraint on (idx, version).
If two processes try to append to the same stream simultaneously, one succeeds and the other gets a validation error — no locks, no blocking:
event = stream.append!(SomeEvent.new)
unless event.valid?
event.errors[:version] # => ["has already been taken"]
# Reload and retry your business logic
end
Testing
Funes provides helpers for testing projections in isolation:
class InventorySnapshotProjectionTest < ActiveSupport::TestCase
include Funes::ProjectionTestHelper
test "receiving items increases quantity on hand" do
initial_state = InventorySnapshot.new(quantity_on_hand: 10)
event = Inventory::ItemReceived.new(quantity: 5, unit_cost: 9.99)
result = interpret_event_based_on(InventorySnapshotProjection, event, initial_state)
assert_equal 15, result.quantity_on_hand
end
end
Strict Mode
By default, projections ignore events they don't have interpretations for. Enable strict mode to catch missing handlers:
class StrictProjection < Funes::Projection
raise_on_unknown_events
# Now forgetting to handle an event type raises Funes::UnknownEvent
end
License
The gem is available as open source under the terms of the MIT License.