I’ve been working on flow_state
, a small gem for modelling, orchestrating and tracing multi‑step workflows natively with Rails.
Back story
Over the years I’ve built countless iterations on pipelines that:
- Fetch data from third-party APIs
- Blend and transform it (lately with generative AI)
- Persist the result in my own models
They always ended up as tangled callbacks or chains of jobs calling jobs, with retry logic scattered everywhere. When something failed, it was hard to trace what happened and where.
I wanted:
- A Rails-native orchestration layer on top of SolidQueue/ActiveJob
- No external dependencies like Temporal or Restate
- A clear audit trail of each state transition, whether it succeeded or failed
- A conventional and Rails-y way to declare states, transitions, guards and persisted artefacts
The core idea
With flow_state you:
-
Define props
storing metadata required to initialise a workflow.prop :third_party_id, String
-
Declare states
state :pending state :syncing_api state :failed_api, error: true initial_state :pending
-
Define every possible transition
transition!
specifies which states can be transitioned from and to, and validates every move. It prevents race conditions by not allowing transitions between incompatible states. -
Define schemas for persisted artefacts
Artefacts are stored during one transition, for use in another.persist :third_party_response, Hash
-
Apply guards
Block a transition unless a condition holds. -
Write your steps as explicit methods
No magic methods or heavy meta-programming.def start_api_fetch! transition!(from: :pending, to: :started_api_fetch) end def finish_api_fetch!(result) transition!(from: :started_api_fetch, to: :finished_api_fetch) { result } end def fail_api_fetch! transition!(from: :started_api_fetch, to: :failed_api_fetch) end
Example: synchronise song data
class SongSyncFlow < FlowState::Base
prop :song_id, String
state :pending
state :picked
state :syncing_api
state :synced_api
state :failed_api, error: true
state :syncing_model
state :synced_model
state :failed_model, error: true
state :completed
initial_state :pending
persist :api_response
def pick!
transition!(
from: %i[pending completed failed_api failed_model],
to: :picked,
after_transition: -> { enqueue_api_job }
)
end
def finish_api!(response)
transition!(
from: :syncing_api,
to: :synced_api,
persists: :api_response,
after_transition: -> { enqueue_model_job }
) { response }
end
def fail_api!
transition!(from: :syncing_api, to: :failed_api)
end
# …and so on…
end
- Your jobs simply call
pick!
,finish_api!
, etc. - Your transitions cause errors if your app attempts to transition from incompatible states, as modelled by you.
- Each call is locked, logged and validated.
- Guards stop you moving on until data is correct.
- Artefacts can be stored between transitions, for later reference.
Getting started
- Add to your Gemfile
bundle add 'flow_state'
- Run the installer and migrate
rails generate flow_state:install rails db:migrate
- Create your flows in
app/flows
, ie.SongSyncFlow
inapp/flows/song_sync_flow.rb
I’m using it across two projects at the moment, and it’s made reasoning about complex stepped workflows considerably easier. It’s helped me design workflows that are resilient by default.