Spree Development Patterns
Before writing code
Fetch live docs:
- Fetch https://spreecommerce.org/docs/developer/customization/decorators for the modern decorator pattern.
- Inspect
lib/spree/dependencies.rbin the livespreegem for the current swappable-services registry. - Read the Spree blog's developer posts for current best practices: https://spreecommerce.org/blog.
- For Rails idioms, cross-reference the Rails 7+ guides.
- Check the v5.4 announcement's AGENTS.md mention — Spree ships AI-coding rules.
Pattern: The Customization Hierarchy
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.
Pattern: The Decorator Idiom
# 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:
- File ends with
_decorator.rb prepend, notinclude(sosuperworks)- Guard against double-prepend (the
unlessclause)
Pattern: 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
Pattern: Idempotent Subscribers
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.
Pattern: Multi-Store Scoping Discipline
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.
Pattern: Prefixed IDs (v5.4+)
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.
Pattern: Calculator Polymorphism
Spree's Calculator base class powers:
- Shipping cost (
ShippingMethod#calculator) - Promotion discounts (
PromotionAction#calculator) - Tax rates (
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
Pattern: Service-Object Composition
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.
Pattern: Avoid 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.
Pattern: Avoid Deface in v5
Deface was a CSS-selector view-override engine for the legacy ERB frontend. In v5:
- Deface only works on ERB views — and v5 admin is Hotwire/Turbo with partial slots instead
- Deface overrides are silently no-ops on missing virtual paths
- The Page Builder + slot system replace Deface in modern Spree
If you find yourself wanting Deface, ask:
- Can I use a partial slot? (Yes → use it)
- Can I customize via Page Builder section? (Yes → use it)
- Can I patch in the storefront repo? (Yes — for Next.js storefront customizations)
Pattern: Hotwire / Turbo / Stimulus
Spree v5 admin is Hotwire-native. Conventions:
- Turbo Frames for partial page updates (lazy-loaded panels)
- Turbo Streams for server-driven DOM updates (after an action)
- Stimulus controllers for client-side interactivity
// 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>
Pattern: Sidekiq for Anything Non-Trivial
Anything that:
- Hits an external API
- Sends an email
- Processes an image
- Updates >100 records
- Could take >100ms
…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)