About spree-workflows
spree-workflows is a developer skill for Spree’s service and workflow architecture, including extension hooks and result handling. It helps teams place custom business logic at suitable points without patching core operations.
Key features
- 01Service and workflow responsibilities
- 02Validation, context, and lifecycle hooks
- 03Hook registration and rejection handling
- 04Workflow extension point catalog
- 05Guidance for events and operation replacement
Use cases
- 01Block checkout based on eligibility rules
- 02Add context to tax or promotion calculations
- 03Write related records during a lifecycle hook
- 04Choose between hooks, events, and workflows
Install
Copy the command and run it in a terminal.
npx skills add https:/ / github.com/ spree/ agent-skills --skill spree-workflowsAfter installing, type/spree-workflowsin Claude Code to confirm it loaded.
Bundled filesreferences/hooks.md
Details
From the project READMESpree Workflows & Hooks
Commands use the Spree CLI (
spree …). On a classic Rails app without the CLI, usebin/rails …/bundle exec rake …from the app root. Paths below assume the Rails app lives inserver/.
Spree's business logic lives in two kinds of plain Ruby classes, called the same way and returning the same Spree::ServiceModule::Result:
| Tier | Lives in | Use for |
|---|---|---|
| Service | app/services/ | Plain create/update/delete, one sequential call body. Most of Spree. |
Workflow (Spree::Workflow) | app/workflows/ | Flows that need extension points, external calls (gateways, carriers), or compensation (undo when a later step fails): cart add/complete, order cancel, payment capture, fulfillment, returns, product status changes, imports… |
result = Spree.cart_add_item_workflow.call(cart: cart, variant: variant, quantity: 2)
result.success? # never raise on expected failures — check this
result.value # the line item
result.error.to_s # message on failure; result.error.value is ActiveModel::Errors for rejections
There are no state machines in Spree. Records carry a plain status string (Spree::HasStatus), and every transition is a workflow with its own hooks.
Where custom logic goes — decide first
| You want to… | Use |
|---|---|
| Stop an operation (purchase limit, B2B eligibility, fraud hold, return policy) | validate hook |
| Contribute data to a calculation (tax exemption, promotion eligibility, carrier data) | Context hook (set_*_context, get_provider_data, extend_payload) |
| Write related rows atomically with the change | Lifecycle hook (after_*, some run inside the transaction) |
| Send email, call a webhook, update a search index, sync an ERP | Event subscriber — spree-events-webhooks |
| Add a checkout requirement (the customer must provide X) | Spree::Checkout::Registry.add_requirement — spree-checkout |
| Replace a whole operation | Spree.dependencies — spree-dependencies (last resort; you own the copy) |
| Add a brand-new multi-step operation | Your own Spree::Workflow (below) |
Reach for the smallest one. A hook survives upgrades; a replaced class has to be re-synced every release.
Don't put business behavior on models (new public model methods that write, after_save side effects, transition callbacks) and don't decorate or patch finalize!, Order#cancel, Payment#capture! and friends — those are deprecated shells delegating to workflows (Order#finalize! → Spree.order_complete_workflow), and the real flow (Spree::Carts::Complete, Spree::Orders::Cancel, Spree::Payments::Capture) never calls your override. Use a hook on the workflow instead.
Registering a hook
In config/initializers/spree.rb:
Spree.hooks.register('carts.add_item.validate', 'MyStore::CheckPurchaseLimit')
Spree.hooks.register('carts.upsert_items.validate', 'MyStore::CheckPurchaseLimit')
# A block works for one-liners (but can't be deduplicated or unregistered by name)
Spree.hooks.register('orders.cancel.after_cancel') do |workflow|
MyStore::Analytics.track(:order_canceled, workflow.order.number)
end
- Key is
'<workflow key>.<hook>'. The workflow key derives from the class name:Spree::Carts::AddItem→carts.add_item. - Handlers are stored as class-name strings, constantized when the hook fires — boot-safe and reload-safe. Registering the same class twice is a no-op.
- Timing:
Spree.hooksis a process-wide registry core never resets, so top-level registration in an initializer is correct (unlike registries such aspayment_methods, which core reassigns and must be appended inafter_initialize). Don't register inconfig.to_prepare— it re-runs on every dev reload and duplicates block handlers. The one case needingRails.application.config.after_initializeis unregistering a handler core registers (below). - The handler is any class with
#call(workflow). It receives the workflow instance: every#performkeyword is a public reader (workflow.cart,workflow.variant,workflow.quantity), plus whatever the workflow exposes viaattr_reader(workflow.line_item,workflow.order,workflow.fulfillment…). Check the workflow source /references/hooks.mdfor which readers are populated at each hook — e.g.line_itemis nil duringcarts.add_item.validate. - Keys are validated at boot when
eager_loadis on (production, usually CI): a typo raisesSpree::Hooks::UnknownHookErrorlisting the valid hooks. In development (lazy loading) a bad key silently never fires — runSpree.hooks.validate!in a console afterRails.application.eager_load!to check. Spree.hooks.unregister(key, 'Handler')removes one handler;unregister(key)removes all for the key.
module MyStore
class CheckPurchaseLimit
def call(workflow)
return if (workflow.quantity || 1).to_i <= 10 # carts.add_item quantity defaults to nil (=1)
workflow.errors.add(:quantity, :purchase_limit_exceeded,
message: 'You can order at most 10 of this item.')
workflow.reject!
end
end
end
workflow.errors is an ActiveModel::Errors. reject! (or reject!('message'), shorthand for an error on :base) aborts the flow, unwinds compensation, rolls back an open transaction and returns a failure result. The API renders it exactly like a model validation failure — 422 validation_error with details keyed by field — so SDK clients need no special handling. Prefer a symbolic type (:purchase_limit_exceeded) plus message: so clients can branch on the code.
carts.add_item adds to a quantity; carts.upsert_items sets it (quantity edits, removals, bulk payloads). A cart rule belongs on both keys — the readers (cart, variant, quantity) match, so one handler class registers on each. On the storefront an upsert_items rejection skips that item and reports it in the cart's warnings instead of failing the batch; admin order edits fail the whole edit.
The three hook kinds
| Kind | Names | Runs | Return value | Many handlers |
|---|---|---|---|---|
| Validate | validate (also orders.cancel.before_cancel) | Before anything is written — rejecting costs nothing | Ignored; veto with reject! | Run in registration order; first reject! stops the flow, later validators never run |
| Context | set_promotion_context, set_tax_line_context, get_provider_data, extend_payload | Before a calculation | Return a Hash — merged (deep) across handlers and handed to the calculation | All run; key collision → last registered wins and is reported via Rails.error |
| Lifecycle | before_* / after_* (after_item_added, before_finalize, after_cancel, before_capture…) | Around/after the work; several run inside the flow's transaction | Ignored | All run in order |
# Context hook: contribute tax-exemption evidence to totals recalculation
module MyStore
class TaxExemption
def call(workflow)
cert = MyStore::ExemptionLookup.for(workflow.cart.customer)
cert ? { exemption_certificate: cert.number } : {}
end
end
end
Spree.hooks.register('carts.recalculate_totals.set_tax_line_context', 'MyStore::TaxExemption')
The merged hash reaches the tax provider as context:; for promotions it's exposed as workflow.promotion_context (carts.recalculate.set_promotion_context).
Multi-handler rules you must design for
- Assume you are never alone on a hook. Your app, extensions and Spree itself register handlers (core registers
Spree::Returns::EligibilityValidatoronreturns.create.validateandexchanges.create.validate). - Don't depend on ordering between handlers. Order = registration order (gem initializer load order). If two pieces must be sequenced, put them in one handler.
- Handlers are not isolated. An exception propagates out of the workflow, skips later handlers, and rolls back the open transaction — a
raiseinafter_item_addedmeans the item was never added. Rescue inside optional work, or move it to an event subscriber. - Reject only from
validate.carts.complete.before_finalizeruns after the card was charged — rejecting there rolls back the order while the charge stands. - No slow work in in-transaction lifecycle hooks (
after_item_added,after_cancel,after_*on status workflows). Network calls hold a DB connection across a round trip; use a subscriber.
Replacing a hook Spree registers itself
Core's return-window validator is registered by an engine initializer that runs before your config/initializers/*, so a plain unregister in an initializer removes it:
# config/initializers/spree.rb
Spree.hooks.unregister('returns.create.validate', 'Spree::Returns::EligibilityValidator')
Spree.hooks.unregister('exchanges.create.validate', 'Spree::Returns::EligibilityValidator')
Spree.hooks.register('returns.create.validate', 'MyStore::ReturnPolicy')
Spree.hooks.register('exchanges.create.validate', 'MyStore::ReturnPolicy')
Most useful hook points
| Key | Kind | When |
|---|---|---|
carts.add_item.validate | validate | Before the line item is built (outside the transaction) |
carts.add_item.after_item_added | lifecycle | After save + recalc, in transaction (workflow.line_item, line_item_created) |
carts.upsert_items.validate / .after_items_upserted | validate / lifecycle | Per item before it's applied / after the batch |
carts.merge.validate / .after_merge | validate / lifecycle | Guest→customer cart merge |
carts.recalculate.set_promotion_context | context | Before promotions are evaluated |
carts.recalculate.after_recalculate | lifecycle | After repricing |
carts.recalculate_totals.set_tax_line_context | context | Before tax is estimated |
carts.complete.validate | validate | After checkout requirements pass, before the order exists — the place to block checkout (credit limit, approval required) |
carts.complete.before_finalize / .after_finalize | lifecycle | After payment, before/after the order is placed (workflow.order, order_group) |
orders.cancel.before_cancel / .after_cancel | validate / lifecycle | Veto a cancel / write alongside it (in transaction) |
payments.capture.validate | validate | Fraud holds — before the gateway is called |
payments.process.validate | validate | Before authorize/purchase hits the gateway |
refunds.create.validate | validate | Before a refund record exists (approval thresholds) |
fulfillments.create.get_provider_data | context | Carrier account / service level / customs data |
fulfillments.fulfill.validate / .after_fulfill | validate / lifecycle | Before/after marking shipped |
returns.create.validate | validate | Return eligibility (replace core's window rule) |
customers.create.validate | validate | Registration policy (bot screening, B2B approval) |
customers.anonymize.validate | validate | Refuse a GDPR erasure (open disputes, legal hold) |
products.activate.validate | validate | Require image/price/category before a product goes live |
products.create/update.validate | validate | Fires for Admin API, CSV imports and seeds alike |
Admin draft-order editing uses twin workflows with their own keys and the same hooks: orders.add_item, orders.upsert_items, orders.recalculate, orders.recalculate_totals. Register on the cart key for storefront carts, the order key for admin edits, or both. Twins forward order: as cart:, so handlers read workflow.cart on either key.
Full catalog (every workflow, hook, dependency key and reader): references/hooks.md.
Inspect at runtime (no rake task — use the console):
Spree.hooks.workflows # { 'carts.add_item' => 'Spree::Carts::AddItem', ... }
Spree.hooks.carts # { 'carts.add_item' => [:validate, :after_item_added], ... }
Spree::Carts::Complete.declared_hooks # [:validate, :before_finalize, :after_finalize]
Spree.hooks.keys # keys with registered handlers
Spree.hooks.handler_count('carts.complete.validate')
Spree.hooks.validate! # true, or raises UnknownHookError
Writing your own workflow
Write one only for a new multi-step operation with external calls, compensation, or extension points. Plain CRUD stays a service (or a model save).
# server/app/workflows/my_store/subscriptions/renew.rb
module MyStore
module Subscriptions
class Renew < Spree::Workflow
hooks :validate, :after_renew # declare every run_hooks name
attr_reader :order # derived state handlers may read
# The signature IS the contract. Bare `super` turns each keyword into a reader.
def perform(subscription:, renewed_at: nil)
super
step :ensure_renewable
run_hooks :validate
ApplicationRecord.transaction do
step :build_order, on_flow_failure: :discard_order
step :extend_period
end
external_step :charge_customer # refuses to run inside a transaction this workflow opened
run_hooks :after_renew
subscription.publish_event('subscription.renewed')
success(order)
end
private
def ensure_renewable
failure(subscription, :not_active) unless subscription.active? # raises — aborts the flow
end
def build_order
@order = MyStore::Subscriptions::BuildOrder.call(subscription: subscription).value
end
def extend_period
subscription.update!(renews_at: (renewed_at || Time.current) + 1.month)
end
def charge_customer
Spree.payment_capture_workflow.call(payment: order.payments.last) # a failure Result fails this step
end
def discard_order # undo — armed when the transaction commits, run in reverse on later failure
order&.destroy
end
end
end
end
| Word | Meaning |
|---|---|
step :name | Run the private method. A returned failure Result fails the flow. |
step :name, with: -> { Spree.some_workflow } | Delegate to a swappable collaborator; its keywords are sliced from this workflow's readers |
on_flow_failure: :undo | Compensation for the step, run in reverse if a later step fails |
external_step :name | Network/gateway I/O; raises Spree::Workflow::ContractError inside a workflow-opened transaction |
run_hooks :name | Dispatch a declared hook; returns the merged Hash (context hooks) |
failure(value, error) | Abort from anywhere (raises internally, so an open transaction rolls back) |
workflow.reject! | The handler-facing twin of failure, carrying errors |
halt!(value) | Successful early exit — not allowed inside a transaction the workflow opened |
hooks :a, :b / workflow_key 'x.y' | Declare extension points / override the derived key |
Rules: network calls never share a DB transaction (external_step enforces it); failure/halt! raise Exception subclasses, so a plain rescue in #perform won't swallow them — and don't rescue Exception. Inside #perform a parameter name is a local variable that shadows its reader; name derived state differently (variant in, line_item out).
Statuses (instead of state machines)
class MyStore::Subscription < Spree.base_class
include Spree::HasStatus
has_status :trialing, :active, :paused, :canceled, default: :trialing
end
# => inclusion validation, #paused?, .paused scope, .with_status(:active, :trialing)
Spree::GiftCard.add_status(:on_hold, after: :active) # extend a core model's statuses
No transition graph, no events, no callbacks — moving a record between statuses is a workflow (MyStore::Subscriptions::Pause.call(...), not subscription.pause!). A custom status needs your own workflow to move records into it; core workflows guard on core statuses, and add_status is additive only. Call Spree's transitions through their workflows too: Spree.product_archive_workflow.call(product: product).
Observability
Every run, step and hook dispatch emits ActiveSupport::Notifications:
| Event | Payload |
|---|---|
perform.spree_workflow | workflow, outcome (success/failure/error) |
step.spree_workflow | workflow, step, external, outcome |
hooks.spree_workflow | workflow, hook, handler_count |
ActiveSupport::Notifications.subscribe('step.spree_workflow') do |*, payload|
Rails.logger.info("#{payload[:workflow]}##{payload[:step]} #{payload[:outcome]}")
end
With the spree_opentelemetry gem (plus OTEL_EXPORTER_OTLP_ENDPOINT), these become spans automatically: one per workflow run, one per step (external_steps as client spans), one per hook dispatch that has handlers.
Testing
Handlers are plain classes — unit-test them with a real workflow instance, and integration-test through the workflow:
RSpec.describe MyStore::CheckPurchaseLimit do
let(:cart) { create(:cart) }
let(:variant) { create(:variant) }
let(:handler) { 'MyStore::CheckPurchaseLimit' }
before { Spree.hooks.register('carts.add_item.validate', handler) }
after { Spree.hooks.unregister('carts.add_item.validate', handler) }
it 'rejects more than 10' do
result = Spree.cart_add_item_workflow.call(cart: cart, variant: variant, quantity: 11)
expect(result).to be_failure
expect(result.error.value.details[:quantity]).to include(a_hash_including(error: :purchase_limit_exceeded))
expect(cart.line_items.count).to eq(0)
end
end
- If the handler is registered in
config/initializers/spree.rb, it is already active in specs — skip thebefore/after. - Never
Spree.hooks.clear!in app specs — it permanently wipes core registrations (the return-window validator) for the rest of the process. Unregister exactly what you registered. For blocks, keep the proc:h = ->(w) { ... }; Spree.hooks.register(key, &h)…Spree.hooks.unregister(key, h). - Add a boot-time check to CI:
Rails.application.eager_load!; Spree.hooks.validate!.
Gotchas
- Swapping a workflow class changes its hook key.
MyApp::Carts::AddItem < Spree::Carts::AddItemdispatchesmy_app.carts.add_item.*— every handler oncarts.add_item.*(yours, extensions', core's) silently stops firing. If you must subclass, declareworkflow_key 'carts.add_item'in it. Seespree-dependencies. - Registering on
orders.complete.*raisesUnknownHookErrorat boot (eager load) —Spree::Orders::Completedeclares no hooks. Checkout goes throughcarts.complete. carts.recalculate_totalsonly hasset_tax_line_context; promotion context is oncarts.recalculate.- Workflows run from jobs, rake tasks or the console need a store context. Set
Spree::Current.store = cart.store(ororder.store) first —Spree::Store.defaultmay be nil. A job walking records of several stores wraps each inSpree::Current.with_store(record.store) { … }(oreach_store_of(relation)in aSpree::BaseJobsubclass). Doing so also arms the dev/testSpree::StoreScopeGuardfor that job, so an unscoped query inside a handler you wrote gets flagged there just as it would in an API request. - Validate hooks see arguments, not results:
workflow.line_item,workflow.order,workflow.fulfillmentare nil until the step that sets them has run.
Where to read further
node_modules/@spree/docs/dist/developer/customization/workflows.md(https://spreecommerce.org/docs/developer/customization/workflows)node_modules/@spree/docs/dist/developer/customization/validations.md— hooks vs model validations vs validator registriesnode_modules/@spree/docs/dist/developer/providers/observability.md— OpenTelemetry setup- Source:
spree_corelib/spree/workflow.rb,lib/spree/hooks.rb,app/workflows/spree/**,app/models/concerns/spree/has_status.rb - Related skills:
spree-dependencies(replace a class),spree-events-webhooks(after-the-fact side effects),spree-checkout(requirements registry),spree-customization(routing)