DEV Community

Oleg Borys for Smily

Posted on • Originally published at smily.com

Event Sourcing with Rails from scratch

In the previous article Introduction to Event Sourcing and CQRS we got familiar with the main concepts of Event Sourcing and reviewed the cons and pros of this approach.

Implementing Event Sourcing in Rails can be a powerful way to handle complex business logic and maintain a reliable audit trail of changes to your application's state. Event Sourcing involves storing a sequence of events that represent changes to the state of your application over time.

Now, before we plummet into using Event Sourcing in Rails with help or battle-tested solutions, let's research the basics and learn how to implement things from scratch. However, be aware - you should think twice before using custom implementation (too many chances you going to miss some small detail and the consequences may be huge).
For these purposes let's create a simple application for listing rental advertisements on the website.

The process may be quite complicated, but for the sake of simplicity, let's assume it goes as below:

  • the ad listing is created
  • the content is updated
  • the listing goes published on the website
  • ad listing removed

Preparations

where we start building a path in our journey and getting familiar with events

So, let's create a new project for our implementation purposes first:

> rails new event_sourced_ads --database=postgresql --skip-test
Enter fullscreen mode Exit fullscreen mode

Skipping tests, since I’m a rspec fan so we'll use that:

gem "rspec-rails", "~> 6.1.0"
Enter fullscreen mode Exit fullscreen mode

Then bundle and rails generate rspec:install.

Now, once the initial setup is done we are going to use events. Let’s implement that part. And guess what we’re starting with:

dclass CreateEvents < ActiveRecord::Migration[7.1]
  def change
    create_table :events, id: :uuid do |t|
      t.string :stream_name
      t.string :event_type
      t.jsonb :data

      t.timestamps
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

We use UUID here for primary keys. When dealing with distributed systems and the need for worldwide uniqueness, opting for a UUID could be the optimal decision.

We need streams to separate events related to certain entities. Streams are needed to group events of a particular kind. In our case, I’m going to group events related to one single ad, so those can be easily fetched. Frankly speaking, it’s not a good idea to store stream names like that, since the events may be in different streams. For the sake of simplicity, let’s consider doing some evil (we’ll do more till the end).

We need some basic event class that we can publish and verify input with, also we need some way to use pub-sub (quite a crucial part). I’m excited about dry-rb stack - I can’t say it’s perfect, but it usually perfectly suits all my needs, so I'm turning on the imagination and seeing what it brings… ImaginationCompleted.publish(data: {idea: "Create BaseEvent", pub_sub: "KISS rails has one built-in"})

Who am I to argue with that 😇 Let’s start with a spec:

...
it "persists an event record" do
  expect { publish }.to change { Event.count }.by(1)
  expect(Event.last).to have_attributes(
    event_type: "FakeEvent",
    data: {"name" => "whatever"},
    stream_name: "123123"
  )
end

it "sends a notification" do
  allow(ActiveSupport::Notifications).to receive(:instrument)
  publish
  expect(ActiveSupport::Notifications).to have_received(:instrument).with(
    "FakeEvent", data: {name: "whatever"}, stream_name: "123123"
  )
end
...
Enter fullscreen mode Exit fullscreen mode

and after some struggle, we come up with:

# lib/events/base_event.rb
module Events
  class BaseEvent
    class InvalidAttributes < StandardError; end

    class MissingContract < StandardError; end

    attr_reader :data

    def self.schema(&block)
      inner_schema = block.call
      define_method(:params_schema) do
        Dry::Schema.Params do
          required(:data).hash(inner_schema)
        end
      end
    end

    def self.publish(**args)
      new(**args.slice(:data)).publish(stream_name: args[:stream_name])
    end

    def initialize(**args)
      validate_input(args)
      @data = args[:data]
    end

    def publish(stream_name: nil)
      Event.create!(
        event_type: self.class.name, data:, stream_name:
      )
      ActiveSupport::Notifications.instrument(self.class.name, data:, stream_name:)
      self
    end

    def params_schema
      ->(_) { raise MissingContract, "Contract needs to be implemented" }
    end

    def validate_input(args)
      data_validation = params_schema.call(args)
      raise InvalidAttributes.new(data_validation.errors.to_h) if data_validation.errors.any?
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

And let’s try using our new pet. You know where to start…

RSpec.describe Events::AdCreated do
  describe ".publish" do
    subject(:publish) do
      described_class.publish(
        data: {title: "Some title", body: "Some description"},
        stream_name: "123456789",
      )
    end

    it "persists the event in database" do
      expect { publish }.to change { Event.count }.by(1)
      expect(Event.last).to have_attributes(
        event_type: "Events::AdCreated",
        data: {
          "title" => "Some title",
          "body" => "Some description"
        },
        stream_name: "123456789"
      )
    end
  end
