Overview
Spree’s business logic lives in two kinds of plain Ruby classes. Both are called the same way and both return the same result object, so as a caller you never need to know which one you’re using. Services (app/services/) are the default. A service is an ordinary class
with a call method — creating a customer, updating a price, removing an item
from a cart. Most of Spree is services.
Workflows (app/workflows/) handle the flows that need more: completing a
checkout, cancelling an order, capturing a payment, creating a fulfillment.
These are the operations where something can go wrong halfway through, where
money moves, or where you might want to inject your own logic partway.
A workflow earns its place by needing at least one of:
- Extension points — named places where your code can run inside the flow
- External calls — payment gateways, carrier APIs, anything over the network
- Compensation — undoing earlier work when a later step fails
Calling them
Identical for both:result.success?. See
Dependencies for how to swap either one
for your own class.
Extending a workflow with hooks
Before hooks, customizing a flow meant replacing the whole class and keeping your copy in sync with every Spree release. Hooks let you run your own code at a named point inside a flow you don’t own. Register inconfig/initializers/spree.rb:
<workflow>.<hook>. Handlers are stored as class name strings,
resolved when the hook fires — that keeps registration safe at boot time and
survives code reloading in development. A block works for one-liners:
call method that receives the workflow:
Hook keys are validated at boot. Registering against a hook that doesn’t exist
raises
Spree::Hooks::UnknownHookError with the list of valid hooks for that
workflow, so a typo fails immediately instead of silently never firing.Every hook can have many handlers
A hook is not a single slot. Your extension, another extension and the host application can all register against the same key, and every one of them runs. Assume you are never alone on a hook.register calls
happened, which for gems is initializer load order. Don’t depend on it. If your
handler only makes sense after another one has run, you have a sequencing
requirement that hooks don’t express; put both pieces in one handler.
Registering the same class twice is a no-op. register deduplicates by
class name, so an initializer that runs twice (or a gem registering defensively)
won’t double up. Two different classes are two handlers, and two separate
blocks are always two handlers — blocks can’t be compared, so prefer class names
anywhere registration might repeat.
What “many handlers” means differs by kind:
One consequence worth planning for: because any handler can veto, a
validate
handler should say why it rejected in the message, and a lifecycle handler
should not assume it is the only observer of the event.
The three kinds of hooks
Lifecycle hooks — react to something that happened
Named in the past tense (after_item_added, after_cancel, after_create).
They run after the work is done. Return values are ignored; you cannot change
the outcome.
after_item_added, after_cancel). That’s deliberate — it lets you write
related records atomically with the change. It also means slow work does not
belong there: use an event subscriber for
emails, webhooks and other eventual work.
Validation hooks — veto before the work happens
Always calledvalidate. They run before anything is written, so rejecting
costs nothing — no rollback, no partial state, no money moved.
Call reject! on the workflow to stop the flow:
Context hooks — feed data into a calculation
Named imperatively (set_promotion_context, set_tax_line_context,
get_provider_data). They run before a calculation so you can contribute data
to it.
Your handler returns a hash. Spree merges the hashes from every registered
handler and hands the result to the workflow:
Rails.error so it’s visible rather than mysterious.
Available hooks
before_cancel and before_resume accept reject! like a validate hook.
Draft-order editing in the admin uses twin workflows with their own keys —
orders.add_item, orders.recalculate, orders.recalculate_totals — carrying
the same hooks as their cart counterparts. Register against the cart key for
storefront carts, the order key for admin edits, or both.
Inspect what’s available at runtime:
Writing your own workflow
Most extensions only need hooks. Write a workflow when you’re adding a new multi-step operation of your own — one with external calls, compensation, or extension points for others.
Everything else is ordinary Rails —
ApplicationRecord.transaction,
with_lock, if, rescue, publish_event.
Two rules worth internalising:
Network calls never share a database transaction. That’s what
external_step enforces. A gateway call inside a transaction holds a database
connection open across a network round trip, and a timeout leaves your database
and the payment processor disagreeing about what happened.
New models get a plain status string, not a state machine. Transitions are
workflows: MyStore::Subscriptions::Cancel.call(...), not subscription.cancel!.
Transition callbacks hide side effects inside a save, cannot take arguments, and
have no compensation story.
Observability
Every step emits anActiveSupport::Notifications event, so your APM sees the
flow without extra instrumentation:
Choosing an extension point
Reach for the smallest one that does the job. A hook survives Spree upgrades;
a replaced class has to be kept in sync with every release.

