Skip to content

A Minimal Event Sourcing Plugin for Rails

License

Notifications You must be signed in to change notification settings

artofcodelabs/rdux

Folders and files

NameName
Last commit message
Last commit date

Latest commit

ย 

History

54 Commits
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 

Repository files navigation

Rdux - A Minimal Event Sourcing Plugin for Rails

GitHub GitHub tag (latest SemVer)

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::Action model. Rdux::Action is converted to the Rdux::FailedAction when it fails. These models can be nested, allowing for complex action structures.
  • Revert and Retry ๐Ÿ‘‰ Rdux::Action can be reverted. Rdux::FailedAction retains the input data and processing results necessary for implementing custom mechanisms to retry failed actions.
  • Exception Handling and Recovery ๐Ÿ‘‰ Rdux automatically creates a Rdux::FailedAction when an exception occurs during action execution. It retains the up_payload and allows you to capture additional data using opts[:up_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.
  • Streams ๐Ÿ‘‰ Rdux enables the identification of action chains (streams) by utilizing resource IDs stored in metadata. This makes it easy to query and track related actions.

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:

$ bundle

Or install it yourself as:

$ gem install rdux

Then 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 dispatch

Arguments:

  • action: The name of the module or class (action performer) that processes the action. This is stored in the database as an instance of Rdux::Action, with its name attribute set to action (e.g., Task::Create).
  • payload (Hash): The input data passed as the first argument to the call or up method 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 the payload are converted to strings.
  • opts (Hash): Optional parameters passed as the second argument to the call or up method, if defined. This can help avoid redundant database queries (e.g., if you already have an ActiveRecord object available before calling Rdux.perform). A helper is available to facilitate this use case: (opts[:ars] || {}).each { |k, v| payload["#{k}_id"] = v.id }, where :ars represents ActiveRecord objects. Note that opts is not stored in the database, and the payload should be fully sufficient to perform an action. opts provides an optimization.
  • meta (Hash): Additional metadata stored in the database alongside the action and payload. The stream key is particularly useful for specifying the stream of actions used during reversions. For example, a stream can be constructed based on the owner of the action.

Example:

Rdux.perform(
  Task::Create,
  { task: { name: 'Foo bar baz' } },
  { ars: { user: current_user } },
  meta: {
    stream: { user_id: current_user.id, context: 'foo' },
    bar: 'baz'
  }
)

๐Ÿ“ˆ Flow diagram

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 or up.
This method must return a Rdux::Result struct.
Optionally, an action can implement a class or instance method down to specify how to revert it.

Action Structure:

  • call or up method: Accepts a required payload and an optional opts argument. This method processes the action and returns a Rdux::Result.
  • down method: Accepts the deserialized down_payload which is one of arguments of the Rdux::Result struct returned by the up method on success and saved in DB. down method can optionally accept the 2nd argument (Hash) which :nested key contains nested Rdux::Actions

See ๐Ÿš› Dispatching an action section.

Examples:

# app/actions/task/create.rb

class Task
  class Create
    def up(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, down_payload: { user_id: user.id, task_id: task.id }, val: { task: }]
      else
        Rdux::Result[false, { errors: task.errors }]
      end
    end

    def down(payload)
      Delete.up(payload)
    end
  end
end
# app/actions/task/delete.rb

class Task
  module Delete
    def self.up(payload)
      user = User.find(payload['user_id'])
      task = user.tasks.find(payload['task_id'])
      task.destroy
      Rdux::Result[true, { task: task.attributes }]
    end
  end
end

Suggested 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, :down_payload, :val, :up_result, :save, :after_save, :nested, :action) do
    def val
      self[:val] || down_payload
    end

    def save_failed?
      ok == false && save
    end
  end
end

Arguments:

  • ok (Boolean): Indicates whether the action was successful. If true, the Rdux::Action is persisted in the database.
  • down_payload (Hash): Passed to the action performerโ€™s down method during reversion (down method is called on Rdux::Action). It does not have to be defined if an action performer does not implement the down method. down_payload is saved in the DB.
  • val (Hash): Contains different returned data than down_payload.
  • up_result (Hash): Stores data related to the actionโ€™s execution, such as created record IDs, DB changes, responses from 3rd parties, etc.
  • save (Boolean): If true and ok is false, the action is saved as a Rdux::FailedAction.
  • after_save (Proc): Called just before the dispatch method returns the Rdux::Result with Rdux::Action or Rdux::FailedAction as an argument.
  • nested (Array of Rdux::Result): Rdux::Action can be connected with other rdux_actions. Rdux::FailedAction can be connected with other rdux_actions and rdux_failed_actions. To establish an association, a given action must Rdux.dispatch other actions in the up or call method and add the returned by the dispatch value (Rdux::Result) to the :nested array
  • action: Rdux assigns Rdux::Action or Rdux::FailedAction to this argument

โฎ๏ธ Reverting an Action

To revert an action, call the down method on the persisted in DB Rdux::Action instance.
The Rdux::Action must have a down_payload defined and the action (action performer) must have the down method implemented.

Revert action

The down_at attribute is set upon successful reversion. Actions cannot be reverted if there are newer, unreverted actions in the same stream (if defined) or in general. See meta in ๐Ÿš› Dispatching an action section.

๐Ÿ—ฟ 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",
#   up_payload: {"task"=>{"name"=>"Foo bar baz"}, "user_id"=>159163583},
#   down_payload: {"task_id"=>207620945},
#   down_at: nil,
#   up_payload_sanitized: false,
#   up_result: nil,
#   meta: {},
#   stream_hash: nil,
#   rdux_action_id: nil,
#   rdux_failed_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>>

res.action.down

๐Ÿ˜ท Sanitization

When Rdux.perform is called, the up_payload is sanitized using Rails.application.config.filter_parameters before being saved to the database.
The action performerโ€™s up or call method receives the unsanitized version.
Note that once the up_payload is sanitized, the Rdux::Action cannot be retried by calling the #up method.

๐Ÿ—ฃ๏ธ 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.
Both Rdux::Action and Rdux::FailedAction are standard ActiveRecord models.
You can inherit from them and extend.

Example:

class Action < Rdux::Action
  include Actionable
end

๐Ÿš‘ Recovering from Exceptions

Rdux creates a Rdux::FailedAction when an exception is raised during the execution of an action.
The up_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[:up_result] to store all necessary data incrementally.
The assigned data will then be available as the up_result argument in the Rdux::FailedAction.

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[:up_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)
        return res if res.ok

        Rdux::Result[ok: false, val: { errors: res.val[:errors] }, save: true, nested: [res]]
      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

About

A Minimal Event Sourcing Plugin for Rails

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Languages