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 alreadyopen?
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.