dist/codex/spree-commerce/skills/spree-dev-patterns/SKILL.md
Cross-cutting Spree development patterns — the customization preference hierarchy (Events > Webhooks > Dependencies > Decorators), `Spree::Dependencies` service-object swapping, the `_decorator.rb` + `prepend` + `self.prepended` idiom, idempotent subscribers and webhook receivers, multi-store scoping discipline, prefixed IDs, calculator polymorphism (shipping/promotion/tax share the base), service-object composition with `dry-monads` or simple results, why to avoid `class_eval` reopening and Deface, and Spree-on-Rails idioms (Hotwire/Turbo Stimulus, ActiveStorage, Action Cable, Sidekiq). Use when designing the architecture of a Spree extension or solving cross-cutting concerns.
npx skillsauth add orcaqubits/agentic-commerce-claude-plugins spree-dev-patternsInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Fetch live docs:
lib/spree/dependencies.rb in the live spree gem for the current swappable-services registry.Always reach for the lowest-numbered tool that solves your problem:
| Priority | Tool | When |
|----------|------|------|
| 1 | Event Subscriber | React to a domain change asynchronously |
| 2 | Webhook | Notify an external system |
| 3 | Spree::Dependencies swap | Change a service object's behavior |
| 4 | Admin Navigation + Partials | Add UI to admin |
| 5 | Decorator (prepend) | Last resort for model/controller customization |
Higher numbers tie you tighter to Spree internals and break more often on upgrade.
# app/models/spree/product_decorator.rb
module MyApp::ProductDecorator
def self.prepended(base)
# Class-level additions go here
base.has_many :reviews, class_name: 'MyApp::Review'
base.validates :seo_title, length: { maximum: 70 }, allow_nil: true
base.scope :featured, -> { where(featured: true) }
end
# Instance-method overrides — call `super` to preserve core behavior
def display_name
seo_title.presence || super
end
end
Spree::Product.prepend(MyApp::ProductDecorator) unless Spree::Product.include?(MyApp::ProductDecorator)
Three things to never forget:
_decorator.rbprepend, not include (so super works)unless clause)Spree::Dependencies Service Swapping# config/initializers/spree.rb
Spree::Dependencies.cart_add_item_service = MyApp::CartAddItemService
Spree::Dependencies.shipping_rate_estimator = MyApp::CustomEstimator
Spree::Dependencies.order_updater_class = MyApp::OrderUpdater
Your service must implement the same public contract as the one it replaces. Extend rather than rewrite:
class MyApp::CartAddItemService < Spree::Cart::AddItem
def call(order:, variant:, quantity: 1, options: {})
result = super
apply_custom_logic(result, options)
result
end
end
Events fire at least once in some failure modes (process restart mid-publish, retry). Make handlers idempotent:
class OrderCompletedSubscriber < Spree::Subscriber
subscribes_to 'order.completed'
on 'order.completed', :handle
def handle(event)
order = event.order
# Idempotency key: order ID + state transition
return if AccountingSync.where(order_id: order.id).exists?
AccountingSync.create!(order: order, synced_at: Time.current)
AccountingApiClient.push(order)
end
end
For webhook receivers, use the event's unique ID + a processed_events table.
Every customer-facing query should scope by store:
# Bad
Spree::Product.active.featured
# Good
current_store.products.active.featured
# In a service / job, pass store explicitly
class MyApp::Service
def initialize(store:)
@store = store
end
def call
@store.orders.complete
end
end
Code reviews should flag any query that uses a bare Spree::Order.… or Spree::Product.… in customer-facing code.
API v3 exposes prefixed IDs (prod_…, ord_…). Don't expose raw DB IDs to external clients. The model gives you both:
order.id # 12345 (internal database ID)
order.prefixed_id # "ord_01HXVZK..."
Treat prefixed IDs as opaque strings — sortable but otherwise meaningless to consumers.
Spree's Calculator base class powers:
ShippingMethod#calculator)PromotionAction#calculator)TaxRate#calculator)class MyApp::Calculator::PercentOver100 < Spree::Calculator
preference :percent, :decimal, default: 10
def self.description
'Percent off when cart exceeds 100'
end
def compute(object)
return 0 if object.amount < 100
object.amount * (preferred_percent / 100.0) * -1
end
end
Register where appropriate:
Rails.application.config.spree.calculators.promotion_actions.create_adjustment << MyApp::Calculator::PercentOver100
Spree's service objects return either the result or raise. Common pattern:
class MyApp::OrderProcessor
def initialize(order:)
@order = order
end
def call
enrich_metadata
notify_subscribers
@order
end
private
attr_reader :order
def enrich_metadata
order.metafields.find_or_create_by(namespace: 'my_app', key: 'processed_at') do |m|
m.value = Time.current.iso8601
end
end
def notify_subscribers
Spree::Bus.publish('my_app.order_processed', order: order)
end
end
For functional-style result handling, integrate dry-monads:
class MyApp::OrderProcessor
include Dry::Monads[:result]
def call(order:)
enriched = enrich_metadata(order)
return Failure(:enrichment_failed) if enriched.nil?
notify(order)
Success(order)
end
end
Use whichever style your team is consistent on.
class_eval Reopening# BAD
Spree::Product.class_eval do
def display_name
seo_title.presence || name
end
end
This breaks autoloading in development and has no override semantics for super. Use a decorator module + prepend instead.
Deface was a CSS-selector view-override engine for the legacy ERB frontend. In v5:
If you find yourself wanting Deface, ask:
Spree v5 admin is Hotwire-native. Conventions:
// app/javascript/controllers/order_quick_actions_controller.js
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static targets = ['button'];
async refund(event) {
event.preventDefault();
const response = await fetch(this.buttonTarget.dataset.url, { method: 'POST', headers: this.headers() });
if (response.ok) this.buttonTarget.disabled = true;
}
headers() {
return {
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content,
'Accept': 'text/vnd.turbo-stream.html'
};
}
}
<div data-controller="order-quick-actions">
<%= button_to 'Refund', refund_order_path(order),
method: :post,
data: { 'order-quick-actions-target': 'button', action: 'order-quick-actions#refund' } %>
</div>
Anything that:
…belongs in a Sidekiq job, not a controller action.
class MyApp::SyncToErpJob < ApplicationJob
queue_as :default
def perform(order_id)
order = Spree::Order.find(order_id)
ErpClient.upsert(order)
end
end
# Enqueue from a subscriber
MyApp::SyncToErpJob.perform_later(order.id)
Never edit vendor/bundle/.../spree/.... Two reasons:
Use decorators, dependencies, subscribers, and slots — that's why they exist.
# lib/spree_my_extension/version.rb
module SpreeMyExtension
VERSION = '1.2.3'
end
Tag releases, pin to Spree minor in gemspec:
# spree_my_extension.gemspec
spec.add_dependency 'spree', '>= 5.4', '< 6.0'
Test against multiple Spree minors in CI.
v5.4 ships an AGENTS.md at the repo root — AI-coding rules for tools like Claude Code and Cursor. Read it when you adopt a new Spree version; it codifies the customization hierarchy and code-style conventions.
class_eval → use prepend decoratorSpree::OrderUpdaterSpree::Dependencies in favor of decorators → tight couplingspree_auth_devise to a new v5 project → archived gemAlways read AGENTS.md (v5.4+) and the latest customization docs before designing a non-trivial extension. The patterns evolve; what was idiomatic in v4 is wrong in v5.
development
Build with Spree's headless Next.js storefront — the official `spree/storefront` repo (Next.js 16 App Router with Server Actions and Turbopack, React 19 Server Components, Tailwind CSS 4, TypeScript 5, `@spree/sdk`, Sentry), server-only auth (httpOnly JWT cookies + publishable key), MeiliSearch faceted catalog, one-page checkout with Apple/Google Pay/Klarna/Affirm/SEPA, multi-region market routing, GA4 + JSON-LD SEO, and Vercel/Docker deployment. Use when forking or customizing the storefront, or evaluating headless adoption.
tools
Build Spree extensions as Rails engines — gem scaffolding, `bin/rails g spree:extension`, mounting routes/migrations/assets, the modern `prepend` decorator pattern (`*_decorator.rb` with `self.prepended(base)`), generators (`spree:model_decorator`, `spree:controller_decorator`), the four customization surfaces in preference order (Events > Webhooks > Dependencies > Decorators), Spree::Dependencies for swapping service objects, gem release/versioning, and the deprecated Deface engine. Use when building a reusable Spree extension or adding non-trivial customization to an app.
development
Build with Spree's event bus and Webhooks 2.0 — `Spree::Events` publication, `Spree::Subscriber` DSL with `subscribes_to` and `on`, wildcard matching, lifecycle events (`{model}.created/.updated/.deleted` via `publishes_lifecycle_events`), the canonical event catalog (order.*, payment.*, shipment.*, product.*), Webhooks 2.0 endpoints, HMAC-SHA256 signing (`X-Spree-Webhook-Signature`), exponential-backoff retries, and Sidekiq job orchestration. Use when wiring event-driven business logic, building webhook consumers, or replacing ActiveSupport callback chains.
development
Deploy Spree to production — PostgreSQL + Redis + Sidekiq stack, Docker multi-arch images on GHCR, the `spree-starter` Dockerfile + Compose, Heroku/Render/Fly.io/AWS targets, env-var conventions, RAILS_MASTER_KEY, asset precompilation (Tailwind 4 + Propshaft), Action Cable, MeiliSearch indexing, S3 / ActiveStorage for media, log/observability setup, zero-downtime deploys, and migration strategy. Use when going from local dev to production, scaling Spree, or troubleshooting deploys.