{"id":47714715,"url":"https://github.com/artofcodelabs/rdux","last_synced_at":"2026-04-02T18:50:42.093Z","repository":{"id":258397322,"uuid":"291398519","full_name":"artofcodelabs/rdux","owner":"artofcodelabs","description":"A Minimal Event Sourcing Plugin for Rails","archived":false,"fork":false,"pushed_at":"2026-03-26T05:20:56.000Z","size":1349,"stargazers_count":2,"open_issues_count":1,"forks_count":0,"subscribers_count":0,"default_branch":"master","last_synced_at":"2026-03-27T01:34:39.549Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":"","language":"Ruby","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/artofcodelabs.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"MIT-LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2020-08-30T04:18:47.000Z","updated_at":"2026-01-18T06:13:22.000Z","dependencies_parsed_at":"2026-03-02T07:10:30.645Z","dependency_job_id":null,"html_url":"https://github.com/artofcodelabs/rdux","commit_stats":null,"previous_names":["artofcodelabs/rdux"],"tags_count":23,"template":false,"template_full_name":null,"purl":"pkg:github/artofcodelabs/rdux","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/artofcodelabs%2Frdux","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/artofcodelabs%2Frdux/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/artofcodelabs%2Frdux/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/artofcodelabs%2Frdux/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/artofcodelabs","download_url":"https://codeload.github.com/artofcodelabs/rdux/tar.gz/refs/heads/master","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/artofcodelabs%2Frdux/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":31313445,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-04-02T12:59:32.332Z","status":"ssl_error","status_checked_at":"2026-04-02T12:54:48.875Z","response_time":89,"last_error":"SSL_connect returned=1 errno=0 peeraddr=140.82.121.5:443 state=error: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-04-02T18:50:41.332Z","updated_at":"2026-04-02T18:50:42.084Z","avatar_url":"https://github.com/artofcodelabs.png","language":"Ruby","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Rdux - A Minimal Event Sourcing Plugin for Rails\n\n\u003cdiv align=\"center\"\u003e\n\n  \u003cdiv\u003e\n    \u003cimg width=\"500px\" src=\"docs/logo.webp\"\u003e\n  \u003c/div\u003e\n\n![GitHub](https://img.shields.io/github/license/artofcodelabs/rdux)\n![GitHub tag (latest SemVer)](https://img.shields.io/github/v/tag/artofcodelabs/rdux)\n\n\u003c/div\u003e\n\nRdux 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.\n\n**Key Features**\n\n* **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.\n* **Model Representation** 👉 Before action is executed it gets stored in the database through the `Rdux::Action` model. This model can be nested, allowing for complex action structures.\n* **Exception Handling and Recovery** 👉 Rdux automatically creates a `Rdux::Action` record when an exception occurs during action execution. It retains the `payload` and allows you to capture additional data using `opts[:action].result`, ensuring all necessary information is available for retrying the action.\n* **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.\n\nRdux is designed to integrate seamlessly with your existing Rails application, offering a straightforward and powerful solution for managing and auditing key actions.\n\n## 📲 Instalation\n\nAdd this line to your application's Gemfile:\n\n```ruby\ngem 'rdux'\n```\n\nAnd then execute:\n\n```bash\n$ bundle\n```\n\nOr install it yourself as:\n\n```bash\n$ gem install rdux\n```\n\nThen install and run migrations:\n\n```bash\n$ bin/rails rdux:install:migrations\n$ bin/rails db:migrate\n```\n\n⚠️ Note: Rdux requires Rails 7.1+. It uses `jsonb` columns on PostgreSQL and `json` on other adapters.\n\n## 🎮 Usage\n\n### 🚛 Dispatching an action\n\nTo dispatch an action using Rdux, use the `dispatch` method (aliased as `perform`).\n\nDefinition:\n\n```ruby\ndef dispatch(action, payload, opts: {}, meta: nil)\n\nalias perform dispatch\n```\n\nArguments:\n\n* `action`: The name of the module or class (action performer) that processes the action. `action` is stored in the database as the `name` attribute of the `Rdux::Action` instance (e.g., `Task::Create`).\n* `payload` (Hash): The input data passed as the first argument to the `call` 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.\n* `opts` (Hash): Optional parameters passed as the second argument to the `call` 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.\n* `meta` (Hash): Additional metadata stored in the database alongside the `action` and `payload`.\n\nExample:\n\n```ruby\nRdux.perform(\n  Task::Create,\n  { task: { name: 'Foo bar baz' } },\n  opts: { ars: { user: current_user } },\n  meta: { bar: 'baz' }\n)\n```\n\n### 📈 Flow diagram\n\n![Flow Diagram](docs/flow.png)\n\n### 🕵️‍♀️ Processing an action\n\nAction in Rdux is processed by an action performer which is a Plain Old Ruby Object (PORO) that implements the `self.call` method.\nThis method accepts a required `payload` and an optional `opts` argument.\n`opts[:action]` stores the Active Record object.\n`call` method processes the action and must return a `Rdux::Result` struct.\n\nSee [🚛 Dispatching an action](#-dispatching-an-action) section.\n\nExample:\n\n```ruby\n# app/actions/task/create.rb\n\nclass Task\n  module Create\n    def self.call(payload, opts)\n      user = opts.dig(:ars, :user) || User.find(payload['user_id'])\n      task = user.tasks.new(payload['task'])\n      if task.save\n        Rdux::Result[ok: true, val: { task: }]\n      else\n        Rdux::Result[false, { errors: task.errors }]\n      end\n    end\n  end\nend\n```\n\n#### Suggested Directory Structure\n\nThe location that is often used for entities like actions accross code bases is `app/services`.\nThis directory is de facto the bag of random objects.\nI'd recomment to place actions inside `app/actions` for better organization and consistency.\nActions are consistent in terms of structure, input and output data.\nThey are good canditates to create a new layer in Rails apps.\n\nStructure:\n\n```\n.\n└── app/actions/\n    ├── activity/\n    │   ├── common/\n    │   │   └── fetch.rb\n    │   ├── create.rb\n    │   ├── stop.rb\n    │   └── switch.rb\n    ├── task/\n    │   ├── create.rb\n    │   └── delete.rb\n    └── misc/\n        └── create_attachment.rb\n```\n\nThe [dedicated page about actions](docs/ACTIONS.md) contains more arguments in favor of actions.\n\n### ⛩️ Returned `struct` `Rdux::Result`\n\nDefinition:\n\n```ruby\nmodule Rdux\n  Result = Struct.new(:ok, :val, :result, :save, :nested, :action) do\n    def save_failed?\n      ok == false \u0026\u0026 save ? true : false\n    end\n  end\nend\n```\n\nArguments:\n\n* `ok` (Boolean): Indicates whether the action was successful. If `true`, the `Rdux::Action` is persisted in the database.\n* `val` (Hash): returned data.\n* `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 as `Rdux::Action#result`.\n* `save` (Boolean): If `true` and `ok` is `false`, the action is still persisted in the database.\n* `nested` (Array of `Rdux::Result`): `Rdux::Action` can be connected with other `rdux_actions`. To establish an association, a given action must `Rdux.dispatch` other actions in the `call` method and add the returned by the `dispatch` value (`Rdux::Result`) to the `:nested` array\n* `action`: Rdux assigns persisted `Rdux::Action` to this argument\n\n### 🗿 Data model\n\n```ruby\npayload = {\n  task: { 'name' =\u003e 'Foo bar baz' },\n  user_id: 159163583\n}\n\nres = Rdux.dispatch(Task::Create, payload)\n\nres.action\n# #\u003cRdux::Action:0x000000011c4d8e98\n#   id: 1,\n#   name: \"Task::Create\",\n#   payload: {\"task\"=\u003e{\"name\"=\u003e\"Foo bar baz\"}, \"user_id\"=\u003e159163583},\n#   payload_sanitized: false,\n#   result: nil,\n#   meta: {},\n#   rdux_action_id: nil,\n#   created_at: Fri, 28 Jun 2024 21:35:36.838898000 UTC +00:00,\n#   updated_at: Fri, 28 Jun 2024 21:35:36.839728000 UTC +00:00\u003e\u003e\n```\n\n### 😷 Sanitization\n\nWhen `Rdux.perform` is called, the `payload` is sanitized using `Rails.application.config.filter_parameters` before being saved to the database.\nThe action performer’s `call` method receives the unsanitized version.\n\n### 🗣️ Queries\n\nMost likely, it won't be necessary to save a `Rdux::Action` for every request a Rails app receives.\nThe suggested approach is to save `Rdux::Action`s for Create, Update, and Delete (CUD) operations.\nThis approach organically creates a new layer - queries in addition to actions.\nThus, it is required to call `Rdux.perform` only for actions.\n\nOne approach is to create a `perform` method that invokes either `Rdux.perform` or a query, depending on the presence of `action` or `query` keywords.\nThis method can also handle setting `meta` attributes, performing parameter validation, and more.\n\nExample:\n\n```ruby\nclass TasksController \u003c ApiController\n  def show\n    perform(\n      query: Task::Show,\n      payload: { id: params[:id] }\n    )\n  end\n\n  def create\n    perform(\n      action: Task::Create,\n      payload: create_task_params\n    )\n  end\nend\n```\n\n### 🕵️ Indexing\n\nDepending on your use case, it’s recommended to create indices, especially when using PostgreSQL and querying JSONB columns.\\\n`Rdux::Action` is a standard ActiveRecord model.\nYou can inherit from it and extend.\n\nExample:\n\n```ruby\nclass Action \u003c Rdux::Action\n  include Actionable\nend\n```\n\n### 🚑 Recovering from Exceptions\n\nRdux captures exceptions raised during the execution of an action and sets the `Rdux::Action#ok` attribute to `false`.\nThe `payload` is retained, but having only the input data is often not enough to retry an action.\nIt is crucial to capture data obtained during the action’s execution, up until the exception occurred.\nThis can be done by using `opts[:action].result` attribute to store all necessary data incrementally.\n\nExample:\n\n```ruby\nclass CreditCard\n  class Charge\n    class \u003c\u003c self\n      def call(payload, opts)\n        create_res = create(payload.slice('user_id', 'credit_card'), opts.slice(:user))\n        return create_res unless create_res.ok\n\n        opts[:action].result = { credit_card_create_action_id: create_res.action.id }\n        charge_id = PaymentGateway.charge(create_res.val[:credit_card].token, payload['amount'])[:id]\n        if charge_id.nil?\n          Rdux::Result[ok: false, val: { errors: { base: 'Invalid credit card' } }, save: true,\n                       nested: [create_res]]\n        else\n          Rdux::Result[ok: true, val: { charge_id: }, nested: [create_res]]\n        end\n      end\n\n      private\n\n      def create(payload, opts)\n        res = Rdux.perform(Create, payload, opts:)\n        res.ok ? res : Rdux::Result[ok: false, val: { errors: res.val[:errors] }, save: true]\n      end\n    end\n  end\nend\n```\n\n### 🧹 Development Mode\n\nSet 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.\n\n```bash\nRDUX_DEV=1 bin/rails server\n```\n\nThis keeps your development database clean from failed action records caused by exceptions during iterative development.\n\n## 🧩 Process\n\n**Process** 👉 a series of actions or steps taken in order to achieve a particular end.\n\n`Rdux::Process` is a persisted model that groups multiple `Rdux::Action`s.\nIt also stores an ordered list of `steps` (`jsonb`/`json`).\n\nWhen a process starts:\n\n* Steps run **sequentially** in the order defined in `STEPS`\n* Process execution continues only when the latest process action returns `ok: true`\n* Execution stops on the first failed action step (`ok == false`)\n* `process.ok` is persisted from the latest non-`nil` step result\n\nKey points:\n\n* `Rdux::Process` **has many** `Rdux::Action`s (`process.actions`)\n* `Rdux::Action` **belongs to** a process (`action.process`)\n* `Rdux.start(ProcessModuleOrClass, payload)` starts a process performer (a PORO namespace/class with a `STEPS` constant)\n* `STEPS` must be an `Array` (validated on `Rdux::Process`)\n* `steps` is stored as `jsonb` on PostgreSQL and `json` on other adapters (default: `[]`)\n* `STEPS` supports:\n  * a step definition hash (`{ name: User::Create, payload: -\u003e(payload, prev_res) { ... } }`)\n  * a callable step (`-\u003e(payload, process) { ... }`)\n* For hash steps, Rdux dispatches `Rdux.perform(step_name, step_payload, process: process)` (`step_payload` is the full process payload unless `payload:` proc is provided)\n* For callable steps, Rdux calls the step with `(safe_payload, process)` and the step is responsible for dispatching an action (with `Rdux.perform(..., process:)`)\n  * ⚠️ If a step returns `ok: false`, that step action is persisted (and can be assigned to the process) **only** when it also returns `save: true`. This is required.\n* Inside an action performer, use `opts[:action]` to access the current persisted action, then traverse `opts[:action].process.actions` (and their `result`)\n* Actions dispatched *inside* an action performer (via `Rdux.perform`) are linked via `rdux_action_id` (`action.rdux_actions`) and are not automatically assigned to the process\n\nExample:\n\n```ruby\nmodule Processes\n  module Subscription\n    module Create\n      STEPS = [\n        lambda { |payload, process|\n          payload = payload.slice('plan_id', 'user', 'total_cents')\n          Rdux.perform(::Subscription::Preview, payload, process:)\n        },\n        lambda { |payload, process|\n          payload = payload.slice('user')\n          Rdux.perform(User::Create, payload, process:)\n        },\n        { name: CreditCard::Create,\n          payload: lambda { |payload, prev_res|\n            payload.slice('credit_card').merge(user_id: prev_res.action.result['user_id'])\n          } },\n        { name: Payment::Create,\n          payload: -\u003e(_, prev_res) { { token: prev_res.val[:credit_card].token } } },\n        { name: ::Subscription::Create,\n          payload: -\u003e(payload, prev_res) { payload.slice('plan_id').merge(ext_charge_id: prev_res.val[:charge_id]) } }\n      ].freeze\n    end\n  end\nend\n\nres = Rdux.start(Processes::Subscription::Create, payload)\nprocess = res.val[:process]\n\n# from any action performer:\ndef self.call(payload, opts)\n  results = opts[:action].process.actions.order(:id).pluck(:result)\n  # ...\nend\n```\n\n## 🛠️ Helpers\n\n### `ActionResult`\n\n`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.\n\nIt sets `action.result` with:\n\n* `relations` — a map of `\"model_name#id\" =\u003e id` (or raw hashes) for each resource that was modified or created\n* `db_changes` — `saved_changes` for each resource that was modified or created\n* any extra key/value pairs passed as keyword arguments\n\nIt also creates an `ActionResource` record for each AR resource, linking it to the action via a polymorphic association.\n\n**Usage:**\n\n```ruby\n# inside an action performer\nopts[:action].result = ActionResult.call(\n  action: opts[:action],\n  resources: [task]\n)\n\n# action.result stored in DB:\n# {\n#   \"relations\"  =\u003e { \"task#1\" =\u003e 1 },\n#   \"db_changes\" =\u003e {\n#     \"task#1\" =\u003e {\n#       \"id\"         =\u003e [nil, 1],\n#       \"name\"       =\u003e [nil, \"Foo bar baz\"],\n#       \"user_id\"    =\u003e [nil, 42],\n#       \"created_at\" =\u003e [nil, \"2024-06-28 21:35:36\"],\n#       \"updated_at\" =\u003e [nil, \"2024-06-28 21:35:36\"]\n#     }\n#   }\n# }\n```\n\nResources can be ActiveRecord objects or plain hashes (merged directly into `relations`):\n\n```ruby\nActionResult.call(\n  action: opts[:action],\n  resources: [task, { user_id: user.id }],\n  additional_info: 'Foo Bar Baz'\n)\n```\n\n**`ActionResource` model** (`app/models/action_resource.rb`):\n\n```ruby\nclass ActionResource \u003c ApplicationRecord\n  belongs_to :action, class_name: 'Rdux::Action'\n  belongs_to :resource, polymorphic: true\n\n  validates :action_id, uniqueness: { scope: %i[resource_type resource_id] }\n  validates :resource_type, presence: true\nend\n```\n\n**`ActionResult` service** (`app/services/action_result.rb`):\n\n```ruby\nclass ActionResult\n  class \u003c\u003c self\n    def call(action:, resources:, **custom)\n      result = { relations: {}, db_changes: {} }\n\n      resources.each do |resource|\n        if resource.is_a?(Hash)\n          result[:relations].merge!(resource)\n          next\n        end\n\n        key = relation_key(resource)\n        result[:relations][key] = resource.id\n        result[:db_changes][key] = resource.saved_changes if resource.saved_changes.present?\n      end\n\n      persist_relations(result[:relations], action.id)\n      result.merge(custom)\n    end\n\n    private\n\n    def relation_key(resource)\n      \"#{resource.class.name.underscore}##{resource.id}\"\n    end\n\n    def resource_type_for(name)\n      type = name.sub(/_id$/, '').sub(/#\\d+$/, '').camelize\n      resource_class = type.safe_constantize\n      resource_class \u0026\u0026 resource_class \u003c ApplicationRecord ? type : nil\n    end\n\n    def persist_relations(relations, action_id)\n      relations.each do |name, id|\n        resource_type = resource_type_for(name)\n        next if resource_type.nil? || !id.to_s.match?(/\\A\\d+\\z/)\n\n        ActionResource.create!(action_id:, resource_type:, resource_id: id)\n      end\n    end\n  end\nend\n```\n\n## 👩🏽‍🔬 Testing\n\n### 💉 Setup\n\n```bash\n$ cd test/dummy\n$ DB=all bin/rails db:create\n$ DB=all bin/rails db:prepare\n$ cd ../..\n```\n\n### 🧪 Run tests\n\n```bash\n$ DB=postgres bin/rails test\n$ DB=sqlite bin/rails test\n```\n\n## 📄 License\n\nThe gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).\n\n## 👨‍🏭 Author\n\nZbigniew Humeniuk from [Art of Code](https://artofcode.co)\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fartofcodelabs%2Frdux","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fartofcodelabs%2Frdux","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fartofcodelabs%2Frdux/lists"}