Spree Dependencies (swapping whole classes)
Commands use the Spree CLI (
). On a classic Rails app without the CLI, run
/
from the app root. Paths assume the Rails app lives in
.
lets you replace a core class — a service, a workflow, a policy, a serializer — with your own. Spree's code calls
Spree.cart_add_item_workflow.call(...)
, never
Spree::Carts::AddItem.call(...)
, so your replacement runs everywhere: Store API, Admin API, dashboard, jobs, imports.
First: do you actually need to replace the class?
Replacing a class means you own a copy that must be re-synced with every Spree release. Most reasons people swap a class are covered by a workflow hook, which survives upgrades:
| You want to… | Use instead of a swap |
|---|
| Block add-to-cart / checkout / a cancel / a return | hook — Spree.hooks.register('carts.add_item.validate', 'MyStore::Rule')
|
| Add data to tax or promotion calculation | Context hook (carts.recalculate_totals.set_tax_line_context
, carts.recalculate.set_promotion_context
) |
| Write rows alongside the change, atomically | Lifecycle hook (carts.add_item.after_item_added
, carts.complete.after_finalize
) |
| Sync to ERP / send email / reindex | Event subscriber () |
| Add a checkout requirement | Spree::Checkout::Registry.add_requirement
() |
| Add a field to an API response | Subclass the serializer (below) — a small, legitimate swap |
See the
skill for the hook catalog. Swap a class only when you are genuinely replacing
how something is computed (a different merge strategy, a custom buy-box ranking, a B2B access policy, a commission model).
The seams
Spree::Core::Dependencies::INJECTION_POINTS_WITH_DEFAULTS
(in
,
lib/spree/core/dependencies.rb
) defines
167 core injection points;
106 of them are
keys.
Spree::Api::ApiDependencies::INJECTION_POINTS_WITH_DEFAULTS
(
,
lib/spree/api/dependencies.rb
) holds
272 API points — serializers only: 91 Store (
), 139 Admin (
), 42 Seller (
). (Counts from the 6.0 source; run
spree rake spree:dependencies:list
for your version.)
Naming tells you the contract:
| Suffix | Base class | You override |
|---|
| subclass | — never |
| plain class with prepend Spree::ServiceModule::Base
| |
| , , , others | varies — read the default | as documented on the default |
Frequently swapped:
| Key | Default | Notes |
|---|
| | perform(variant:, cart:, quantity:, metadata:, options:, price:)
|
cart_upsert_items_workflow
| Spree::Carts::UpsertItems
| set-quantity / remove / bulk |
cart_recalculate_workflow
| Spree::Carts::Recalculate
| full reprice (delivery, promotions, totals) |
cart_recalculate_totals_workflow
| Spree::Carts::RecalculateTotals
| the single totals seam (discount/fee/tax rows → sums) |
order_recalculate_totals_workflow
| Spree::Orders::RecalculateTotals
| post-placement re-sum twin |
| | guest→customer cart merge |
| | checkout completion — prefer hooks |
| / | / | |
order_update_statuses_service
| Spree::Orders::UpdateStatuses
| sole writer of / |
fulfillment_create_workflow
/ fulfillment_update_workflow
| Spree::Fulfillments::Create
/ | |
| , , , | , | |
| … | , , | |
storefront_access_policy_class
| Spree::Storefront::AccessPolicy
| Store API record visibility (ownership + guest tokens); the B2B seam |
products_for_context_service
| Spree::Products::ForContext
| catalog narrowing for a buyer |
| Spree::Products::SelectBuyBox
| marketplace: which variant leads |
purchase_amount_due_at_checkout_service
| Spree::Purchases::AmountDueAtCheckout
| deposits / net terms |
tax_resolve_exemptions_service
| Spree::Tax::ResolveExemptions
| exemption evidence for the tax provider |
| Spree::Reporting::Adapters::Live
| |
| (set by a search gem) | |
(
) builds every staff CanCanCan ability — Admin API JWT staff, the Seller API, imports/exports. Subclass
, keep
initialize(user, options = {})
(
,
), call
, then add rules. It works behind the permission-key gate, and secret API keys (
) never consult it. See
.
Replacing a workflow
Subclass the shipped workflow and override
with the same keywords.
's signature is the contract (Ruby raises
on a mismatch); a bare
runs the whole parent flow and returns its
.
ruby
# server/app/workflows/my_store/carts/add_item.rb
module MyStore
module Carts
class AddItem < Spree::Carts::AddItem
# REQUIRED: without this the subclass dispatches 'my_store.carts.add_item.*'
# and every handler registered on 'carts.add_item.*' silently stops firing.
workflow_key 'carts.add_item'
def perform(variant:, cart: nil, **rest)
result = super # full Spree flow; a failure raises through, so code below runs only on success
external_step :notify_recommendations # network I/O — outside any transaction
result # Result#value is the line item
end
private
def notify_recommendations
MyStore::Recommendations.item_added(cart_id: cart.prefixed_id, sku: line_item.sku)
end
end
end
end
ruby
# server/config/initializers/spree.rb
Spree.cart_add_item_workflow = MyStore::Carts::AddItem
Rules for workflow replacements:
- Override , never . owns instrumentation, / handling and compensation.
- Keep pointing at the parent's key (see above). This is the most common silent breakage.
- To change one piece, override the private step method (e.g. ) and call — but step names are internals and can change between releases; a hook is safer.
- Return a
Spree::ServiceModule::Result
('s, or ); a non-Result return is wrapped in .
- Callers of
Spree.cart_add_item_workflow
expect the parent's type (a line item here). Don't change it.
- Workflows collaborate through other seams (
step :recalculate, with: -> { Spree.cart_recalculate_workflow }
), so swapping one seam affects every flow that delegates to it.
Replacing a plain service
Same pattern, overriding
:
ruby
module MyStore
class CartUpdate < Spree::Carts::Update
def call(cart:, params:)
result = super
MyStore::ErpSync.enqueue(result.value.prefixed_id) if result.success?
result
end
end
end
Spree.carts_update_service = MyStore::CartUpdate
Inherit from the default so you keep
Spree::ServiceModule::Base
wiring (class-level
,
/
). Always return a
.
Legacy keys are stashed, not applied
Seams that became workflows still accept the old names so apps boot — but
assigning one does nothing. The value is stored, a deprecation warning says "NO LONGER CONSULTED by Spree — the override was not applied", and Spree keeps calling the workflow. A class written against the old service contract (
with
steps) isn't interchangeable with the workflow contract. Port it: subclass the workflow, move logic into
/hooks, assign the new key. These names are removed in 6.1.
| Legacy key (stash-only) | Assign this instead | Default |
|---|
| | |
| cart_recalculate_workflow
| Spree::Carts::Recalculate
|
| | |
| | |
carts_upsert_items_service
| cart_upsert_items_workflow
| Spree::Carts::UpsertItems
|
| | |
| | |
fulfillment_create_service
| fulfillment_create_workflow
| Spree::Fulfillments::Create
|
, fulfillment_update_service
| fulfillment_update_workflow
| Spree::Fulfillments::Update
|
payments_handle_webhook_service
| payments_handle_webhook_workflow
| Spree::Payments::HandleWebhook
|
gift_card_apply/remove/redeem_service
| gift_card_apply/remove/redeem_workflow
| |
Pure renames (applied, with a warning):
checkout_add_store_credit_service
→
store_credit_apply_service
,
checkout_remove_store_credit_service
→
store_credit_remove_service
; API
→
,
→
(and
twins).
Removed keys (assigning raises
at boot):
(completion validation is
Spree::Checkout::Requirements
+
),
,
,
checkout_complete_service
,
checkout_select_shipping_method_service
,
checkout_get_shipping_rates_service
,
,
,
cart_estimate_shipping_rates_service
,
cart_change_currency_service
,
,
,
, and the legacy finders/sorters (
,
,
, …). Carts are created/updated via
/
; checkout progression is
. Coming from 5.x? See
.
API serializers
API dependencies are
serializers only — there are no per-API service keys; every endpoint calls the core
seams directly.
ruby
# server/app/serializers/my_store/product_serializer.rb
module MyStore
class ProductSerializer < Spree::Api::V3::ProductSerializer # Alba resource
typelize brand_name: [:string, nullable: true]
attribute :brand_name do |product|
product.brand&.name
end
end
end
# config/initializers/spree.rb
Spree.api.product_serializer = 'MyStore::ProductSerializer' # Store API
Spree.api.admin_product_serializer = 'MyStore::Admin::ProductSerializer' # Admin API — separate key
- Store and Admin keys are independent.
Spree::Api::V3::Admin::ProductSerializer
inherits the shipped Spree::Api::V3::ProductSerializer
by constant, so swapping does not change Admin output — subclass and swap both if you need the field in both.
- Nested associations often resolve through the accessor (
one :product, resource: proc { Spree.api.product_serializer }
), so your class is used there too.
- Adding typed fields for types: (see ).
Per-controller overrides
To change one endpoint only, decorate the API controller's named hook (
,
,
,
,
resource_permitted_attributes
):
ruby
# server/app/controllers/spree/api/v3/store/products_controller_decorator.rb
module Spree::Api::V3::Store
module ProductsControllerDecorator
protected
def serializer_class
MyStore::DetailedProductSerializer
end
end
end
Spree::Api::V3::Store::ProductsController.prepend(Spree::Api::V3::Store::ProductsControllerDecorator)
There are no per-controller
hooks — controllers call the global seams. See
.
Using seams from your own code
ruby
Spree.cart_add_item_workflow.call(cart: cart, variant: variant, quantity: 1) # resolves overrides
Spree.api.cart_serializer.new(cart, params: { store: current_store }).to_h
Spree::Carts::AddItem.call(...) # ❌ bypasses any override
Extensions especially must go through the accessor so they compose with the host app's overrides.
Syntax:
(recommended — fails immediately on a typo'd constant),
Spree.dependencies { |d| d.<key> = MyClass }
for several, or the legacy
Spree::Dependencies.<key> = 'MyClass'
(string; resolved lazily, still supported).
Inspecting overrides
bash
spree rake spree:dependencies:list # every seam with its current value, [OVERRIDDEN] marked
spree rake spree:dependencies:list | grep workflow
spree rake spree:dependencies:overrides # only overrides: default -> current (file:line that set it)
spree rake spree:dependencies:validate # constantize every seam; exits 1 on NameError — good CI check
is the first thing to run on an inherited project. Programmatic equivalents:
Spree::Dependencies.current_values
,
.overridden?(:cart_add_item_workflow)
,
.override_info(:cart_add_item_workflow)
,
;
for the API side. Legacy stash-only writes don't appear as overrides — grep initializers for
after upgrading.
Gotchas
- Load order: overrides go in
config/initializers/spree.rb
. If two gems set the same key, the last assignment wins; check with spree:dependencies:overrides
.
- Swapped workflow lost its hooks → missing (see above).
- Override "does nothing" after upgrade → you're assigning a legacy key; look for the "NO LONGER CONSULTED" deprecation in the boot log.
- Dropping behavior: a replacement that skips must re-implement stock reservations, pricing providers, availability checks, recalculation and events. Read the parent's in full first.
- at call time → your signature doesn't accept a keyword callers pass. Accept and forward it.
Where to read further
node_modules/@spree/docs/dist/developer/customization/dependencies.md
(https://spreecommerce.org/docs/developer/customization/dependencies) — note: parts of the published page still show API v2 keys; the source constants are authoritative.
node_modules/@spree/docs/dist/developer/customization/workflows.md
— hooks, the preferred alternative
- Source:
lib/spree/core/dependencies.rb
(INJECTION_POINTS_WITH_DEFAULTS
, , , ), lib/spree/api/dependencies.rb
, lib/tasks/dependencies.rake
- Related skills: , , ,