Rdux - A Minimal Event Sourcing Plugin for Rails
Rdux is a lightweight, minimalistic Rails plugin designed to introduce event sourcing and audit logging capabilities to your Rails application. With Rdux, you can efficiently track and store the history of actions performed within your app, offering transparency and traceability for key processes.
Key Features
- Audit Logging ๐ Rdux stores sanitized input data, the name of module or class (action performer) responsible for processing them, processing results, and additional metadata in the database.
-
Model Representation ๐ Before action is executed it gets stored in the database through the
Rdux::Actionmodel. This model can be nested, allowing for complex action structures. -
Exception Handling and Recovery ๐ Rdux automatically creates a
Rdux::Actionrecord when an exception occurs during action execution. It retains thepayloadand allows you to capture additional data usingopts[:action].result, ensuring all necessary information is available for retrying the action. - Metadata ๐ Metadata can include the ID of the authenticated resource responsible for performing a given action, as well as resource IDs from external systems related to the action. This creates a clear audit trail of who executed each action and on whose behalf.
Rdux is designed to integrate seamlessly with your existing Rails application, offering a straightforward and powerful solution for managing and auditing key actions.
๐ฒ Instalation
Add this line to your application's Gemfile:
gem 'rdux'And then execute:
$ bundleOr install it yourself as:
$ gem install rduxThen install and run migrations:
$ bin/rails rdux:install:migrations
$ bin/rails db:migrateโ ๏ธ Note: Rdux uses JSONB datatype instead of text for Postgres.
๐ฎ Usage
๐ Dispatching an action
To dispatch an action using Rdux, use the dispatch method (aliased as perform).
Definition:
def dispatch(action, payload, opts = {}, meta: nil)
alias perform dispatchArguments:
-
action: The name of the module or class (action performer) that processes the action.actionis stored in the database as thenameattribute of theRdux::Actioninstance (e.g.,Task::Create). -
payload(Hash): The input data passed as the first argument to thecallmethod of the action performer. The data is sanitized and stored in the database before being processed by the action performer. During deserialization, the keys in thepayloadare converted to strings. -
opts(Hash): Optional parameters passed as the second argument to thecallmethod, if defined. This can help avoid redundant database queries (e.g., if you already have an ActiveRecord object available before callingRdux.perform). A helper is available to facilitate this use case:(opts[:ars] || {}).each { |k, v| payload["#{k}_id"] = v.id }, where:arsrepresents ActiveRecord objects. Note thatoptsis not stored in the database, and thepayloadshould be fully sufficient to perform an action.optsprovides an optimization. -
meta(Hash): Additional metadata stored in the database alongside theactionandpayload.
Example:
Rdux.perform(
Task::Create,
{ task: { name: 'Foo bar baz' } },
{ ars: { user: current_user } },
meta: { bar: 'baz' }
)๐ Flow diagram
๐ต๏ธโโ๏ธ Processing an action
Action in Rdux is processed by an action performer which is a Plain Old Ruby Object (PORO) that implements a class or instance method call.
This method accepts a required payload and an optional opts argument.
opts[:action] stores the Active Record object.
call method processes the action and must return a Rdux::Result struct.
See ๐ Dispatching an action section.
Example:
# app/actions/task/create.rb
class Task
class Create
def call(payload, opts)
user = opts.dig(:ars, :user) || User.find(payload['user_id'])
task = user.tasks.new(payload['task'])
if task.save
Rdux::Result[ok: true, val: { task: }]
else
Rdux::Result[false, { errors: task.errors }]
end
end
end
endSuggested Directory Structure
The location that is often used for entities like actions accross code bases is app/services.
This directory is de facto the bag of random objects.
I'd recomment to place actions inside app/actions for better organization and consistency.
Actions are consistent in terms of structure, input and output data.
They are good canditates to create a new layer in Rails apps.
Structure:
.
โโโ app/actions/
โโโ activity/
โ โโโ common/
โ โ โโโ fetch.rb
โ โโโ create.rb
โ โโโ stop.rb
โ โโโ switch.rb
โโโ task/
โ โโโ create.rb
โ โโโ delete.rb
โโโ misc/
โโโ create_attachment.rb
The dedicated page about actions contains more arguments in favor of actions.
โฉ๏ธ Returned struct Rdux::Result
Definition:
module Rdux
Result = Struct.new(:ok, :val, :result, :save, :nested, :action) do
def save_failed?
ok == false && save ? true : false
end
end
endArguments:
-
ok(Boolean): Indicates whether the action was successful. Iftrue, theRdux::Actionis persisted in the database. -
val(Hash): returned data. -
result(Hash): Stores data related to the actionโs execution, such as created record IDs, DB changes, responses from 3rd parties, etc. that will be persisted asRdux::Action#result. -
save(Boolean): Iftrueandokisfalse, the action is still persisted in the database. -
nested(Array ofRdux::Result):Rdux::Actioncan be connected with otherrdux_actions. To establish an association, a given action mustRdux.dispatchother actions in thecallmethod and add the returned by thedispatchvalue (Rdux::Result) to the:nestedarray -
action: Rdux assigns persistedRdux::Actionto this argument
๐ฟ Data model
payload = {
task: { 'name' => 'Foo bar baz' },
user_id: 159163583
}
res = Rdux.dispatch(Task::Create, payload)
res.action
# #<Rdux::Action:0x000000011c4d8e98
# id: 1,
# name: "Task::Create",
# payload: {"task"=>{"name"=>"Foo bar baz"}, "user_id"=>159163583},
# payload_sanitized: false,
# result: nil,
# meta: {},
# rdux_action_id: nil,
# created_at: Fri, 28 Jun 2024 21:35:36.838898000 UTC +00:00,
# updated_at: Fri, 28 Jun 2024 21:35:36.839728000 UTC +00:00>>๐ท Sanitization
When Rdux.perform is called, the payload is sanitized using Rails.application.config.filter_parameters before being saved to the database.
The action performerโs call method receives the unsanitized version.
๐ฃ๏ธ Queries
Most likely, it won't be necessary to save a Rdux::Action for every request a Rails app receives.
The suggested approach is to save Rdux::Actions for Create, Update, and Delete (CUD) operations.
This approach organically creates a new layer - queries in addition to actions.
Thus, it is required to call Rdux.perform only for actions.
One approach is to create a perform method that invokes either Rdux.perform or a query, depending on the presence of action or query keywords.
This method can also handle setting meta attributes, performing parameter validation, and more.
Example:
class TasksController < ApiController
def show
perform(
query: Task::Show,
payload: { id: params[:id] }
)
end
def create
perform(
action: Task::Create,
payload: create_task_params
)
end
end๐ต๏ธ Indexing
Depending on your use case, itโs recommended to create indices, especially when using PostgreSQL and querying JSONB columns.
Rdux::Action is a standard ActiveRecord model.
You can inherit from it and extend.
Example:
class Action < Rdux::Action
include Actionable
end๐ Recovering from Exceptions
Rdux captures exceptions raised during the execution of an action and sets the Rdux::Action#ok attribute to false.
The payload is retained, but having only the input data is often not enough to retry an action.
It is crucial to capture data obtained during the actionโs execution, up until the exception occurred.
This can be done by using opts[:action].result attribute to store all necessary data incrementally.
Example:
class CreditCard
class Charge
class << self
def call(payload, opts)
create_res = create(payload.slice('user_id', 'credit_card'), opts.slice(:user))
return create_res unless create_res.ok
opts[:action].result = { credit_card_create_action_id: create_res.action.id }
charge_id = PaymentGateway.charge(create_res.val[:credit_card].token, payload['amount'])[:id]
if charge_id.nil?
Rdux::Result[ok: false, val: { errors: { base: 'Invalid credit card' } }, save: true,
nested: [create_res]]
else
Rdux::Result[ok: true, val: { charge_id: }, nested: [create_res]]
end
end
private
def create(payload, opts)
res = Rdux.perform(Create, payload, opts)
res.ok ? res : Rdux::Result[ok: false, val: { errors: res.val[:errors] }, save: true]
end
end
end
end๐ฉ๐ฝโ๐ฌ Testing
๐ Setup
$ cd test/dummy
$ DB=all bin/rails db:create
$ DB=all bin/rails db:prepare
$ cd ../..๐งช Run tests
$ DB=postgres bin/rails test
$ DB=sqlite bin/rails test๐ License
The gem is available as open source under the terms of the MIT License.
๐จโ๐ญ Author
Zbigniew Humeniuk from Art of Code

