Active Event Store is a wrapper over Rails Event Store which adds conventions and transparent Rails integration.
Why creating a wrapper and not using Rails Event Store itself?
RES is an awesome project but, in our opinion, it lacks Rails simplicity and elegance (=conventions and less boilerplate). It's an advanced tool for advanced developers. We've been using it in multiple projects in a similar way, and decided to extract our approach into this gem (originally private).
Secondly, we wanted to have a store implementation independent API that would allow us to adapterize the actual event store in the future (something like ActiveEventStore.store_engine = :rails_event_store
or ActiveEventStore.store_engine = :hanami_events
).
Add the gem to your project:
# Gemfile
gem "active_event_store", "~> 1.0"
Setup database according to the Rails Event Store docs:
rails generate rails_event_store_active_record:migration
rails db:migrate
- Ruby (MRI) >= 2.6
- Rails >= 6.0
- RailsEventStore >= 2.1
Events are represented by event classes, which describe events payloads and identifiers:
class ProfileCompleted < ActiveEventStore::Event
# (optional) event identifier is used for transmitting events
# to subscribers.
#
# By default, identifier is equal to `name.underscore.gsub('/', '.')`.
#
# You don't need to specify identifier manually, only for backward compatibility when
# class name is changed.
self.identifier = "profile_completed"
# Add attributes accessors
attributes :user_id
# Sync attributes only available for sync subscribers
# (so you can add some optional non-JSON serializable data here)
# For example, we can also add `user` record to the event to avoid
# reloading in sync subscribers
sync_attributes :user
end
NOTE: we use JSON to serialize events, thus only the simple field types (numbers, strings, booleans) are supported.
Each event has predefined (reserved) fields:
event_id
– unique event idtype
– event type (=identifier)metadata
We suggest to use a naming convention for event classes, for example, using the past tense and describe what happened (e.g. "ProfileCreated", "EventPublished", etc.).
We recommend to keep event definitions in the app/events
folder.
Since we use abstract identifiers instead of class names, we need a way to tell our mapper how to infer an event class from its type.
In most cases, we register events automatically when they're published or when a subscription is created.
You can also register events manually:
# by passing an event class
ActiveEventStore.mapping.register_event MyEventClass
# or more precisely (in that case `event.type` must be equal to "my_event")
ActiveEventStore.mapping.register "my_event", MyEventClass
To publish an event you must first create an instance of the event class and call ActiveEventStore.publish
method:
event = ProfileCompleted.new(user_id: user.id)
# or with metadata
event = ProfileCompleted.new(user_id: user.id, metadata: {ip: request.remote_ip})
# then publish the event
ActiveEventStore.publish(event)
That's it! Your event has been stored and propagated to the subscribers.
To subscribe a handler to an event you must use ActiveEventStore.subscribe
method.
You can do this in your app or engine initializer:
# some/engine.rb
# To make sure event store has been initialized use the load hook
# `store` == `ActiveEventStore`
ActiveSupport.on_load :active_event_store do |store|
# async subscriber – invoked from background job, enqueued after the current transaction commits
# NOTE: all subscribers are asynchronous by default
store.subscribe MyEventHandler, to: ProfileCreated
# sync subscriber – invoked right "within" `publish` method
store.subscribe MyEventHandler, to: ProfileCreated, sync: true
# anonymous handler (could only be synchronous)
store.subscribe(to: ProfileCreated, sync: true) do |event|
# do something
end
# you can omit event if your subscriber follows the convention
# for example, the following subscriber would subscribe to
# ProfileCreated event
store.subscribe OnProfileCreated::DoThat
end
Subscribers could be any callable Ruby objects that accept a single argument (event) as its input or classes that inherit from Class
and have #call
as an instance method.
We suggest putting subscribers to the app/subscribers
folder using the following convention: app/subscribers/on_<event_type>/<subscriber.rb>
, e.g. app/subscribers/on_profile_created/create_chat_user.rb
.
NOTE: Active Job must be loaded to use async subscribers (i.e., require "active_job/railtie"
or require "rails/all"
in your config/application.rb
).
NOTE: Subscribers that inherit from Class
and implement call
as a class method will not be instantiated.
You can test subscribers as normal Ruby objects.
NOTE To test using minitest include the ActiveEventStore::TestHelpers
module in your tests.
To test that a given subscriber exists, you can use the have_enqueued_async_subscriber_for
matcher:
# for asynchronous subscriptions (rspec)
it "is subscribed to some event" do
event = MyEvent.new(some: "data")
expect { ActiveEventStore.publish event }
.to have_enqueued_async_subscriber_for(MySubscriberService)
.with(event)
end
# for asynchronous subscriptions (minitest)
def test_is_subscribed_to_some_event
event = MyEvent.new(some: "data")
assert_async_event_subscriber_enqueued(MySubscriberService, event: event) do
ActiveEventStore.publish event
end
end
NOTE Async event subscribers are queued only after the current transaction has committed so when using assert_enqued_async_subcriber
in rails
make sure to have self.use_transactional_fixtures = false
at the top of your test class.
NOTE: You must have rspec-rails
gem in your bundle to use have_enqueued_async_subscriber_for
matcher.
For synchronous subscribers using have_received
is enough:
it "is subscribed to some event" do
allow(MySubscriberService).to receive(:call)
event = MyEvent.new(some: "data")
ActiveEventStore.publish event
expect(MySubscriberService).to have_received(:call).with(event)
end
To test event publishing, use have_published_event
matcher:
# rspec
expect { subject }.to have_published_event(ProfileCreated).with(user_id: user.id)
# minitest
assert_event_published(ProfileCreated, with: {user_id: user.id}) { subject }
NOTE: have_published_event
and assert_event_published
only supports block expectations.
NOTE 2 with
modifier works like have_attributes
matcher (not contain_exactly
); you can only specify serializable attributes in with
(i.e. sync attributes are not supported, 'cause they are not persistent).
Bug reports and pull requests are welcome on GitHub at https://github.com/palkan/active_event_store.
The gem is available as open source under the terms of the MIT License.