Skip to content
Back to skills

Spree Dev Patterns

ASecurity

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 ...

  • 39 stars
  • 0 votes
  • 0 copies
  • 2 views
  • Added September 7, 2026
ai-agentsjavascriptgojavarubybashreactnextjsrailsapidatabase

Works with

  • claude code
  • cursor
  • cli
  • api

Security analysis

A100/100

Scanned September 7, 2026

npx -y skills add OrcaQubits/agentic-commerce-skills-plugins --skill spree-dev-patterns --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Spree Dev Patterns?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Spree Dev Patterns
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/orcaqubits-spree-dev-patterns-agentic-commerce-skills-plugin/badge)](https://www.skillsdirectory.com/skills/orcaqubits-spree-dev-patterns-agentic-commerce-skills-plugin)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: spree-dev-patterns
description: 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.
allowed-tools: Read, Write, Edit, Bash, Grep, Glob, WebSearch, WebFetch
---

# Spree Development Patterns

## Before writing code

**Fetch live docs**:
1. Fetch https://spreecommerce.org/docs/developer/customization/decorators for the modern decorator pattern.
2. Inspect `lib/spree/dependencies.rb` in the live `spree` gem for the current swappable-services registry.
3. Read the Spree blog's developer posts for current best practices: https://spreecommerce.org/blog.
4. For Rails idioms, cross-reference the Rails 7+ guides.
5. 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

```ruby
# 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`, not `include` (so `super` works)
- Guard against double-prepend (the `unless` clause)

## Pattern: `Spree::Dependencies` Service Swapping

```ruby
# 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:

```ruby
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:

```ruby
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:

```ruby
# 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:

```ruby
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`)

```ruby
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:

```ruby
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:

```ruby
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`:

```ruby
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

```ruby
# 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:
1. Can I use a partial slot? (Yes → use it)
2. Can I customize via Page Builder section? (Yes → use it)
3. 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

```javascript
// 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'
    };
  }
}
```

```erb
<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.

```ruby
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)
```

## Pattern: Don't Modify Core Files

Never edit `vendor/bundle/.../spree/...`. Two reasons:
1. Bundle install wipes your changes
2. Upgrades become impossible

Use decorators, dependencies, subscribers, and slots — that's why they exist.

## Pattern: Versioning Your Extension

```ruby
# lib/spree_my_extension/version.rb
module SpreeMyExtension
  VERSION = '1.2.3'
end
```

Tag releases, pin to Spree minor in gemspec:

```ruby
# spree_my_extension.gemspec
spec.add_dependency 'spree', '>= 5.4', '< 6.0'
```

Test against multiple Spree minors in CI.

## Pattern: Spree's AGENTS.md (v5.4+)

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.

## Anti-Pattern Roundup

- **Decorating to add a feature you could subscribe to** → use events
- **Class-reopening with `class_eval`** → use `prepend` decorator
- **Modifying Order totals manually** → use `Spree::OrderUpdater`
- **Storing API keys in browser code** → use httpOnly cookies + server actions
- **Skipping multi-store scoping** → leak attack
- **Ignoring `Spree::Dependencies` in favor of decorators** → tight coupling
- **Custom admin views instead of partial slots** → upgrade pain
- **Using Deface in v5** → silent no-op
- **Adding `spree_auth_devise` to a new v5 project** → archived gem
- **Building features in controllers instead of service objects** → untestable

---

Always 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.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…