{"id":19429414,"url":"https://github.com/betterment/steady_state","last_synced_at":"2025-09-23T18:35:00.857Z","repository":{"id":34860540,"uuid":"154216295","full_name":"Betterment/steady_state","owner":"Betterment","description":"Simple state management via \"an enum with guard rails\"","archived":false,"fork":false,"pushed_at":"2025-09-22T15:02:10.000Z","size":48,"stargazers_count":13,"open_issues_count":5,"forks_count":6,"subscribers_count":24,"default_branch":"main","last_synced_at":"2025-09-22T17:19:48.963Z","etag":null,"topics":["aasm","rails","ruby","state-machine"],"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/Betterment.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE.txt","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":"2018-10-22T21:01:13.000Z","updated_at":"2025-09-22T15:02:13.000Z","dependencies_parsed_at":"2024-06-17T16:22:46.410Z","dependency_job_id":"16b78f9c-6334-4759-8080-3b8063e8e9d0","html_url":"https://github.com/Betterment/steady_state","commit_stats":null,"previous_names":[],"tags_count":5,"template":false,"template_full_name":null,"purl":"pkg:github/Betterment/steady_state","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Betterment%2Fsteady_state","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Betterment%2Fsteady_state/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Betterment%2Fsteady_state/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Betterment%2Fsteady_state/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/Betterment","download_url":"https://codeload.github.com/Betterment/steady_state/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/Betterment%2Fsteady_state/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":276629891,"owners_count":25676631,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","status":"online","status_checked_at":"2025-09-23T02:00:09.130Z","response_time":73,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"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":["aasm","rails","ruby","state-machine"],"created_at":"2024-11-10T14:19:26.279Z","updated_at":"2025-09-23T18:35:00.816Z","avatar_url":"https://github.com/Betterment.png","language":"Ruby","readme":"# SteadyState\n\n\u003e A minimalist approach to managing object state, perhaps best described as an \"enum with guard rails.\" Designed to work with `ActiveRecord` and `ActiveModel` classes, or anywhere where Rails validations are used.\n\n## Overview\n\nSteadyState takes idea of a [Finite State Machine](https://en.wikipedia.org/wiki/Finite-state_machine) and cuts out everything but the most basic declaration of states and transitions (i.e. a directed graph). It then uses ActiveModel validations to enforce these transition rules, and plays nicely with other `validates`/`validate` declarations on your model.\n\nAll of the features one might expect of a Finite State Machine—take, for example, named events, conditional rules, transition hooks, and event callbacks—can then be implemented using existing methods like `valid?`, `errors`, `after_save`, and so on. This approach is effective in contexts that already rely on these methods for control flow (e.g. a Rails controller).\n\nBoth `ActiveRecord` and `ActiveModel` classes are supported, as well as any class adhering to the `ActiveModel::Validations` APIs.\n\n## Installation\n\nAdd this to your Gemfile:\n\n```\ngem 'steady_state'\n```\n\n## Getting Started\n\nTo enable stateful behavior on an attribute or column, call `steady_state` with the name of the attribute, and define the states as strings, like so:\n\n```ruby\nclass Material \u003c ApplicationRecord\n  include SteadyState\n\n  steady_state :state do\n    state 'solid', default: true\n    state 'liquid', from: 'solid'\n    state 'gas', from: 'liquid'\n    state 'plasma', from: 'gas'\n  end\nend\n```\n\nThe `:from` option specifies the state transition rules, i.e. which state(s) a given state is allowed to transition from. It may accept either a single state, or a list of states:\n\n```ruby\nstate 'cancelled', from: %w(step-1 step-2)\n```\n\nThe `:default` option defines the state that your object will start in if no other state is provided:\n\n```ruby\nmaterial = Material.new\nmaterial.state # =\u003e 'solid'\n```\n\nYou may always instantiate a new object in any state, regardless of the default:\n\n```ruby\nmaterial = Material.new(state: 'liquid')\nmaterial.state # =\u003e 'liquid'\n```\n\nA class may have any number of these `steady_state` declarations, one per stateful attribute.\n\n### Moving Between States\n\nAfter your object has been instantiated (or loaded from a database via your ORM), the transitional validations and rules begin to take effect. To change the state, simply use the attribute's setter (e.g. `state=`), and then call `valid?` to see if the change will be accepted:\n\n```ruby\nmaterial.with_lock do # if this is an ActiveRecord, a lock is necessary to avoid race conditions\n  material.state.solid? # =\u003e true\n  material.state = 'liquid'\n  material.state # =\u003e 'liquid'\n  material.valid? # =\u003e true\n\n  # If the change is not valid, a validation error will be added to the object:\n  material.state.liquid? # =\u003e true\n  material.state = 'solid'\n  material.state # =\u003e 'solid'\n  material.valid? # =\u003e false\n  material.errors[:state] # =\u003e ['is invalid']\nend\n```\n\n#### A Deliberate Design Choice\n\nNotice that even when the rules are violated, the state attribute does not revert to the previous state, nor is an exception raised inside of the setter. This is a deliberate design decision.\n\nCompare this behavior to, say, a numericality validation:\n\n```ruby\nvalidates :amount, numericality: { greater_than: 0 }\n\nmodel = MyModel.new(amount: -100)\nmodel.amount # =\u003e -100\nmodel.valid? # false\nmodel.errors[:amount] # =\u003e ['must be greater than 0']\n```\n\nIn keeping with the general pattern of `ActiveModel::Validations`, we rely on an object's _current state in memory_ to determine whether or not it is valid. For both the `state` and `amount` fields, the attribute is allowed to hold an invalid value, resulting in a validation error on the object.\n\n### Saving Changes to State\n\nCommonly, state transition events are expected to have names, like \"melt\" and \"evaporate,\" and other such _action verbs_.\nSteadyState has no such expectation, and will not define any named events for you.\n\nIf you need them, we encourage you to define these transitions using plain ol' Ruby methods, like so:\n\n```ruby\ndef melt\n  with_lock { update(state: 'liquid') }\nend\n\ndef melt!\n  with_lock { update!(state: 'liquid') }\nend\n```\n\nThe use of `with_lock` is *strongly encouraged* in order to prevent race conditions that might result in invalid state transitions.\n\nThis is especially important for operations with side-effects, as a transactional lock will both prevent race conditions and guarantee an atomic rollback\nif anything raises an exception:\n\n```ruby\ndef melt\n  with_lock do\n    if update(state: 'liquid', melted_at: Time.zone.now)\n      owner.update!(melt_count: owner.lock!.melt_count + 1)\n      Delayed::Job.enqueue MeltNotificationJob.new(self)\n      true\n    else\n      false\n    end\n  end\nend\n```\n\nHere is an example Rails controller making use of this new `melt` method:\n\n\n```ruby\nclass MaterialsController \u003c ApplicationController\n  def melt\n    @material = Material.find(params[:id])\n    if @material.melt\n      redirect_to material_path(@material)\n    else\n      render :edit\n    end\n  end\nend\n```\n\nWith the ability to define your states, apply transitional validations, and persist state changes, you should have everything you need to start using SteadyState inside of your application!\n\n## Addional Features \u0026 Configuration\n\n### Predicates\n\nPredicate methods (or \"Huh methods\") are automatically defined for each state:\n\n```ruby\nmaterial = Material.new\nmaterial.solid? # =\u003e true\nmaterial.liquid? # =\u003e false\n```\n\nYou can disable these if, for instance, they conflict with other methods:\n\n```ruby\nsteady_state :status, predicates: false do\n  # ...\nend\n```\n\nEither way, predicate methods are always available on the value itself:\n\n```ruby\nmaterial.status.solid? # =\u003e true\nmaterial.status.liquid? # =\u003e false\n```\n\n### Custom Validations\n\nUsing the supplied predicate methods, you can define your own validations that take effect only when the object enters a specific state:\n\n```ruby\nvalidates :container, absence: true, if: :solid?\nvalidates :container, presence: true, if: :liquid?\n```\n\nWith such a validation in place, a state change will not be valid unless the related validation rules are resolved at the same time:\n\n```ruby\nobject.update!(state: 'liquid') # !! ActiveRecord::RecordInvalid\nobject.update!(state: 'liquid', container: Cup.new) # 🎉\n```\n\nWith these tools, you can define rich sets of state-aware rules about your object, and then rely simply on built-in methods like `valid?` and `errors` to determine if an operation violates these rules.\n\n### Scopes\n\nOn ActiveRecord objects, scopes are automatically defined for each state:\n\n```ruby\nMaterial.solid # =\u003e query for 'solid' records\nMaterial.liquid # =\u003e query for 'liquid' records\n```\n\nThese can be disabled as well:\n\n```ruby\nsteady_state :step, scopes: false do\n  # ...\nend\n```\n\n`steady_state` also follows the same `prefix` api as `delegate` in Rails.  You may optionally define your scopes to be prefixed to the name of the state machine with `prefix: true`, or you may provide a custom prefix with `prefix: :some_custom_name`.  This may be useful when dealing with multiple state machines on one object.\n\n```ruby\nsteady_state :temperature, scopes: { prefix: true } do\n  state 'cold', default: true\nend\n\nsteady_state :color_temperature, scopes: { prefix: 'color' } do\n  state 'cold', default: true\nend\n\nMaterial.solid # =\u003e query for 'solid' records\nMaterial.temperature_cold # =\u003e query for records with a cold temperature\nMaterial.color_cold # =\u003e query for for records with a cold color temperature\n```\n\n### Next and Previous States\n\nThe `may_become?` method can be used to see if setting the state to a particular value would be allowed (ignoring all other validations):\n\n```ruby\nmaterial.state.may_become?('gas') #=\u003e true\nmaterial.state.may_become?('solid') #=\u003e false\n```\n\nTo get a list of all of the valid state transitions, use the `next_values` method:\n\n```ruby\nmaterial.state.next_values # =\u003e ['gas']\n```\n\nAs it stands, state history is not preserved, but it is still possible to get a list of all possible previous states using the `previous_values` method:\n\n```ruby\nmaterial.state.previous_values # =\u003e ['solid']\n```\n\n### The \"States Getter\"\n\nA pluralized, class-level helper method can be used to access all possible state values:\n\n```ruby\nMaterial.states # =\u003e ['solid', 'liquid', 'gas']\n```\n\nThese values respond to reflection methods like `may_become?`, `next_values`, and `previous_values`.\n\n```\nMaterial.states.first.solid? # =\u003e true\nMaterial.states[1].may_become?('solid') # =\u003e false\nMaterial.states[1].next_values # =\u003e ['gas']\n```\n\nThe automatic definition of this class method can be disabled by passing `states_getter: false`:\n\n```ruby\nsteady_state :step, states_getter: false do\n  # ...\nend\n\nMyClass.steps # =\u003e NoMethodError\n```\n\n\n### ActiveModel Support\n\nSteadyState is also available to classes that are not database-backed, as long as they include the `ActiveModel::Model` mixin:\n\n```ruby\nclass Material\n  include ActiveModel::Model\n\n  attr_accessor :state\n\n  steady_state :state do\n    state 'solid', default: true\n    state 'liquid', from: 'solid'\n  end\n\n  def melt\n    self.state = 'liquid'\n    valid? # will return `false` if state transition is invalid\n  end\n\n  def melt!\n    self.state = 'liquid'\n    validate! # will raise an exception if state transition is invalid\n  end\nend\n```\n\n## How to Contribute\n\nWe would love for you to contribute! Anything that benefits the majority of `steady_state` users—from a documentation fix to an entirely new feature—is encouraged.\n\nBefore diving in, check our issue tracker and consider creating a new issue to get early feedback on your proposed change.\n\n#### Suggested Workflow\n\n* Fork the project and create a new branch for your contribution.\n* Write your contribution (and any applicable test coverage).\n* Make sure all tests pass (bundle exec rake).\n* Submit a pull request.\n","funding_links":[],"categories":[],"sub_categories":[],"project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fbetterment%2Fsteady_state","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fbetterment%2Fsteady_state","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fbetterment%2Fsteady_state/lists"}