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 requires Rails 7.1+. It uses jsonb columns on PostgreSQL and json on other adapters.
๐ฎ 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' } },
opts: { 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 the self.call method.
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
module Create
def self.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๐งน Development Mode
Set the RDUX_DEV environment variable to prevent Rdux from persisting failed actions on exceptions. When RDUX_DEV is set, the action record is destroyed and the exception is re-raised without storing error details in the database.
RDUX_DEV=1 bin/rails serverThis keeps your development database clean from failed action records caused by exceptions during iterative development.
๐งฉ Process
Process ๐ a series of actions or steps taken in order to achieve a particular end.
Rdux::Process is a persisted model that groups multiple Rdux::Actions.
It also stores an ordered list of steps (jsonb/json).
When a process starts:
- Steps run sequentially in the order defined in
STEPS - Process execution continues only when the latest process action returns
ok: true - Execution stops on the first failed action step (
ok == false) -
process.okis persisted from the latest non-nilstep result
Key points:
-
Rdux::Processhas manyRdux::Actions (process.actions) -
Rdux::Actionbelongs to a process (action.process) -
Rdux.start(ProcessModuleOrClass, payload)starts a process performer (a PORO namespace/class with aSTEPSconstant) -
STEPSmust be anArray(validated onRdux::Process) -
stepsis stored asjsonbon PostgreSQL andjsonon other adapters (default:[]) -
STEPSsupports:- a step definition hash (
{ name: User::Create, payload: ->(payload, prev_res) { ... } }) - a callable step (
->(payload, process) { ... })
- a step definition hash (
- For hash steps, Rdux dispatches
Rdux.perform(step_name, step_payload, process: process)(step_payloadis the full process payload unlesspayload:proc is provided) - For callable steps, Rdux calls the step with
(safe_payload, process)and the step is responsible for dispatching an action (withRdux.perform(..., process:))- โ ๏ธ If a step returns
ok: false, that step action is persisted (and can be assigned to the process) only when it also returnssave: true. This is required.
- โ ๏ธ If a step returns
- Inside an action performer, use
opts[:action]to access the current persisted action, then traverseopts[:action].process.actions(and theirresult) - Actions dispatched inside an action performer (via
Rdux.perform) are linked viardux_action_id(action.rdux_actions) and are not automatically assigned to the process
Example:
module Processes
module Subscription
module Create
STEPS = [
lambda { |payload, process|
payload = payload.slice('plan_id', 'user', 'total_cents')
Rdux.perform(::Subscription::Preview, payload, process:)
},
lambda { |payload, process|
payload = payload.slice('user')
Rdux.perform(User::Create, payload, process:)
},
{ name: CreditCard::Create,
payload: lambda { |payload, prev_res|
payload.slice('credit_card').merge(user_id: prev_res.action.result['user_id'])
} },
{ name: Payment::Create,
payload: ->(_, prev_res) { { token: prev_res.val[:credit_card].token } } },
{ name: ::Subscription::Create,
payload: ->(payload, prev_res) { payload.slice('plan_id').merge(ext_charge_id: prev_res.val[:charge_id]) } }
].freeze
end
end
end
res = Rdux.start(Processes::Subscription::Create, payload)
process = res.val[:process]
# from any action performer:
def self.call(payload, opts)
results = opts[:action].process.actions.order(:id).pluck(:result)
# ...
end๐ ๏ธ Helpers
ActionResult
ActionResult is not part of Rdux itself, but a useful helper you can copy into your app to persist DB changes and resource relations alongside an action.
It sets action.result with:
-
relationsโ a map of"model_name#id" => id(or raw hashes) for each resource that was modified or created -
db_changesโsaved_changesfor each resource that was modified or created - any extra key/value pairs passed as keyword arguments
It also creates an ActionResource record for each AR resource, linking it to the action via a polymorphic association.
Usage:
# inside an action performer
opts[:action].result = ActionResult.call(
action: opts[:action],
resources: [task]
)
# action.result stored in DB:
# {
# "relations" => { "task#1" => 1 },
# "db_changes" => {
# "task#1" => {
# "id" => [nil, 1],
# "name" => [nil, "Foo bar baz"],
# "user_id" => [nil, 42],
# "created_at" => [nil, "2024-06-28 21:35:36"],
# "updated_at" => [nil, "2024-06-28 21:35:36"]
# }
# }
# }Resources can be ActiveRecord objects or plain hashes (merged directly into relations):
ActionResult.call(
action: opts[:action],
resources: [task, { user_id: user.id }],
additional_info: 'Foo Bar Baz'
)ActionResource model (app/models/action_resource.rb):
class ActionResource < ApplicationRecord
belongs_to :action, class_name: 'Rdux::Action'
belongs_to :resource, polymorphic: true
validates :action_id, uniqueness: { scope: %i[resource_type resource_id] }
validates :resource_type, presence: true
endActionResult service (app/services/action_result.rb):
class ActionResult
class << self
def call(action:, resources:, **custom)
result = { relations: {}, db_changes: {} }
resources.each do |resource|
if resource.is_a?(Hash)
result[:relations].merge!(resource)
next
end
key = relation_key(resource)
result[:relations][key] = resource.id
result[:db_changes][key] = resource.saved_changes if resource.saved_changes.present?
end
persist_relations(result[:relations], action.id)
result.merge(custom)
end
private
def relation_key(resource)
"#{resource.class.name.underscore}##{resource.id}"
end
def resource_type_for(name)
type = name.sub(/_id$/, '').sub(/#\d+$/, '').camelize
resource_class = type.safe_constantize
resource_class && resource_class < ApplicationRecord ? type : nil
end
def persist_relations(relations, action_id)
relations.each do |name, id|
resource_type = resource_type_for(name)
next if resource_type.nil? || !id.to_s.match?(/\A\d+\z/)
ActionResource.create!(action_id:, resource_type:, resource_id: id)
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

