Since our architecture is modularized, we need a way for our components/engines to communicate with each other.
We do this by adding a pub/sub (or event sourcing) layer to our application via Rails Event Store.
We do not use RES directly but through the railsy-events
engine, which wraps RES functionality and provide its own API. That would allow us to replace RES in the future (if necessary) without changing our application code.
Events are represented by event classes, which describe events payloads and identifiers:
class ProfileCompleted < Railsy::Events::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
NOTE: events should be in the past tense and describe what happened (e.g. "ProfileCreated", "EventPublished", etc.).
Events are stored in 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.
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
Railsy::Events.mapper.register_event MyEventClass
# or more precisely (in that case `event.type` must be equal "my_event")
Railsy::Events.mapper.register "my_event", MyEventClass
To publish an event you must first create an instane of the event class and call Railsy::Events.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
Railsy::Events.publish(event)
That's it! Your event has been stored and propagated.
To subscribe a handler to an event you must use Railsy::Events.subscribe
method.
You shoud do this in your app or engine initializer:
# some/engine.rb
initializer "my_engine.subscribe_to_events" do
# To make sure event store is initialized use load hook
# `store` == `Common::Events`
ActiveSupport.on_load "railsy-events" do |store|
# async subscriber – invoked from background job, enqueued after the current transaction commits
# NOTE: all subsribers are asynchrounous 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: ConnectBy::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
end
NOTE: event handler must be a callable object.
Although subscriber could be any callable Ruby object, that have specific input format (event); thus we suggest putting subscribers under app/subscribers/on_<event_type>/<subscriber.rb>
, e.g. app/subscribers/on_profile_created/create_chat_user.rb
).
You can test subscribers as normal Ruby objects.
To test that a given subscriber exists, you can do the following:
# for asynchrounous subscriptions
it "is subscribed to some event" do
event = MyEvent.new(some: "data")
expect { Railsy::Events.publish event }.
to have_enqueued_async_subscriber_for(MySubscriberService).
with(event)
end
# for synchrounous subscriptions
it "is subscribed to some event" do
allow(MySubscriberService).to receive(:call)
event = MyEvent.new(some: "data")
Railsy::Events.publish event
expect(MySubscriberService).to have_received(:call).with(event)
end
To test publishing use have_published_event
matcher:
expect { subject }.to have_published_event(ProfileCreated).with(user_id: user.id)
NOTE: have_published_event
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 do not persist).
See RailsEventStore/rails_event_store#561