Skip to main content

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
Everything else stays a service. If you’re writing a plain create-update-delete operation, write a service.

Calling them

Identical for both:
Never raise on expected failures — check 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 in config/initializers/spree.rb:
The key is <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:
Your handler is a class with a 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.
Handlers run in registration order — the order the 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.
Handlers are not isolated from each other. An exception raised in one propagates out of the workflow, later handlers on that hook never run, and an open transaction rolls back — a raise in an after_item_added handler leaves the customer’s item not added at all.If your handler does something optional (analytics, a nice-to-have notification), rescue inside it so a failure in your code can’t undo someone else’s order. Work that is genuinely allowed to fail belongs in an event subscriber, not a hook.

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.
Note that some lifecycle hooks run inside the flow’s database transaction (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 called validate. 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:
The caller receives a normal failure result — no exception reaches your controller:
Reject from validate hooks, not from after_* hooks. Rejecting late still rolls the database back, but it cannot undo work that already left the system — carts.complete.before_finalize runs after the customer’s card was charged, so rejecting there rolls back the order while the charge stands. If you need to stop a flow, validate is the place.

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:
Handlers stay independent — there is no shared object to mutate and no ordering to reason about. A handler returning anything other than a hash contributes nothing, so lifecycle-style handlers are harmless if registered here by mistake. If two handlers set the same key, the last registered one wins and the collision is reported through 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.
The whole vocabulary: 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 an ActiveSupport::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.