/active_event_store

Rails Event Store in a more Rails way

Primary LanguageRubyMIT LicenseMIT

Gem Version Build

Active Event Store

Active Event Store is a wrapper over Rails Event Store which adds conventions and transparent Rails integration.

Motivation

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

Sponsored by Evil Martians

Installation

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

Requirements

  • Ruby (MRI) >= 2.6
  • Rails >= 6.0
  • RailsEventStore >= 2.1

Usage

Describe events

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 id
  • type – 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.

Events registration

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

Publish events

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.

Subscribe to events

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.

Testing

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

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/palkan/active_event_store.

License

The gem is available as open source under the terms of the MIT License.