@spree/docs 0.1.141 → 0.1.143

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +3,7 @@ title: Customizing the Spree API
3
3
  description: Add new Store API endpoints, customize existing JSON responses with serializer decorators, and extend Spree's REST API to fit your storefront needs.
4
4
  ---
5
5
 
6
- Before you start customizing Spree API endpoints, make sure you reviewed all existing API endpoints in the [Spree API docs](/api-reference).
6
+ Before you start customizing Spree API endpoints, make sure you reviewed all existing API endpoints in the [Spree API docs](../../api-reference/admin-api/endpoints.md).
7
7
 
8
8
  For a step-by-step walkthrough of adding a complete new resource (model, serializer, controller, routes), see the [API tutorial](../tutorial/api.md).
9
9
 
@@ -532,4 +532,3 @@ end
532
532
  - [Admin Partials](../admin/extending-ui.md) - Extend admin UI without view decorators
533
533
  - [Extending Core Models Tutorial](../tutorial/extending-models.md) - Step-by-step guide to connecting custom models with Spree core
534
534
  - [Customization Overview](quickstart.md) - General customization patterns
535
- - [Logic Customization](logic.md) - Customizing business logic
@@ -5,7 +5,16 @@ section: customization
5
5
 
6
6
  ## Overview
7
7
 
