spree-workflows について
spree-workflows は、Spree のサービスとワークフローの構成、拡張フック、結果処理を解説する開発者向けスキルです。コア処理を直接変更せず、適切な箇所に業務ロジックを追加できます。
主な機能
- 01サービスとワークフローの役割
- 02検証、コンテキスト、ライフサイクルフック
- 03フック登録と処理の拒否
- 04ワークフロー拡張ポイントの一覧
- 05イベントと処理置換の指針
活用シーン
- 01条件に応じたチェックアウトの拒否
- 02税やプロモーション計算へのコンテキスト追加
- 03ライフサイクルフックで関連レコードを作成
- 04フック、イベント、ワークフローの選択
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ spree/ agent-skills --skill spree-workflowsインストール後、Claude Code で/spree-workflowsを入力して読み込まれたか確認します。
同梱ファイルreferences/hooks.md
詳細
Spree のワークフローとフック
コマンドでは Spree CLI(
spree …)を使用します。CLI のない従来の Rails アプリでは、アプリのルートからbin/rails …/bundle exec rake …を使用してください。以下のパスは、Rails アプリがserver/にあることを前提としています。
Spree のビジネスロジックは、同じ方法で呼び出され、同じ Spree::ServiceModule::Result を返す、2 種類のプレーンな Ruby クラスにあります。
| 階層 | 配置場所 | 用途 |
|---|---|---|
| サービス | app/services/ | 単純な作成・更新・削除など、順次実行する単一の call 本体。Spree の大部分を占めます。 |
ワークフロー(Spree::Workflow) | app/workflows/ | 拡張ポイント、外部呼び出し(ゲートウェイ、配送業者)、または補償処理(後続のステップが失敗した場合の取り消し)が必要なフローです。カートへの追加・完了、注文のキャンセル、支払いのキャプチャ、フルフィルメント、返品、商品ステータスの変更、インポートなどが該当します。 |
result = Spree.cart_add_item_workflow.call(cart: cart, variant: variant, quantity: 2)
result.success? # never raise on expected failures — check this
result.value # the line item
result.error.to_s # message on failure; result.error.value is ActiveModel::Errors for rejections
Spree にステートマシンはありません。レコードには通常の status 文字列(Spree::HasStatus)があり、各遷移はそれぞれ独自のフックを持つワークフローです。
カスタムロジックの配置場所 — まず判断する
| やりたいこと | 使用するもの |
|---|---|
| 操作を中止する(購入上限、B2B 購入資格、不正利用保留、返品ポリシー) | validate フック |
| 計算に使うデータを提供する(免税、プロモーションの適用資格、配送業者データ) | コンテキストフック(set_*_context、get_provider_data、extend_payload) |
| 変更と関連する行をアトミックに書き込む | ライフサイクルフック(after_*。一部はトランザクション内で実行されます) |
| メール送信、Webhook の呼び出し、検索インデックスの更新、ERP の同期 | イベントサブスクライバー — spree-events-webhooks |
| チェックアウト要件を追加する(顧客が X を提供する必要がある) | Spree::Checkout::Registry.add_requirement — spree-checkout |
| 操作全体を置き換える | Spree.dependencies — spree-dependencies(最後の手段です。置き換えた実装は自分で保守します) |
| 新しい複数ステップの操作を追加する | 独自の Spree::Workflow(下記) |
最小限の方法を選んでください。フックはアップグレード後も維持されますが、クラスを置き換えた場合はリリースごとに再同期する必要があります。
モデルにビジネス処理を追加するのは避けてください(書き込みを行う新しいパブリックモデルメソッド、after_save による副作用、遷移コールバックなど)。また、finalize!、Order#cancel、Payment#capture! などをデコレートしたりパッチしたりするのも避けてください。これらはワークフローに処理を委譲する非推奨のシェル(Order#finalize! → Spree.order_complete_workflow)であり、実際のフロー(Spree::Carts::Complete、Spree::Orders::Cancel、Spree::Payments::Capture)からオーバーライドが呼び出されることはありません。代わりに、ワークフローにフックを使用してください。
フックの登録
config/initializers/spree.rb で:
Spree.hooks.register('carts.add_item.validate', 'MyStore::CheckPurchaseLimit')
Spree.hooks.register('carts.upsert_items.validate', 'MyStore::CheckPurchaseLimit')
# A block works for one-liners (but can't be deduplicated or unregistered by name)
Spree.hooks.register('orders.cancel.after_cancel') do |workflow|
MyStore::Analytics.track(:order_canceled, workflow.order.number)
end
- キーは
'<workflow key>.<hook>'です。ワークフローキーはクラス名から導出されます:Spree::Carts::AddItem→carts.add_item。 - ハンドラーはクラス名の文字列として保存され、フックの実行時に定数化されます。これにより、起動時やリロード時にも安全です。同じクラスを複数回登録しても何も起こりません。
- タイミング:
Spree.hooksはプロセス全体で使われるレジストリーで、コアによってリセットされることはないため、イニシャライザーでのトップレベル登録が適切です(コアが再割り当てするためafter_initializeで追加する必要があるpayment_methodsなどのレジストリーとは異なります)。config.to_prepareには登録しないでください。開発時のリロードごとに再実行され、ブロックハンドラーが重複します。Rails.application.config.after_initializeが必要なのは、コアが登録するハンドラーを登録解除する場合(下記)のみです。 - ハンドラーには
#call(workflow)を持つ任意のクラスを指定できます。受け取るのはワークフローインスタンスです。すべての#performキーワードはパブリックリーダー(workflow.cart、workflow.variant、workflow.quantity)として利用でき、さらにワークフローがattr_readerで公開する値(workflow.line_item、workflow.order、workflow.fulfillmentなど)も利用できます。各フックでどのリーダーに値が設定されるかは、ワークフローのソースまたはreferences/hooks.mdを確認してください。たとえば、carts.add_item.validateの時点ではline_itemは nil です。 eager_loadが有効な場合(本番環境、および通常は CI)には、起動時にキーが検証されます。誤記があると、有効なフックの一覧を含むSpree::Hooks::UnknownHookErrorが発生します。開発環境(遅延読み込み)では、無効なキーがあってもフックは実行されませんが、エラーも発生しません。確認するには、Rails.application.eager_load!の後にコンソールでSpree.hooks.validate!を実行してください。Spree.hooks.unregister(key, 'Handler')は指定したハンドラーを1つ登録解除し、unregister(key)はそのキーに登録されたすべてのハンドラーを登録解除します。
module MyStore
class CheckPurchaseLimit
def call(workflow)
return if (workflow.quantity || 1).to_i <= 10 # carts.add_item quantity defaults to nil (=1)
workflow.errors.add(:quantity, :purchase_limit_exceeded,
message: 'You can order at most 10 of this item.')
workflow.reject!
end
end
end
workflow.errors は ActiveModel::Errors です。reject!(または reject!('message')。これは :base に対するエラーの省略記法です)を呼び出すと、フローを中断し、補償処理を巻き戻し、開いているトランザクションをロールバックして、失敗結果を返します。API はモデルのバリデーション失敗とまったく同じ形式でこれを返します。つまり、フィールドごとにキー付けされた details を含む 422 validation_error となるため、SDK クライアントで特別な処理を行う必要はありません。クライアントがコードに基づいて分岐できるよう、message: とともにシンボリック型(:purchase_limit_exceeded など)を使うことを推奨します。
carts.add_item は数量に加算しますが、carts.upsert_items は数量を設定します(数量の編集、削除、一括ペイロード)。カートのルールは両方のキーに設定します。リーダー(cart、variant、quantity)は一致するため、1つのハンドラークラスをそれぞれに登録できます。ストアフロントでは、upsert_items が拒否されてもバッチ全体は失敗せず、そのアイテムはスキップされ、カートの warnings に報告されます。管理画面での注文編集では、編集全体が失敗します。
3種類のフック
| 種類 | 名前 | 実行タイミング | 戻り値 | 複数のハンドラー |
|---|---|---|---|---|
| 検証 | validate(および orders.cancel.before_cancel) | 書き込みが行われる前。拒否してもコストはかかりません | 無視されます。reject! で拒否します | 登録順に実行されます。最初の reject! でフローが停止し、後続の検証ハンドラーは実行されません |
| コンテキスト | set_promotion_context、set_tax_line_context、get_provider_data、extend_payload | 計算の前 | Hash を返します。各ハンドラーの値が(ディープに)マージされ、計算に渡されます | すべて実行されます。キーが衝突すると、最後に登録された値が優先され、その内容が Rails.error で報告されます |
| ライフサイクル | before_* / after_*(after_item_added、before_finalize、after_cancel、before_capture など) | 処理の前後、または処理後。複数のフックはフローのトランザクション内で実行されることがあります | 無視されます | すべて登録順に実行されます |
# Context hook: contribute tax-exemption evidence to totals recalculation
module MyStore
class TaxExemption
def call(workflow)
cert = MyStore::ExemptionLookup.for(workflow.cart.customer)
cert ? { exemption_certificate: cert.number } : {}
end
end
end
Spree.hooks.register('carts.recalculate_totals.set_tax_line_context', 'MyStore::TaxExemption')
マージされたハッシュは context: として税プロバイダーに渡されます。プロモーションの場合は workflow.promotion_context として公開されます(carts.recalculate.set_promotion_context)。
設計時に考慮すべき複数ハンドラーのルール
- フックを単独で使えるとは考えないでください。 アプリ、拡張機能、Spree 自身がハンドラーを登録します(コアは
Spree::Returns::EligibilityValidatorをreturns.create.validateとexchanges.create.validateに登録します)。 - ハンドラー間の実行順序に依存しないでください。 順序は登録順(gem のイニシャライザーの読み込み順)です。2 つの処理を順番に実行する必要がある場合は、1 つのハンドラーにまとめてください。
- ハンドラーは分離されていません。 例外はワークフローの外に伝播し、後続のハンドラーをスキップして、開いているトランザクションをロールバックします。
after_item_addedでraiseすると、アイテムは追加されません。任意の処理では内部で例外を捕捉するか、イベントサブスクライバーに移してください。 - 拒否するのは
validateからのみにしてください。carts.complete.before_finalizeはカードへの課金後に実行されるため、そこで拒否すると、課金は成立したまま注文がロールバックされます。 - トランザクション内のライフサイクルフックでは、時間のかかる処理を行わないでください(
after_item_added、after_cancel、ステータスワークフローのafter_*)。ネットワーク呼び出し中は往復通信の間、DB 接続が占有されるため、サブスクライバーを使用してください。
Spree 自身が登録したフックを置き換える
コアの返品期間バリデーターは、config/initializers/* より前に実行されるエンジンのイニシャライザーによって登録されるため、イニシャライザー内で通常の unregister を実行すると削除できます。
# config/initializers/spree.rb
Spree.hooks.unregister('returns.create.validate', 'Spree::Returns::EligibilityValidator')
Spree.hooks.unregister('exchanges.create.validate', 'Spree::Returns::EligibilityValidator')
Spree.hooks.register('returns.create.validate', 'MyStore::ReturnPolicy')
Spree.hooks.register('exchanges.create.validate', 'MyStore::ReturnPolicy')
最も有用なフックポイント
| Key | 種類 | タイミング |
|---|---|---|
carts.add_item.validate | 検証 | 明細項目が作成される前(トランザクションの外) |
carts.add_item.after_item_added | ライフサイクル | 保存と再計算の後、トランザクション内(workflow.line_item、line_item_created) |
carts.upsert_items.validate / .after_items_upserted | 検証 / ライフサイクル | 各項目の適用前 / 一括処理の後 |
carts.merge.validate / .after_merge | 検証 / ライフサイクル | ゲストカートから顧客カートへのマージ |
carts.recalculate.set_promotion_context | コンテキスト | プロモーションの評価前 |
carts.recalculate.after_recalculate | ライフサイクル | 価格再計算の後 |
carts.recalculate_totals.set_tax_line_context | コンテキスト | 税額の見積もり前 |
carts.complete.validate | 検証 | チェックアウト要件の確認後、注文が作成される前 — チェックアウトをブロックする場所(与信限度額、承認の必須化) |
carts.complete.before_finalize / .after_finalize | ライフサイクル | 決済後、注文確定の前後(workflow.order、order_group) |
orders.cancel.before_cancel / .after_cancel | 検証 / ライフサイクル | キャンセルを拒否する / キャンセルに合わせて書き込む(トランザクション内) |
payments.capture.validate | 検証 | 不正利用の保留 — 決済ゲートウェイの呼び出し前 |
payments.process.validate | 検証 | オーソリまたは購入処理が決済ゲートウェイに送られる前 |
refunds.create.validate | 検証 | 返金レコードの作成前(承認基準額) |
fulfillments.create.get_provider_data | コンテキスト | 配送業者のアカウント / サービスレベル / 通関データ |
fulfillments.fulfill.validate / .after_fulfill | 検証 / ライフサイクル | 発送済みとして記録する前 / 後 |
returns.create.validate | 検証 | 返品の適格性(コアの返品可能期間のルールを置き換える) |
customers.create.validate | 検証 | 登録ポリシー(ボットのスクリーニング、B2B 承認) |
customers.anonymize.validate | 検証 | GDPR に基づく消去を拒否する(係争中、法的保留) |
products.activate.validate | 検証 | 商品を公開する前に画像 / 価格 / カテゴリを必須にする |
products.create/update.validate | 検証 | Admin API、CSV インポート、シードのすべてで実行 |
管理画面での下書き注文の編集では、独自のキーを持ち、同じフックを使う対になるワークフローを使用します:orders.add_item、orders.upsert_items、orders.recalculate、orders.recalculate_totals。ストアフロントのカートにはカートのキー、管理画面での編集には注文のキー、または両方に登録してください。対になるワークフローは order: を cart: として渡すため、どちらのキーでもハンドラーは workflow.cart を読み取ります。
完全なカタログ(すべてのワークフロー、フック、依存関係キー、リーダー): references/hooks.md。
実行時に確認する(rake タスクはありません — コンソールを使用してください):
Spree.hooks.workflows # { 'carts.add_item' => 'Spree::Carts::AddItem', ... }
Spree.hooks.carts # { 'carts.add_item' => [:validate, :after_item_added], ... }
Spree::Carts::Complete.declared_hooks # [:validate, :before_finalize, :after_finalize]
Spree.hooks.keys # keys with registered handlers
Spree.hooks.handler_count('carts.complete.validate')
Spree.hooks.validate! # true, or raises UnknownHookError
独自のワークフローを作成する
外部呼び出し、補償処理、または拡張ポイントを含む、新しい複数ステップの操作に限り、独自のワークフローを作成します。単純な CRUD はサービス(またはモデルの保存処理)のままにします。
# server/app/workflows/my_store/subscriptions/renew.rb
module MyStore
module Subscriptions
class Renew < Spree::Workflow
hooks :validate, :after_renew # declare every run_hooks name
attr_reader :order # derived state handlers may read
# The signature IS the contract. Bare `super` turns each keyword into a reader.
def perform(subscription:, renewed_at: nil)
super
step :ensure_renewable
run_hooks :validate
ApplicationRecord.transaction do
step :build_order, on_flow_failure: :discard_order
step :extend_period
end
external_step :charge_customer # refuses to run inside a transaction this workflow opened
run_hooks :after_renew
subscription.publish_event('subscription.renewed')
success(order)
end
private
def ensure_renewable
failure(subscription, :not_active) unless subscription.active? # raises — aborts the flow
end
def build_order
@order = MyStore::Subscriptions::BuildOrder.call(subscription: subscription).value
end
def extend_period
subscription.update!(renews_at: (renewed_at || Time.current) + 1.month)
end
def charge_customer
Spree.payment_capture_workflow.call(payment: order.payments.last) # a failure Result fails this step
end
def discard_order # undo — armed when the transaction commits, run in reverse on later failure
order&.destroy
end
end
end
end
| 用語 | 意味 |
|---|---|
step :name | プライベートメソッドを実行します。返された失敗 Result によってフローは失敗します。 |
step :name, with: -> { Spree.some_workflow } | 差し替え可能なコラボレーターに処理を委譲します。そのキーワード引数は、このワークフローのリーダーから抽出されます |
on_flow_failure: :undo | ステップの補償処理です。後続のステップが失敗した場合、逆順に実行されます |
external_step :name | ワークフローが開始したトランザクション内で Spree::Workflow::ContractError を発生させる、ネットワーク/ゲートウェイ I/O です |
run_hooks :name | 宣言済みのフックをディスパッチし、マージされた Hash(コンテキストフック)を返します |
failure(value, error) | どこからでも処理を中断します(内部で例外を発生させるため、開いているトランザクションはロールバックされます) |
workflow.reject! | errors を伴う、ハンドラー向けの failure 相当のメソッドです |
halt!(value) | 正常な早期終了です。ワークフローが開始したトランザクション内では使用できません |
hooks :a, :b / workflow_key 'x.y' | 拡張ポイントを宣言する/派生キーを上書きします |
ルール:ネットワーク呼び出しを DB トランザクション内で共有してはいけません(external_step がこれを強制します)。failure/halt! は Exception のサブクラスを発生させるため、#perform 内の通常の rescue では捕捉されません。また、rescue Exception は使用しないでください。#perform 内では、パラメーター名はリーダーをシャドーイングするローカル変数です。派生状態には別の名前を付けてください(入力は variant、出力は line_item)。
ステータス(ステートマシンの代わりに)
class MyStore::Subscription < Spree.base_class
include Spree::HasStatus
has_status :trialing, :active, :paused, :canceled, default: :trialing
end
# => inclusion validation, #paused?, .paused scope, .with_status(:active, :trialing)
Spree::GiftCard.add_status(:on_hold, after: :active) # extend a core model's statuses
遷移グラフ、イベント、コールバックはありません。レコードのステータスを変更する処理はワークフローです(subscription.pause! ではなく、MyStore::Subscriptions::Pause.call(...))。カスタムステータスへレコードを移行するには、独自のワークフローが必要です。コアワークフローがチェックするのはコアステータスであり、add_status はステータスの追加のみを行います。Spree の遷移も、ワークフロー経由で呼び出してください:Spree.product_archive_workflow.call(product: product)。
オブザーバビリティ
すべての実行、ステップ、フックのディスパッチで ActiveSupport::Notifications が通知されます。
| イベント | ペイロード |
|---|---|
perform.spree_workflow | workflow、outcome(success/failure/error) |
step.spree_workflow | workflow、step、external、outcome |
hooks.spree_workflow | workflow、hook、handler_count |
ActiveSupport::Notifications.subscribe('step.spree_workflow') do |*, payload|
Rails.logger.info("#{payload[:workflow]}##{payload[:step]} #{payload[:outcome]}")
end
spree_opentelemetry gem と OTEL_EXPORTER_OTLP_ENDPOINT を使用すると、これらは自動的にスパンになります。ワークフローの実行ごとに 1 つ、ステップごとに 1 つ(external_step は client スパン)、ハンドラーがあるフックのディスパッチごとに 1 つです。
テスト
ハンドラーは通常のクラスです。実際のワークフローインスタンスを使って単体テストし、ワークフロー経由で統合テストします。
RSpec.describe MyStore::CheckPurchaseLimit do
let(:cart) { create(:cart) }
let(:variant) { create(:variant) }
let(:handler) { 'MyStore::CheckPurchaseLimit' }
before { Spree.hooks.register('carts.add_item.validate', handler) }
after { Spree.hooks.unregister('carts.add_item.validate', handler) }
it 'rejects more than 10' do
result = Spree.cart_add_item_workflow.call(cart: cart, variant: variant, quantity: 11)
expect(result).to be_failure
expect(result.error.value.details[:quantity]).to include(a_hash_including(error: :purchase_limit_exceeded))
expect(cart.line_items.count).to eq(0)
end
end
- ハンドラーが
config/initializers/spree.rbに登録されている場合、スペックですでに有効になっています。before/afterは省略してください。 - アプリのスペックで
Spree.hooks.clear!を実行しないでください。プロセスの残りの期間、コア登録(返品期間のバリデーター)が完全に消去されます。登録したものだけを正確に登録解除してください。ブロックの場合は proc を保持します:h = ->(w) { ... }; Spree.hooks.register(key, &h)…Spree.hooks.unregister(key, h)。 - CI に起動時のチェックを追加します:
Rails.application.eager_load!; Spree.hooks.validate!。
注意点
- ワークフロークラスを差し替えると、フックキーが変わります。
MyApp::Carts::AddItem < Spree::Carts::AddItemはmy_app.carts.add_item.*をディスパッチするため、carts.add_item.*に登録されたすべてのハンドラー(自分のもの、拡張機能のもの、コアのもの)がひっそりと実行されなくなります。サブクラス化が必要な場合は、そのクラスでworkflow_key 'carts.add_item'を宣言してください。spree-dependenciesを参照してください。 orders.complete.*に登録すると、起動時(イーガーロード時)にUnknownHookErrorが発生します。Spree::Orders::Completeはフックを宣言していません。チェックアウトはcarts.completeを通ります。carts.recalculate_totalsにあるのはset_tax_line_contextだけです。プロモーションのコンテキストはcarts.recalculateにあります。- ジョブ、rakeタスク、コンソールから実行するワークフローにはストアのコンテキストが必要です。 先に
Spree::Current.store = cart.store(またはorder.store)を設定してください。Spree::Store.defaultは nil の場合があります。複数のストアのレコードを処理するジョブでは、それぞれをSpree::Current.with_store(record.store) { … }で囲みます(またはSpree::BaseJobのサブクラスでeach_store_of(relation)を使用します)。これにより、そのジョブに対して開発/テスト用のSpree::StoreScopeGuardも有効になり、自分が作成したハンドラー内のスコープ指定のないクエリも、APIリクエストの場合と同様に検出されます。 - 検証フックが参照するのは結果ではなく引数です。
workflow.line_item、workflow.order、workflow.fulfillmentは、それらを設定するステップが実行されるまで nil です。
詳細情報
node_modules/@spree/docs/dist/developer/customization/workflows.md(https://spreecommerce.org/docs/developer/customization/workflows)node_modules/@spree/docs/dist/developer/customization/validations.md— フックとモデルのバリデーション、バリデーター登録の違いnode_modules/@spree/docs/dist/developer/providers/observability.md— OpenTelemetry のセットアップ- ソース:
spree_coreのlib/spree/workflow.rb、lib/spree/hooks.rb、app/workflows/spree/**、app/models/concerns/spree/has_status.rb - 関連スキル:
spree-dependencies(クラスの置き換え)、spree-events-webhooks(事後の副作用)、spree-checkout(要件レジストリ)、spree-customization(ルーティング)