end 
Enter fullscreen mode Exit fullscreen mode

and the event itself:

# lib/events/ad_created.rb
class Events::AdCreated < Events::BaseEvent
  schema do
    Dry::Schema.Params do
      required(:title).filled(:string)
      required(:body).filled(:string)
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Aggregate part

the one where we learn to manipulate our ads

So, as user we should be able to create new ad, possibly modify that and publish. However, we shouldn’t be able to edit already published ad. So we need some consistency in actions and having corresponding event published after the action is executed. That’s where the aggregate comes to place.

So, we create AdAggregate class and start with test for the new instance:

# spec/services/ad_aggregate_spec.rb
RSpec.describe AdAggregate do
    it "has valid attributes on initialization" do
    expect(aggregate).to have_attributes(
      id: kind_of(String),
      state: :new
    )
  end
# app/services/ad_aggregate.rb
class AdAggregate
  attr_reader :id, :attributes, :state

  def initialize(id = nil)
    @id = id || SecureRandom.uuid
    @state = :new
  end
end
Enter fullscreen mode Exit fullscreen mode

Next we need possibility to actually create new draft and have those attributes in the aggregate. Also we need to publish an event that the draft is created.

describe "#create_draft" do
  subject(:create_draft) { aggregate.create_draft(**attributes) }

  context "with valid attributes" do
    let(:attributes) { valid_attributes }

    it "updates attributes and state" do
      create_draft
      expect(aggregate).to have_attributes(
        attributes: {
          title: "Test title",
          body: "Test description"
        },
        state: :draft
      )
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

The one is easy to implement, but we face a problem here. We should be able to restore the state of the aggregate later when we want to apply next actions to that. The aggregate is supposed to be event sourced one. So we need a way to apply events to that and all we should actually do here is to apply an event

def create_draft(title:, body:)
  apply Events::AdCreated.new(data: {ad_id: id, title:, body:})
end
Enter fullscreen mode Exit fullscreen mode

We need a handler in the aggregate to understand how we modify the attributes, how the state is changed and how we can restore the state of an aggregate from history of events (for this purpose we’ll create another class in a while 😉). Also, the events should be published when we store the aggregate. For these purposes, let’s add handler methods to explain how we want to modify aggregate’s state on event and common method that will also create a queue of unpublished events

  def unpublished_events
    @unpublished_events ||= []
  end

  def apply_event(event)
    send("apply_#{event.class.name.demodulize.underscore}", event)
  end

    private 

    def apply(event)
    unpublished_events << event
    apply_event(event)
  end

  def apply_ad_created(event)
    @state = :draft
    @attributes = event.data.slice(:title, :body)
  end
Enter fullscreen mode Exit fullscreen mode

So, when the new event is applied we save that in a queue of unpublished events and call the corresponding handler. But what’s the sense of that without having the events stored? How to fetch previously created aggregate? We could implement that here in this class, though according Single Responsibility Principle, it’s definitely a work that someone else should do. That’s where we need a repository:

# frozen_string_literal: true

require "rails_helper"

RSpec.describe Repository do
  describe '.load' do
    subject(:load) { described_class.load(aggregate_class, stream_name) }

    let(:aggregate_class) { AdAggregate }
    let(:stream_name) { SecureRandom.uuid }

    context "without events" do
      it "loads new aggregate" do
        expect(load).to be_instance_of(aggregate_class).and have_attributes(
          id: stream_name,
          state: :new
        )
      end
    end

    context "with existing events" do
      context "when applying AdCreated event" do
        before do
          Event.create(
            event_type: "Events::AdCreated", stream_name:,
            data: {ad_id: stream_name, title: "title", body: "body"}
          )
        end

        it "applies event to aggregate" do
          expect(load).to be_a(AdAggregate).and have_attributes(
            id: stream_name,
            state: :draft,
            attributes: {
              title: "title",
              body: "body"
            }
          )
        end

        context "when applying AdPublished" do
          before do
            Event.create(
              event_type: "Events::AdPublished", stream_name:,
              data: {ad_id: stream_name, remote_id: "xosfjoj"}
            )
          end

          it "applies event to aggregate" do
            expect(load).to be_a(AdAggregate).and have_attributes(
              id: stream_name,
              state: :published,
              attributes: {
                title: "title",
                body: "body"
              }
            )
          end
        end
      end
    end
  end

  describe '.store' do
    subject(:store) { described_class.store(aggregate) }

    context "with unpublished events" do
      let(:aggregate) do
        instance_double(AdAggregate, id: stream_name, unpublished_events: [event])
      end
      let(:stream_name) { SecureRandom.uuid }
      let(:event) do
        Events::AdCreated.new(data: {ad_id: stream_name, title: "title", body: "body"})
      end

      it "publishes pending events" do
        expect { store }.to change { Event.count }.by(1)
        expect(Event.last).to have_attributes(
          stream_name:,
          event_type: "Events::AdCreated",
          data: {
            "ad_id" => stream_name,
            "title" => "title",
            "body" => "body"
          }
        )
      end
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