8
- With Dependencies, you can easily replace parts of Spree core with your custom code. You can replace [Services](https://github.com/spree/spree/tree/master/core/app/services/spree), CanCanCan Abilities (used for [Permissions](permissions)), and [API Serializers](https://github.com/spree/spree/tree/master/api/app/serializers/spree/v2) (used for generating JSON API responses).
8
+ With Dependencies, you can replace parts of Spree core with your custom code:
9
+ [Services and Workflows](workflows.md), CanCanCan
10
+ Abilities (used for [Permissions](permissions.md)), and API Serializers (used for
11
+ generating JSON API responses).
12
+
13
+ > **TIP:** Replacing a whole class means keeping your copy in sync with every Spree
14
+ > release. If you only need to run code inside an existing flow — validating,
15
+ > reacting, or contributing data to a calculation — use a
16
+ > [hook](workflows.md#extending-a-workflow-with-hooks)
17
+ > instead. Hooks survive upgrades.
9
18
 
10
19
  ## Application (global) customization
11
20
 
@@ -14,50 +23,68 @@ This will change every aspect of the application (both APIs, Admin Panel, and St
14
23
  In your `config/initializers/spree.rb` file, you can set the following:
15
24
 
16
25
  ```ruby
17
- Spree.cart_add_item_service = MyAddToCartService
26
+ Spree.cart_update_service = MyStore::CartUpdate
18
27
  ```
19
28
 
20
29
  or using the block syntax:
21
30
 
22
31
  ```ruby
23
32
  Spree.dependencies do |dependencies|
24
- dependencies.cart_add_item_service = MyAddToCartService
33
+ dependencies.cart_update_service = MyStore::CartUpdate
25
34
  end
26
35
  ```
27
36
 
28
37
  Now let's create your custom service.
29
38
 
30
39
  ```bash
31
- mkdir -p app/services && touch app/services/my_add_to_cart_service.rb
40
+ mkdir -p app/services/my_store && touch app/services/my_store/cart_update.rb
32
41
  ```
33
42
 
34
43
  And add the following code to it:
35
44
 
36
45
  ```ruby
37
- class MyAddToCartService < Spree::Cart::AddItem
38
- def call(order:, variant:, quantity: nil, metadata: {}, options: {})
39
- ApplicationRecord.transaction do
40
- run :add_to_line_item
41
- run Spree.cart_recalculate_service
42
- run :update_in_external_system
46
+ module MyStore
47
+ class CartUpdate < Spree::Carts::Update
48
+ def call(cart:, params:)
49
+ result = super
50
+
51
+ MyStore::ErpSync.push(result.value) if result.success?
52
+
53
+ result
43
54
  end
44
55
  end
56
+ end
57
+ ```
58
+
59
+ Inheriting and calling `super` keeps Spree's behaviour and adds yours around it,
60
+ which is usually what you want — a full rewrite means re-implementing logic that
61
+ changes between releases.
62
+
63
+ ### Replacing a workflow
45
64
 
46
- private
65
+ Workflow-backed seams end in `_workflow` (`cart_add_item_workflow`,
66
+ `carts_complete_workflow`, `payment_capture_workflow`, …). A replacement
67
+ subclasses the workflow and overrides `perform`:
47
68
 
48
- def update_in_external_system(new_order_line_item)
49
- # Your custom logic here
69
+ ```ruby
70
+ module MyStore
71
+ class AddItem < Spree::Carts::AddItem
72
+ def perform(variant:, cart: nil, **rest)
73
+ super
74
+
75
+ # your logic, then the standard result
76
+ end
50
77
  end
51
78
  end
52
- ```
53
79
 
54
- This code will:
80
+ Spree.cart_add_item_workflow = MyStore::AddItem
81
+ ```
55
82
 
56
- 1. Inherit from `Spree::Cart::AddItem`
57
- 2. Override the `call` method to add your custom logic
58
- 3. Call `run :add_to_line_item` to add the item to the cart
59
- 4. Call `run Spree.cart_recalculate_service` to recalculate the cart (returns the resolved class)
60
- 5. Call `run :update_in_external_system` to execute your custom logic, eg. updating Order in an external system such as ERP
83
+ > **NOTE:** Before writing this, check whether a
84
+ > [hook](workflows.md#available-hooks) covers your case
85
+ > `carts.add_item.validate` and `carts.add_item.after_item_added` handle most
86
+ > reasons people replace this class, and they don't need maintaining across
87
+ > upgrades.
61
88
 
62
89
  ## Using dependencies in your code
63
90
 
@@ -65,7 +92,7 @@ When you need to use a dependency in your code, you can access it directly via t
65
92
 
66
93
  ```ruby
67
94
  # Returns the resolved class (not a string)
68
- Spree.cart_add_item_service.call(order: order, variant: variant, quantity: 1)
95
+ Spree.cart_add_item_workflow.call(cart: cart, variant: variant, quantity: 1)
69
96
 
70
97
  # For API dependencies, use the Spree.api accessor
71
98
  Spree.api.storefront_cart_serializer.new(order).serializable_hash
@@ -99,7 +126,7 @@ end
99
126
 
100
127
  This will change the serializer in this API endpoint to `MyNewAwesomeCartSerializer` and also it will swap the default `add_item_service` to `MyNewAwesomeAddItemToCart`.
101
128
 
102
- Different API endpoints can have different dependency injection points. You can review their [source code](https://github.com/spree/spree/tree/master/api/app/controllers/spree/api/v2) to see what you can replace.
129
+ Different API endpoints can have different dependency injection points. You can review their [source code](https://github.com/spree/spree/tree/main/api/app/controllers/spree/api/v3) to see what you can replace.
103
130
 
104
131
  ## API level customization
105
132
 
@@ -117,7 +144,7 @@ This will swap the default Cart serializer and Add Item to Cart service for your
117
144
  You can mix and match both global and API-level customizations:
118
145
 
119
146
  ```ruby
120
- Spree.cart_add_item_service = MyNewAwesomeAddItemToCart
147
+ Spree.cart_add_item_workflow = MyNewAwesomeAddItemToCart
121
148
  Spree.api.storefront_cart_add_item_service = AnotherAddItemToCart
122
149
  ```
123
150
 
@@ -137,9 +164,9 @@ This will output all dependencies with their current values:
137
164
 
138
165
  ```
139
166
  [CORE]
140
- cart_add_item_service Spree::Cart::AddItem
141
- cart_create_service Spree::Cart::Create
142
- cart_recalculate_service Spree::Cart::Recalculate [OVERRIDDEN]
167
+ cart_add_item_workflow Spree::Carts::AddItem
168
+ carts_create_service Spree::Carts::Create
169
+ cart_recalculate_workflow Spree::Carts::Recalculate [OVERRIDDEN]
143
170
  ...
144
171
 
145
172
  [API]
@@ -164,10 +191,10 @@ This shows only the dependencies that have been customized, along with their ori
164
191
 
165
192
  ```
166
193
  [Core OVERRIDES]
167
- cart_recalculate_service Spree::Cart::Recalculate -> MyApp::CartRecalculate (config/initializers/spree.rb:15)
194
+ cart_recalculate_workflow Spree::Carts::Recalculate -> MyApp::CartRecalculate (config/initializers/spree.rb:15)
168
195
 
169
196
  [API OVERRIDES]
170
- storefront_cart_add_item_service Spree::Cart::AddItem -> MyApp::CartAddItem (config/initializers/spree.rb:20)
197
+ storefront_cart_add_item_service Spree::Carts::AddItem -> MyApp::CartAddItem (config/initializers/spree.rb:20)
171
198
  ```
172
199
 
173
200
  ### Validate all dependencies
@@ -181,7 +208,7 @@ This validates that all dependencies can be resolved to valid classes. If any de
181
208
  ```
182
209
  ..........F.........
183
210
  1 invalid dependencies:
184
- [Core] cart_add_item_service: uninitialized constant NonExistentClass
211
+ [Core] cart_add_item_workflow: uninitialized constant NonExistentClass
185
212
  ```
186
213
 
187
214
  ## Programmatic introspection
@@ -191,14 +218,14 @@ You can also inspect dependencies programmatically:
191
218
  ```ruby
192
219
  # Check all current values
193
220
  Spree::Dependencies.current_values
194
- # => [{name: :cart_add_item_service, current: MyApp::CartAddItem, default: 'Spree::Cart::AddItem', overridden: true}, ...]
221
+ # => [{name: :cart_add_item_workflow, current: MyApp::CartAddItem, default: 'Spree::Carts::AddItem', overridden: true}, ...]
195
222
 
196
223
  # Check if a specific dependency is overridden
197
- Spree::Dependencies.overridden?(:cart_add_item_service)
224
+ Spree::Dependencies.overridden?(:cart_add_item_workflow)
198
225
  # => true
199
226
 
200
227
  # Get override information (where it was set)
201
- Spree::Dependencies.override_info(:cart_add_item_service)
228
+ Spree::Dependencies.override_info(:cart_add_item_workflow)
202
229
  # => {value: MyApp::CartAddItem, source: "config/initializers/spree.rb:15", set_at: 2024-01-15 10:30:00}
203
230
 
204
231
  # Validate all dependencies resolve to valid classes
@@ -206,18 +233,43 @@ Spree::Dependencies.validate!
206
233
  # => true (or raises Spree::DependencyError)
207
234
  ```
208
235
 
236
+ ## Renamed seams in Spree 6.0
237
+
238
+ Seams backed by a [workflow](workflows.md) were renamed
239
+ from `*_service` to `*_workflow` in 6.0:
240
+
241
+ | Legacy name | Current name |
242
+ |---|---|
243
+ | `cart_add_item_service` | `cart_add_item_workflow` |
244
+ | `cart_recalculate_service` | `cart_recalculate_workflow` |
245
+ | `carts_complete_service` | `carts_complete_workflow` |
246
+ | `cart_merge_strategy` | `cart_merge_workflow` |
247
+ | `order_cancel_service` | `order_cancel_workflow` |
248
+ | `order_complete_service` | `order_complete_workflow` |
249
+ | `fulfillment_create_service` | `fulfillment_create_workflow` |
250
+ | `payments_handle_webhook_service` | `payments_handle_webhook_workflow` |
251
+
252
+ > **WARNING:** The legacy names stay readable for one release, but **assigning to one no longer
253
+ > has any effect** — the override is recorded and a deprecation warning names the
254
+ > seam to port to. A class written against the old service contract isn't
255
+ > interchangeable with the workflow the new call sites use, so applying it
256
+ > silently would break checkout in ways that are hard to trace.
257
+ >
258
+ > If you override any of these, move to the `*_workflow` name and make sure your
259
+ > class subclasses the workflow. The legacy names are removed in Spree 6.1.
260
+
209
261
  ## Backwards compatibility
210
262
 
211
263
  The legacy string-based syntax is still supported for backwards compatibility:
212
264
 
213
265
  ```ruby
214
266
  # Legacy syntax (still works)
215
- Spree::Dependencies.cart_add_item_service = 'MyAddToCartService'
216
- result = Spree::Dependencies.cart_add_item_service.constantize
267
+ Spree::Dependencies.carts_create_service = 'MyStore::CartCreate'
268
+ result = Spree::Dependencies.carts_create_service.constantize
217
269
 
218
270
  # New syntax (recommended)
219
- Spree.cart_add_item_service = MyAddToCartService
220
- result = Spree.cart_add_item_service
271
+ Spree.carts_create_service = MyStore::CartCreate
272
+ result = Spree.carts_create_service
221
273
  ```
222
274
 
223
275
  Both syntaxes can coexist, but the new syntax is recommended as it's more concise and provides better error messages at assignment time.
@@ -0,0 +1,383 @@
1
+ ---
2
+ title: Services & Workflows
3
+ section: customization
4
+ ---
5
+
6
+ ## Overview
7
+
8
+ Spree's business logic lives in two kinds of plain Ruby classes. Both are called
9
+ the same way and both return the same result object, so as a caller you never
10
+ need to know which one you're using.
11
+
12
+ **Services** (`app/services/`) are the default. A service is an ordinary class
13
+ with a `call` method — creating a customer, updating a price, removing an item
14
+ from a cart. Most of Spree is services.
15
+
16
+ **Workflows** (`app/workflows/`) handle the flows that need more: completing a
17
+ checkout, cancelling an order, capturing a payment, creating a fulfillment.
18
+ These are the operations where something can go wrong halfway through, where
19
+ money moves, or where you might want to inject your own logic partway.
20
+
21
+ A workflow earns its place by needing at least one of:
22
+
23
+ * **Extension points** — named places where your code can run inside the flow
24
+ * **External calls** — payment gateways, carrier APIs, anything over the network
25
+ * **Compensation** — undoing earlier work when a later step fails
26
+
27
+ Everything else stays a service. If you're writing a plain create-update-delete
28
+ operation, write a service.
29
+
30
+ ## Calling them
31
+
32
+ Identical for both:
33
+
34
+ ```ruby
35
+ result = Spree.cart_add_item_workflow.call(cart: cart, variant: variant, quantity: 2)
36
+
37
+ if result.success?
38
+ line_item = result.value
39
+ else
40
+ puts result.error.value
41
+ end
42
+ ```
43
+
44
+ Never raise on expected failures — check `result.success?`. See
45
+ [Dependencies](dependencies.md) for how to swap either one
46
+ for your own class.
47
+
48
+ ## Extending a workflow with hooks
49
+
50
+ Before hooks, customizing a flow meant replacing the whole class and keeping
51
+ your copy in sync with every Spree release. Hooks let you run your own code at a
52
+ named point inside a flow you don't own.
53
+
54
+ Register in `config/initializers/spree.rb`:
55
+
56
+ ```ruby
57
+ Spree.hooks.register('carts.add_item.validate', 'MyStore::CheckPurchaseLimit')
58
+ ```
59
+
60
+ The key is `<workflow>.<hook>`. Handlers are stored as **class name strings**,
61
+ resolved when the hook fires — that keeps registration safe at boot time and
62
+ survives code reloading in development. A block works for one-liners:
63
+
64
+ ```ruby
65
+ Spree.hooks.register('orders.cancel.after_cancel') do |workflow|
66
+ MyStore::Analytics.track(:order_cancelled, workflow.order.number)
67
+ end
68
+ ```
69
+
70
+ Your handler is a class with a `call` method that receives the workflow:
71
+
72
+ ```ruby
73
+ module MyStore
74
+ class CheckPurchaseLimit
75
+ def call(workflow)
76
+ # every #perform keyword is a reader: cart, variant, quantity, ...
77
+ return if workflow.quantity <= 10
78
+
79
+ workflow.reject!('You can order at most 10 of this item.')
80
+ end
81
+ end
82
+ end
83
+ ```
84
+
85
+ > **NOTE:** Hook keys are validated at boot. Registering against a hook that doesn't exist
86
+ > raises `Spree::Hooks::UnknownHookError` with the list of valid hooks for that
87
+ > workflow, so a typo fails immediately instead of silently never firing.
88
+
89
+ ## Every hook can have many handlers
90
+
91
+ A hook is not a single slot. Your extension, another extension and the host
92
+ application can all register against the same key, and every one of them runs.
93
+ Assume you are never alone on a hook.
94
+
95
+ ```ruby
96
+ Spree.hooks.register('carts.add_item.validate', 'MyStore::CheckPurchaseLimit')
97
+ Spree.hooks.register('carts.add_item.validate', 'OtherGem::CheckChannelRules')
98
+ Spree.hooks.register('carts.add_item.validate') { |workflow| ... }
99
+ # all three run
100
+ ```
101
+
102
+ **Handlers run in registration order** — the order the `register` calls
103
+ happened, which for gems is initializer load order. Don't depend on it. If your
104
+ handler only makes sense after another one has run, you have a sequencing
105
+ requirement that hooks don't express; put both pieces in one handler.
106
+
107
+ **Registering the same class twice is a no-op.** `register` deduplicates by
108
+ class name, so an initializer that runs twice (or a gem registering defensively)
109
+ won't double up. Two *different* classes are two handlers, and two separate
110
+ blocks are always two handlers — blocks can't be compared, so prefer class names
111
+ anywhere registration might repeat.
112
+
113
+ What "many handlers" means differs by kind:
114
+
115
+ | Kind | With several handlers |
116
+ |---|---|
117
+ | `validate` | Runs in order until one rejects. **The first `reject!` stops the flow** — later validate handlers never run, so don't rely on yours executing. |
118
+ | Context | **All** run and their hashes are merged. A key set by two handlers goes to the last one registered, and the collision is reported through `Rails.error`. |
119
+ | Lifecycle | All run in order. Return values are ignored. |
120
+
121
+ One consequence worth planning for: because any handler can veto, a `validate`
122
+ handler should say *why* it rejected in the message, and a lifecycle handler
123
+ should not assume it is the only observer of the event.
124
+
125
+ > **WARNING:** Handlers are not isolated from each other. An exception raised in one propagates
126
+ > out of the workflow, later handlers on that hook never run, and an open
127
+ > transaction rolls back — a `raise` in an `after_item_added` handler leaves the
128
+ > customer's item not added at all.
129
+ >
130
+ > If your handler does something optional (analytics, a nice-to-have
131
+ > notification), rescue inside it so a failure in your code can't undo someone
132
+ > else's order. Work that is genuinely allowed to fail belongs in an
133
+ > [event subscriber](../core-concepts/events.md), not a hook.
134
+
135
+ ## The three kinds of hooks
136
+
137
+ ### Lifecycle hooks — react to something that happened
138
+
139
+ Named in the past tense (`after_item_added`, `after_cancel`, `after_create`).
140
+ They run after the work is done. Return values are ignored; you cannot change
141
+ the outcome.
142
+
143
+ ```ruby
144
+ class MyStore::NotifyWarehouse
145
+ def call(workflow)
146
+ WarehouseApi.notify(workflow.fulfillment.number)
147
+ end
148
+ end
149
+
150
+ Spree.hooks.register('fulfillments.create.after_create', 'MyStore::NotifyWarehouse')
151
+ ```
152
+
153
+ Note that some lifecycle hooks run **inside** the flow's database transaction
154
+ (`after_item_added`, `after_cancel`). That's deliberate — it lets you write
155
+ related records atomically with the change. It also means slow work does not
156
+ belong there: use an [event subscriber](../core-concepts/events.md) for
157
+ emails, webhooks and other eventual work.
158
+
159
+ ### Validation hooks — veto before the work happens
160
+
161
+ Always called `validate`. They run **before** anything is written, so rejecting
162
+ costs nothing — no rollback, no partial state, no money moved.
163
+
164
+ Call `reject!` on the workflow to stop the flow:
165
+
166
+ ```ruby
167
+ module MyStore
168
+ class LimitReturnWindow
169
+ def call(workflow)
170
+ return if workflow.order.completed_at > 30.days.ago
171
+
172
+ workflow.reject!('This order is outside the 30-day return window.')
173
+ end
174
+ end
175
+ end
176
+ ```
177
+
178
+ The caller receives a normal failure result — no exception reaches your
179
+ controller:
180
+
181
+ ```ruby
182
+ result = Spree.cart_add_item_workflow.call(cart: cart, variant: variant)
183
+ result.success? # => false
184
+ result.error.value # => "You can order at most 10 of this item."
185
+ ```
186
+
187
+ > **WARNING:** Reject from `validate` hooks, not from `after_*` hooks. Rejecting late still
188
+ > rolls the database back, but it cannot undo work that already left the system —
189
+ > `carts.complete.before_finalize` runs *after* the customer's card was charged,
190
+ > so rejecting there rolls back the order while the charge stands. If you need to
191
+ > stop a flow, `validate` is the place.
192
+
193
+ ### Context hooks — feed data into a calculation
194
+
195
+ Named imperatively (`set_promotion_context`, `set_tax_line_context`,
196
+ `get_provider_data`). They run before a calculation so you can contribute data
197
+ to it.
198
+
199
+ Your handler **returns a hash**. Spree merges the hashes from every registered
200
+ handler and hands the result to the workflow:
201
+
202
+ ```ruby
203
+ module MyStore
204
+ class TaxExemption
205
+ def call(workflow)
206
+ certificate = workflow.cart.customer&.tax_exemption_certificate
207
+ return {} if certificate.blank?
208
+
209
+ { exemption_certificate: certificate.number }
210
+ end
211
+ end
212
+ end
213
+
214
+ Spree.hooks.register('carts.recalculate_totals.set_tax_line_context', 'MyStore::TaxExemption')
215
+ ```
216
+
217
+ Handlers stay independent — there is no shared object to mutate and no ordering
218
+ to reason about. A handler returning anything other than a hash contributes
219
+ nothing, so lifecycle-style handlers are harmless if registered here by mistake.
220
+
221
+ If two handlers set the same key, the last registered one wins and the collision
222
+ is reported through `Rails.error` so it's visible rather than mysterious.
223
+
224
+ ## Available hooks
225
+
226
+ | Workflow key | Hook | Kind | When it runs |
227
+ |---|---|---|---|
228
+ | `carts.add_item` | `validate` | validate | Before the line item is built |
229
+ | `carts.add_item` | `after_item_added` | lifecycle | After the item is saved and totals recalculated (in transaction) |
230
+ | `carts.complete` | `validate` | validate | After checkout requirements pass, before the order is created |
231
+ | `carts.complete` | `before_finalize` | lifecycle | After payment, before the order is placed |
232
+ | `carts.complete` | `after_finalize` | lifecycle | After the order is placed |
233
+ | `carts.merge` | `validate` | validate | Before any items move between carts |
234
+ | `carts.merge` | `after_merge` | lifecycle | After the carts are folded together |
235
+ | `carts.recalculate` | `set_promotion_context` | context | Before promotions are evaluated |
236
+ | `carts.recalculate` | `after_recalculate` | lifecycle | After the cart is fully repriced |
237
+ | `carts.recalculate_totals` | `set_tax_line_context` | context | Before tax is estimated |
238
+ | `orders.cancel` | `before_cancel` | validate | Before the cancellation is recorded |
239
+ | `orders.cancel` | `after_cancel` | lifecycle | With the cancellation, in transaction |
240
+ | `orders.resume` | `before_resume` | validate | Before the order is un-cancelled |
241
+ | `orders.resume` | `after_resume` | lifecycle | With the status flip, in transaction |
242
+ | `fulfillments.create` | `validate` | validate | Before the order is locked |
243
+ | `fulfillments.create` | `get_provider_data` | context | Before the fulfillment is built |
244
+ | `fulfillments.create` | `after_create` | lifecycle | After the fulfillment is created and totals recalculated |
245
+ | `payments.capture` | `validate` | validate | Before the gateway is called |
246
+ | `payments.capture` | `before_capture` | lifecycle | Immediately before the gateway call |
247
+ | `payments.capture` | `after_capture` | lifecycle | After a successful capture |
248
+ | `payments.refund` | `validate` | validate | Before the refund record exists |
249
+ | `payments.refund` | `before_refund` | lifecycle | Immediately before the gateway call |
250
+ | `payments.refund` | `after_refund` | lifecycle | After a successful refund |
251
+ | `payments.handle_webhook` | `after_handle` | lifecycle | After the gateway callback is processed |
252
+
253
+ `before_cancel` and `before_resume` accept `reject!` like a `validate` hook.
254
+
255
+ Draft-order editing in the admin uses **twin workflows** with their own keys —
256
+ `orders.add_item`, `orders.recalculate`, `orders.recalculate_totals` — carrying
257
+ the same hooks as their cart counterparts. Register against the cart key for
258
+ storefront carts, the order key for admin edits, or both.
259
+
260
+ Inspect what's available at runtime:
261
+
262
+ ```ruby
263
+ Spree.hooks.workflows # => { 'carts.add_item' => 'Spree::Carts::AddItem', ... }
264
+ Spree::Carts::Complete.declared_hooks # => [:validate, :before_finalize, :after_finalize]
265
+ Spree.hooks.keys # => registered keys
266
+ Spree.hooks.validate! # => true, or raises on a bad registration
267
+ ```
268
+
269
+ ## Writing your own workflow
270
+
271
+ Most extensions only need hooks. Write a workflow when you're adding a *new*
272
+ multi-step operation of your own — one with external calls, compensation, or
273
+ extension points for others.
274
+
275
+ ```ruby
276
+ module MyStore
277
+ class Subscriptions::Renew < Spree::Workflow
278
+ hooks :validate, :after_renew
279
+
280
+ attr_reader :order
281
+
282
+ # The method signature is the contract — Ruby raises on a missing or
283
+ # unknown keyword, and a bare `super` turns each parameter into a reader.
284
+ #
285
+ # @param subscription [MyStore::Subscription]
286
+ # @param renewed_at [Time, nil]
287
+ def perform(subscription:, renewed_at: nil)
288
+ super
289
+
290
+ step :ensure_renewable
291
+ run_hooks :validate
292
+
293
+ ApplicationRecord.transaction do
294
+ step :build_order, on_flow_failure: :discard_order
295
+ step :extend_period
296
+ end
297
+
298
+ external_step :charge_customer
299
+
300
+ run_hooks :after_renew
301
+ subscription.publish_event('subscription.renewed')
302
+ success(order)
303
+ end
304
+
305
+ private
306
+
307
+ def ensure_renewable
308
+ failure(subscription, :not_active) unless subscription.active?
309
+ end
310
+
311
+ def build_order
312
+ @order = MyStore::Subscriptions::BuildOrder.call(subscription: subscription).value
313
+ end
314
+
315
+ def extend_period
316
+ subscription.update!(renews_at: (renewed_at || Time.current) + 1.month)
317
+ end
318
+
319
+ def charge_customer
320
+ Spree.payment_capture_workflow.call(payment: order.payments.last)
321
+ end
322
+
323
+ # Runs if a later step fails after the transaction committed.
324
+ def discard_order
325
+ order&.destroy
326
+ end
327
+ end
328
+ end
329
+ ```
330
+
331
+ The whole vocabulary:
332
+
333
+ | | |
334
+ |---|---|
335
+ | `step :name` | Runs the private method of that name |
336
+ | `external_step :name` | Same, but refuses to run inside a database transaction this workflow opened — use it for every network call |
337
+ | `with: -> { ... }` | Delegates a step to a swappable collaborator, keyword arguments sliced from the workflow's readers |
338
+ | `on_flow_failure: :name` | Names the undo for a step, run in reverse if a later step fails |
339
+ | `run_hooks :name` | Dispatches a declared hook; returns the merged hash from context handlers |
340
+ | `failure(value, error)` | Aborts the flow — rolls back an open transaction and returns a failure result |
341
+ | `reject!(message)` | The same, named for hook handlers vetoing a flow |
342
+ | `halt!(value)` | Successful early exit (not valid inside a transaction the workflow opened) |
343
+ | `hooks :a, :b` | Declares the extension points this workflow dispatches |
344
+
345
+ Everything else is ordinary Rails — `ApplicationRecord.transaction`,
346
+ `with_lock`, `if`, `rescue`, `publish_event`.
347
+
348
+ Two rules worth internalising:
349
+
350
+ **Network calls never share a database transaction.** That's what
351
+ `external_step` enforces. A gateway call inside a transaction holds a database
352
+ connection open across a network round trip, and a timeout leaves your database
353
+ and the payment processor disagreeing about what happened.
354
+
355
+ **New models get a plain `status` string, not a state machine.** Transitions are
356
+ workflows: `MyStore::Subscriptions::Cancel.call(...)`, not `subscription.cancel!`.
357
+ Transition callbacks hide side effects inside a save, cannot take arguments, and
358
+ have no compensation story.
359
+
360
+ ## Observability
361
+
362
+ Every step emits an `ActiveSupport::Notifications` event, so your APM sees the
363
+ flow without extra instrumentation:
364
+
365
+ ```ruby
366
+ ActiveSupport::Notifications.subscribe('step.spree_workflow') do |*, payload|
367
+ Rails.logger.info("#{payload[:workflow]}##{payload[:step]}")
368
+ end
369
+ ```
370
+
371
+ ## Choosing an extension point
372
+
373
+ | You want to | Use |
374
+ |---|---|
375
+ | Stop an operation from happening | A `validate` hook |
376
+ | Add data to a pricing or tax calculation | A context hook |
377
+ | Do something after an operation, in the same transaction | A lifecycle hook |
378
+ | Send an email, call a webhook, update a search index | An [event subscriber](../core-concepts/events.md) |
379
+ | Replace an operation entirely | [Dependencies](dependencies.md) |
380
+ | Add a brand-new multi-step operation | Your own workflow |
381
+
382
+ Reach for the smallest one that does the job. A hook survives Spree upgrades;
383
+ a replaced class has to be kept in sync with every release.