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:
workflow.errors is an ActiveModel::Errors, the same object a model uses. A rejection therefore reaches the API in the shape clients already handle for validation failures — the field name, a symbolic code, and the message:
Add to :base for a rejection that isn’t about one field. reject!('message') with an argument is shorthand for exactly that.
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.upsert_items, 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.
carts.add_item adds to a quantity; carts.upsert_items sets it, and is what a quantity edit, a removal (quantity 0) and a bulk item payload all run through. A rule about what may be in a cart therefore belongs on both keys — the readers are the same (cart, variant, quantity), so one handler class registers against each.upsert_items is also the one flow where a rejection is not fatal on the storefront: the vetoed item is skipped, the rest of the batch applies, and what was dropped comes back in the cart’s warnings. A customer restoring a saved cart keeps whatever is still purchasable. Admin order edits behave the opposite way — the whole edit fails — because a silently dropped row is worse than a failed request when a merchant is editing.
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. 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. As of 6.0 this is not just advice for new models — Spree has no state machines left. Every status a record can hold is declared with Spree::HasStatus, and every move between two of them is a workflow you can hook.

Statuses

Spree::HasStatus declares the statuses a model can hold:
That gives you an inclusion validation, a predicate per value (subscription.paused?), a scope per value (Subscription.paused) and with_status(:active, :trialing) for several at once. It deliberately does not give you a transition graph — deciding which moves are legal is the workflow’s job, which is what lets a transition take arguments, call out to a gateway outside a transaction, and undo itself when a later step fails. Statuses are additive, so an extension can add its own without reopening the model:
A custom status needs a custom workflow to move records into it. That is the design, not a gap: a central place validating transitions would be a state machine again.
has_status never overwrites something the model already defines. Where a status name means more than the column value — Spree::GiftCard#active? also requires the card not to have expired, and Spree::GiftCard.active includes partially redeemed cards — the model’s own definition wins and the generated one is skipped.

Moving a record between statuses

Call the workflow, not the model:
Spree ships one workflow per transition — Spree::Products::Activate, Spree::GiftCards::Redeem, Spree::Imports::Complete, and so on — each with its own validate and after_* hooks, all in Available hooks above. Because the write and the event it publishes happen in the same place, registering against a hook is enough to see every transition, wherever it was triggered from.

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.