This is a very minimalist implementation of an event sourcing pattern, if you want a full-featured framework in ruby you can check out one of these:
I wanted to learn how to build this from scratch and also wanted to build something that would be very easy to use since most of the fully featured frameworks like the two above require a lot of configuration and learning.
Important notice
This plugin will only work with Postgres database because it uses JSONB data type which is only supported by this database.
Usage
So how does it all work?
Let's start with the directory structure:
app/
├─ domain/
│ ├─ customer/
│ │ ├─ command_handlers/
│ │ │ ├─ create.rb
│ │ ├─ events/
│ │ │ ├─ customer_created.rb
│ │ ├─ commands/
│ │ │ ├─ create.rb
The name of the top directory can be different because Rails does not namespace it.
Based on the example above, the usage looks like this
Command -> Command Handler -> Create Event (which under the hood writes changes to the appropriate model)
Explanation of each of these blocks above:
-
Command
- is responsible for any action you want to take in your system, it is also responsible for validating the input parameters it takes (you can use the same validations you would normally use in models).
Example:
class Customer
module Commands
class Create < RailsSimpleEventSourcing::Commands::Base
attr_accessor :first_name, :last_name, :email
validates :first_name, presence: true
validates :last_name, presence: true
validates :email, presence: true
end
end
end
-
CommandHandler
- is responsible for handling the passed command (it automatically checks if a command is valid), making additional API calls, doing additional business logic, and finally creating a proper event. This should always return theRailsSimpleEventSourcing::Result
struct.
This struct has 3 keywords:
-
success?:
true/false (if everything went well, commands are automatically validated, but there might still be an API call here, etc., so you can return false if something went wrong) -
data:
data that you want to return, eg. to the controller (in the example above theevent.aggregate
will return a proper instance of the Customer model) -
errors:
in a scenario where you setsuccess?:false
, you can also return some related errors here (see: test/dummy/app/domain/customer/command_handlers/create.rb for an example)
Example:
class Customer
module CommandHandlers
class Create < RailsSimpleEventSourcing::CommandHandlers::Base
def call
event = Customer::Events::CustomerCreated.create(
first_name: @command.first_name,
last_name: @command.last_name,
email: @command.email,
created_at: Time.zone.now,
updated_at: Time.zone.now
)
RailsSimpleEventSourcing::Result.new(success?: true, data: event.aggregate)
end
end
end
end
-
Event
- is responsible for storing immutable data of your actions, you should use past tense for naming events since an event is something that has already happened (e.g. customer was created)
Example:
class Customer
module Events
class CustomerCreated < RailsSimpleEventSourcing::Event
aggregate_model_name Customer
event_attributes :first_name, :last_name, :email, :created_at, :updated_at
def apply(aggregate)
aggregate.id = aggregate_id
aggregate.first_name = first_name
aggregate.last_name = last_name
aggregate.email = email
aggregate.created_at = created_at
aggregate.updated_at = updated_at
end
end
end
end
In the example above:
-
aggregate_model_name
is used for the corresponding model (each model is normally set to read-only mode since the only way to modify it should be via events), this param is optional since you can have an event that is not applied to the model, e.g. UserLoginAlreadyTaken -
event_attributes
- defines params that will be stored in the event and these params will be available to apply to the model via theapply(aggregate)
method (where aggregate is an instance of your model passed in aggregate_model_name).
Here is an example of a custom controller that uses all the blocks described above:
class CustomersController < ApplicationController
def create
cmd = Customer::Commands::Create.new(
first_name: params[:first_name],
last_name: params[:last_name],
email: params[:email]
)
handler = RailsSimpleEventSourcing::CommandHandler.new(cmd).call
if handler.success?
render json: handler.data
else
render json: { errors: handler.errors }, status: :unprocessable_entity
end
end
end
Now, if you make an API call using curl:
curl -X POST http://localhost:3000/customers \
-H 'Content-Type: application/json' \
-d '{ "first_name": "John", "last_name": "Doe" }' | jq
You will get the response:
{
"id": 1,
"first_name": "John",
"last_name": "Doe",
"created_at": "2024-08-03T16:52:30.829Z",
"updated_at": "2024-08-03T16:52:30.848Z"
}
Run rails c
and do the following:
Customer.last
=>
#<Customer:0x0000000107e20998
id: 1,
first_name: "John",
last_name: "Doe",
created_at: Sat, 03 Aug 2024 16:52:30.829043000 UTC +00:00,
updated_at: Sat, 03 Aug 2024 16:52:30.848243000 UTC +00:00>
Customer.last.events
[#<Customer::Events::CustomerCreated:0x0000000108dbcac8
id: 1,
type: "Customer::Events::CustomerCreated",
event_type: "Customer::Events::CustomerCreated",
aggregate_id: "1",
eventable_type: "Customer",
eventable_id: 1,
payload: {"last_name"=>"Doe", "created_at"=>"2024-08-03T16:58:59.952Z", "first_name"=>"John", "updated_at"=>"2024-08-03T16:58:59.952Z"},
metadata:
{"request_id"=>"2a40d4f9-509b-4b49-a39f-d978679fa5ef",
"request_ip"=>"::1",
"request_params"=>{"action"=>"create", "customer"=>{"last_name"=>"Doe", "first_name"=>"John"}, "last_name"=>"Doe", "controller"=>"customers", "first_name"=>"John"},
"request_user_agent"=>"curl/8.6.0"},
created_at: Sat, 03 Aug 2024 16:58:59.973815000 UTC +00:00,
updated_at: Sat, 03 Aug 2024 16:58:59.973815000 UTC +00:00>]
As you can see, customer has been created and if you check its .events
relationship, you should see an event that created it.
This event has the same attributes in the payload as you set using the event_attributes
method of the Customer::Events::CustomerCreated
class.
There is also a metadata field, which is also defined as JSON, and you can store additional things in this field (this is just for information).
To have these metadata fields populated automatically, you need to include RailsSimpleEventSourcing::SetCurrentRequestDetails
in your ApplicationController.
Example:
class ApplicationController < ActionController::Base
include RailsSimpleEventSourcing::SetCurrentRequestDetails
end
You can override metadata fields by defining the event_metadata
method in the controller, this method should return a Hash which will be stored in the metadata field of the event.
By default, this method looks like this:
def event_metadata
parameter_filter = ActiveSupport::ParameterFilter.new(Rails.application.config.filter_parameters)
{
request_id: request.uuid,
request_user_agent: request.user_agent,
request_referer: request.referer,
request_ip: request.ip,
request_params: parameter_filter.filter(request.params)
}
end
Important notice
The data stored in the events should be immutable (i.e., you shouldn't change it after it's created), so they have simple protection against accidental modification, which means that the model is marked as read-only.
The same goes for models, any model that should be updated by events should include include RailsSimpleEventSourcing::Events
, this will give you access to the .events
relation and you will have read-only protection as well (model should only be updated by creating an event).
Example:
class Customer < ApplicationRecord
include RailsSimpleEventSourcing::Events
end
One thing to note here is that it would be better to do soft-deletes (mark record as deleted) instead of deleting records from the DB, since every record has relations called events
when you have all the events that were applied to it.
More examples
There is a sample application in the test/dummy/app
directory so you can see how updates and deletes are handled.
Installation
Add this line to your application's Gemfile:
gem "rails_simple_event_sourcing"
And then execute:
$ bundle
Or install it yourself as:
$ gem install rails_simple_event_sourcing
Copy migration to your app:
rails rails_simple_event_sourcing:install:migrations
And then run the migration in order to create the rails_simple_event_sourcing_events table (the table that will store the event log):
rake db:migrate
License
The gem is available as open source under the terms of the MIT License.