My first 10 minutes with Eventide

… and check why 5600+ Rails engineers read also this

My first 10 minutes with Eventide

Recently I find out about Eventide project and it looked really nice for me based on the initial documentation so I wanted have a small look inside. Here is what I found in a first few minutes without knowing anything about Eventide so far.

What’s Eventide?

Microservices, Autonomous Services, Service-Oriented Architecture, and Event Sourcing Toolkit for Ruby with Support for Event Store and Postgres

Sounds good to me!

File structure

My first thought was that there is quite interesting structure of directories:

lib/
├── account
│   ├── client
│   │   ├── controls.rb
│   │   └── namespace.rb
│   └── client.rb
├── account_component
│   ├── account.rb
│   ├── commands
│   │   ├── command.rb
│   │   └── withdraw.rb
│   ├── consumers
│   │   ├── commands
│   │   │   └── transactions.rb
│   │   ├── commands.rb
│   │   └── events.rb
│   ├── controls
│   │   ├── account.rb
│   │   ├── commands
│   │   │   ├── close.rb
│   │   │   ├── deposit.rb
│   │   │   ├── open.rb
│   │   │   └── withdraw.rb
│   │   ├── customer.rb
│   │   ├── events
│   │   │   ├── closed.rb
│   │   │   ├── deposited.rb
│   │   │   ├── opened.rb
│   │   │   ├── withdrawal_rejected.rb
│   │   │   └── withdrawn.rb
│   │   ├── id.rb
│   │   ├── message.rb
│   │   ├── money.rb
│   │   ├── position.rb
│   │   ├── replies
│   │   │   └── record_withdrawal.rb
│   │   ├── stream_name.rb
│   │   ├── time.rb
│   │   └── version.rb
│   ├── controls.rb
│   ├── handlers
│   │   ├── commands
│   │   │   └── transactions.rb
│   │   ├── commands.rb
│   │   └── events.rb
│   ├── load.rb
│   ├── messages
│   │   ├── commands
│   │   │   ├── close.rb
│   │   │   ├── deposit.rb
│   │   │   ├── open.rb
│   │   │   └── withdraw.rb
│   │   ├── events
│   │   │   ├── closed.rb
│   │   │   ├── deposited.rb
│   │   │   ├── opened.rb
│   │   │   ├── withdrawal_rejected.rb
│   │   │   └── withdrawn.rb
│   │   └── replies
│   │       └── record_withdrawal.rb
│   ├── projection.rb
│   ├── start.rb
│   └── store.rb
└── account_component.rb

I was not sure where to look first to find the business logic but from the names you can quickly figure out what the project is about.

I looked at start.rb but that didn’t tell me much:

module AccountComponent
  module Start
    def self.call
      Consumers::Commands.start('account:command')
      Consumers::Commands::Transactions.start('accountTransaction')
      Consumers::Events.start('account')
    end
  end
end

I could not easily navigate to an interesting place in RubyMine so I just started poking around.

Commands

The first place that I felt I am on a known ground was around files in lib/account_component/messages/commands/ which include commands such as:

# lib/account_component/messages/commands/deposit.rb
module AccountComponent
  module Messages
    module Commands
      class Deposit
        include Messaging::Message

        attribute :deposit_id, String
        attribute :account_id, String
        attribute :amount, Numeric
        attribute :time, String
      end
    end
  end
end

commands are for telling services/handlers what to do. They’re just data structures. That’s important. So we have our input. Let’s see where it is coming into.

Handlers

In lib/account_component/handlers/commands.rb and lib/account_component/handlers/commands/transactions.rb you can find handlers and the logic for processing those commands. I won’t show the whole code. It’s pretty interesting. Just the most important snippets.


  category :account

  handle Open do |open|
    account_id = open.account_id

    account, version = store.fetch(account_id, include: :version)

    if account.open?
      logger.info(tag: :ignored) { "Command ignored (Command: #{open.message_type}, Account ID: #{account_id}, Customer ID: #{open.customer_id})" }
      return
    end

    time = clock.iso8601

    opened = Opened.follow(open)
    opened.processed_time = time

    stream_name = stream_name(account_id)

    write.(opened, stream_name, expected_version: version)
  end

What I recognized immediately was:

  • getting account in its current version based on historically stored domain events.
account, version = store.fetch(account_id, include: :version)
  • saving new domain events in the account stream using optimistic concurrency (we expect the version has not changed since we got it last time)
write.(opened, stream_name, expected_version: version)

The other interesting parts are:

  • What seems to me like preserving idempotent behavior. Don’t do anything if asked to Open account when the account is already open?
if account.open?
  return
end
  • building new domain event with all the data
opened = Opened.follow(open)
opened.processed_time = time

But at that point I could not easily navigate to follow method to check its implementation. I will probably find out later how it works.

Events

Anyway Opened is a domain event. Let’s see it:

# lib/account_component/messages/events/opened.rb
module AccountComponent
  module Messages
    module Events
      class Opened
        include Messaging::Message

        attribute :account_id, String
        attribute :customer_id, String
        attribute :time, String
        attribute :processed_time, String
      end
    end
  end
end

Events are written to streams. All of the events for a given account are written to that account’s stream. If the account ID is 123, the account’s stream name is account-123, and all events for the account with ID 123 are written to that stream.