and the implementation of that is easy enough. I’ll omit description of that to save some precious space and time

module Repository
  extend self

  def load(aggregate_class, stream_name)
    events = Event.where(stream_name:).map do |event|
      event.event_type.constantize.new(data: event.data)
    end
    aggregate_class.new(stream_name).tap do |aggregate|
      events.each do |event|
        aggregate.apply_event(event)
      end
    end
  end

  def store(aggregate)
    aggregate.unpublished_events.each do |event|
      event.publish(stream_name: aggregate.id)
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

We do have a possibility to store the aggregate to load that from existing events. However, we are missing one of the main purposes for the aggregate. We should disallow editing already published ads, also we definitely can’t publish the same ad twice (well technically we can, but for sure that’s wrong). So, as usually:

describe "#update_content" do
  subject(:update_content) { aggregate.update_content(**new_attributes) }

  let(:aggregate) { described_class.new }
  let(:new_attributes) do
    {title: "Updated title", body: "Updated description"}
  end

  context "when ad is in draft state" do
    before { aggregate.create_draft(**valid_attributes) }

    it "updates ad attributes" do
      update_content
      expect(aggregate).to have_attributes(
        attributes: {
          title: "Updated title",
          body: "Updated description"
        },
        state: :draft
      )
    end
  end

  context "when ad is in published state" do
    before do
      aggregate.create_draft(**valid_attributes)
      aggregate.publish
    end

    it "raises an error" do
      expect { update_content }.to raise_error(described_class::AlreadyPublished)
    end
  end
end

describe "#publish" do
  subject(:publish) { aggregate.publish }

  let(:aggregate) { described_class.new }

  context "when ad is in draft state" do
    before { aggregate.create_draft(**valid_attributes) }

    it "updates state to published" do
      publish
      expect(aggregate.state).to eq(:published)
    end
  end

  context "when ad is in published state" do
    before do
      aggregate.create_draft(**valid_attributes)
      aggregate.publish
    end

    it "raises an error" do
      expect { publish }.to raise_error(described_class::AlreadyPublished)
    end
  end
end 
Enter fullscreen mode Exit fullscreen mode

You can check the implementation of the methods in the repository and it’d be a good idea to try implementing that by yourself 😉 

CQRS part

the one where we get familiar with read models and presentation to users

Ok, pub is ready, now it’s time to drink some beer have sub part:

# config/initializers/event_listeners.rb
Rails.application.config.after_initialize do
  {
    AdEventListener: [
      Events::AdCreated,
    ]
  }.each do |listener, events|
    events.each { |event| ActiveSupport::Notifications.subscribe(event.to_s, listener.to_s.constantize) }
  end
end
Enter fullscreen mode Exit fullscreen mode

So, here we are going to rule where events happen to be. In the example, AdEventListener will get the ActiveSupport event we broadcast with BaseEvent and send a call to our listener. Perfect… but not exactly what we need.

class ApplicationEventListener
  def self.call(event)
    public_send(
      "apply_#{event.name.demodulize.underscore}", **event.payload
    )
  end
end
Enter fullscreen mode Exit fullscreen mode

and now we should be able to create listeners in a very convenient form:

class AdEventListener < ApplicationEventListener
  class << self
    def apply_ad_created(data:, stream_name:)
      Ad.create!(id: stream_name, **data)
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

🤔 …but stop, something’s wrong here. What’s Ad.create!? We don’t have that implemented… The part is omitted for a reason.

What we implemented above is a CQRS system and Ad is a read model. The structure of that is not important and should suit your needs. In this example project I’ve implemented Events::AdModified, Events::AdPublished, Events::AdRemoved. You can get familiar with the project

Retrospective part

the one where we look over what we did

We’ve just implemented an application using Event Sourcing from scratch. I definitely would recommend to stay away from self-made solutions in production. Several simplifications were made (but you may need those once your project grows). Anyway, it’s good to know what‘s inside the black-box (gem) you use.

In the upcoming articles we’re going to play with some recognized instruments to implement event sourced applications

Top comments (0)