Classic thing if you already learned about event sourcing basics.

Handlers again

Here is an interesting thing:

A handler might also respond (or react) to events by other services, or it might respond to events published by its own service (when a service calls itself).

# lib/account_component/handlers/events.rb

  handle Withdrawn do |withdrawn|
    return unless withdrawn.metadata.reply?

    record_withdrawal = RecordWithdrawal.follow(withdrawn, exclude: [
      :transaction_position,
      :processed_time
    ])

    time = clock.iso8601
    record_withdrawal.processed_time = time

    write.reply(record_withdrawal)
  end

Replies

Events and commands are messages in EventIDE. Apparently there is also one more class of messages: Replies.

# lib/account_component/messages/replies/record_withdrawal.rb
module AccountComponent
  module Messages
    module Replies
      class RecordWithdrawal
        include Messaging::Message

        attribute :withdrawal_id, String
        attribute :account_id, String
        attribute :amount, Numeric
        attribute :time, String
        attribute :processed_time, String
      end

I haven’t yet figured out what Replies are used for. It seems interesting.

Model

I wonder where is the logic for changing account balance or checking if the funds are sufficient for withdrawal. Let’s find out.

# lib/account_component/account.rb
module AccountComponent
  class Account
    include Schema::DataStructure

    attribute :id, String
    attribute :customer_id, String
    attribute :balance, Numeric, default: 0
    attribute :opened_time, Time
    attribute :closed_time, Time
    attribute :transaction_position, Integer

    def open?
      !opened_time.nil?
    end

    def closed?
      !closed_time.nil?
    end

    def deposit(amount)
      self.balance += amount
    end

    def withdraw(amount)
      self.balance -= amount
    end

    def current?(position)
      return false if transaction_position.nil?

      transaction_position >= position
    end

    def sufficient_funds?(amount)
      balance >= amount
    end
  end
end

Projection

It’s interesting that even though the model is event sourced you don’t see it when looking at it. Let’s find the place responsible for rebuilding model state based on domain events.

# lib/account_component/projection.rb
module AccountComponent
  class Projection
    include EntityProjection
    include Messages::Events

    entity_name :account

    apply Opened do |opened|
      account.id = opened.account_id
      account.customer_id = opened.customer_id

      opened_time = Time.parse(opened.time)

      account.opened_time = opened_time
    end

    apply Deposited do |deposited|
      account.id = deposited.account_id

      amount = deposited.amount

      account.deposit(amount)

      account.transaction_position = deposited.transaction_position
    end

    apply Withdrawn do |withdrawn|
      account.id = withdrawn.account_id

      amount = withdrawn.amount

      account.withdraw(amount)

      account.transaction_position = withdrawn.transaction_position
    end

    # ...

File structure

So far we haven’t looked at these files in controlers directory. I wonder what’s there.

│   ├── controls
│   │   ├── account.rb
│   │   ├── commands
│   │   │   ├── close.rb
│   │   │   ├── deposit.rb
│   │   │   ├── open.rb
│   │   │   └── withdraw.rb
│   │   ├── customer.rb
│   │   ├── events
│   │   │   ├── closed.rb
│   │   │   ├── deposited.rb
│   │   │   ├── opened.rb
│   │   │   ├── withdrawal_rejected.rb
│   │   │   └── withdrawn.rb
│   │   ├── id.rb
│   │   ├── message.rb
│   │   ├── money.rb
│   │   ├── position.rb
│   │   ├── replies
│   │   │   └── record_withdrawal.rb
│   │   ├── stream_name.rb
│   │   ├── time.rb
│   │   └── version.rb
│   ├── controls.rb

Controls

lib/account_component/controls/commands/close.rb
module AccountComponent
  module Controls
    module Commands
      module Close
        def self.example
          close = AccountComponent::Messages::Commands::Close.build

          close.account_id = Account.id
          close.time = Controls::Time::Effective.example

          close
        end
# lib/account_component/controls/events/withdrawn.rb
module AccountComponent
  module Controls
    module Events
      module Withdrawn
        def self.example
          withdrawn = AccountComponent::Messages::Events::Withdrawn.build

          withdrawn.withdrawal_id = ID.example
          withdrawn.account_id = Account.id
          withdrawn.amount = Money.example
          withdrawn.time = Controls::Time::Effective.example
          withdrawn.processed_time = Controls::Time::Processed.example

          withdrawn.transaction_position = Position.example

          withdrawn
        end
# lib/account_component/controls/account.rb
module AccountComponent
  module Controls
    module Account
      def self.example(balance: nil, transaction_position: nil)
        balance ||= self.balance

        account = AccountComponent::Account.build

        account.id = id
        account.balance = balance
        account.opened_time = Time::Effective::Raw.example

        unless transaction_position.nil?
          account.transaction_position = transaction_position
        end

        account
      end

      module Closed
        def self.example
          account = Account.example
          account.closed_time = Time::Effective::Raw.example
          account
        end
      end

It looks to me like these are helpers that help you build exemplary data, maybe test data. Maybe some kind of builders.

PS

Subscribe to our newsletter to always receive best discounts and free Ruby and Rails lessons every week.

You might also like