Agent Skill · Spree Commerce

spree-decorators

Use when the user wants to extend a Spree model, controller, helper, or service class without forking — add an association to Spree::Product, add a method to Spree::Order, override a validation, add a scope, prepend a before_action, hook into create. Common phrasings include "add brand to product", "decorate Spree::X", "ProductDecorator", "OrderDecorator", "Module#prepend", "spree:model_decorator", "extend an existing Spree model", "add a method to Spree::Order", "override Spree behavior", "monkey patch Spree". Provides the decorator pattern, the generator, the prepended(base) idiom, and the gotchas. Mentions when NOT to decorate — events for after-save side effects, dependencies for service swaps, the resource generator for whole new models.

Provider: Spree Commerce Path in repo: skills/spree-decorators/SKILL.md

Skill body

Spree Decorators

Commands below use the Spree CLI form (spree …, Docker). On a classic Rails app without the CLI (typical pre-5.4), use the native mapping in the spree-project skill — bin/rails / bundle exec rake from the app root, paths without the backend/ prefix.

Decorators let you change existing Spree classes (models, controllers, helpers, services) from your own app without modifying gem source. They’re the standard Ruby Module#prepend pattern with a Spree filename convention and a generator.

Read the warning first

Decorators tightly couple your code to Spree internals. They will probably survive a minor upgrade and might survive a major one. The Spree docs are explicit: decorators are for structural changes (add an association, validation, scope, new method). For behavioral changes (callbacks, side effects, post-save sync), use a modern alternative instead.

Pick the right tool

Use case Use this instead of a decorator
React to a save / create / update / delete Events subscriber — see the spree-events-webhooks skill
Notify an external service when something changes Webhook or events subscriber
Swap how a service computes (cart add, tax, search, checkout) Spree.dependencies — see the spree-dependencies skill
Replace a serializer or ability Spree.dependencies
Add an admin menu item Admin navigation API — see the spree-admin skill
Add a section to an admin form Admin partial injection / slot — see the spree-admin skill
Add a searchable/filterable field Spree.ransack.add_attribute(Spree::Product, :brand_id) in an initializer (also add_association / add_scope) — no decorator needed
Add an association, validation, scope, or new method Decorator (this skill)

If your job is “react to product update by syncing to an ERP,” write a subscriber on product.updated, not a after_save callback in a decorator. The decorator path will break the next time Spree changes how Product saves.

The pattern in three lines

A decorator is just a Ruby module prepended to an existing Spree class. The file lives in your app at the same path Spree uses, with _decorator appended.

# app/models/spree/product_decorator.rb
module Spree
  module ProductDecorator
    # methods, prepended hook, etc.
  end

  Product.prepend(ProductDecorator)
end

Host-app decorators are loaded by an explicit glob, not by plain autoloading: spree-starter (and the spree:install generator) put a config.to_prepare block in config/application.rb that loads every app/**/*_decorator*.rb file, so the prepend line runs at boot and again after every code reload in development. If your app has neither (check config/application.rb for the block), add it — Zeitwerk alone will never load an unreferenced decorator module in development, and your decorators will silently not apply. Once loaded, your module enters the method-lookup chain ahead of Spree::Product’s own definitions: your methods are found first and can super to call the original.

Generate the file

Spree ships two generators — one for models, one for controllers. Use them — they produce the exact filenames, modules, and prepend lines the autoloader expects.

Models

spree generate model_decorator Spree::Product
# or, without the @spree/cli wrapper:
bin/rails g spree:model_decorator Spree::Product

The CLI auto-prefixes spree: for Spree generators (spree g model_decorator ... also works as a shorthand).

Output at app/models/spree/product_decorator.rb:

module Spree
  module ProductDecorator
    def self.prepended(base)
      # base.belongs_to :brand
    end

    # add custom methods here
  end
end

Spree::Product.prepend Spree::ProductDecorator

The argument accepts either Spree::Product or Product — the generator strips the prefix. Works for singular model names under Spree:: (the vast majority), including nested ones. But the generator runs the name through classify, which singularizes the last segment — for plural-named classes (e.g. Spree::Exports::Products, Spree::Promotion::Actions::CreateItemAdjustments) it emits a prepend against a non-existent singular constant and the file raises NameError on load. Write those decorators by hand instead.

Controllers

spree generate controller_decorator Spree::Admin::ProductsController
# or, without the @spree/cli wrapper:
bin/rails g spree:controller_decorator Spree::Admin::ProductsController

Output at app/controllers/spree/admin/products_controller_decorator.rb:

module Spree::Admin
  module ProductsControllerDecorator
    def self.prepended(base)
      # base.before_action :my_filter
    end

    # add custom methods here
  end
end

Spree::Admin::ProductsController.prepend Spree::Admin::ProductsControllerDecorator

The generator handles arbitrary namespace depth:

The final .prepend line is always fully qualified — no surprises about which constant is being decorated.

Model decorator patterns

Add an association

Run the migration first (no foreign key constraint — keep it Spree-style):

bin/rails g migration AddBrandIdToSpreeProducts brand_id:integer:index
class AddBrandIdToSpreeProducts < ActiveRecord::Migration[7.2]
  def change
    add_column :spree_products, :brand_id, :integer
    add_index :spree_products, :brand_id
  end
end

Then the decorator:

# app/models/spree/product_decorator.rb
module Spree
  module ProductDecorator
    def self.prepended(base)
      base.belongs_to :brand, class_name: 'Spree::Brand', optional: true
      base.has_many :videos, class_name: 'Spree::Video', dependent: :destroy
    end
  end

  Product.prepend(ProductDecorator)
end

Class-level additions (associations, validations, scopes, callbacks, extends) always go inside self.prepended(base) and are called on base. Instance methods go at module level.

Add a validation

module Spree
  module ProductDecorator
    def self.prepended(base)
      base.validates :external_id, presence: true, uniqueness: true
      base.validates :weight, numericality: { greater_than: 0 }, allow_nil: true
    end
  end

  Product.prepend(ProductDecorator)
end

Add a scope

module Spree
  module ProductDecorator
    def self.prepended(base)
      base.scope :featured, -> { where("metadata->>'featured' = ?", 'true') }
      base.scope :recently_added, -> { where('created_at > ?', 30.days.ago) }
    end
  end

  Product.prepend(ProductDecorator)
end

If you want this scope queryable from the API, also allowlist it via Ransack — see the Ransack note at the bottom.

Add a new instance method

module Spree
  module ProductDecorator
    def featured?
      metadata[:featured] == true
    end

    def days_until_available
      return 0 if available_on.nil? || available_on <= Time.current
      (available_on.to_date - Date.current).to_i
    end
  end

  Product.prepend(ProductDecorator)
end

Override an existing method (call super)

module Spree
  module ProductDecorator
    def available?
      return false if discontinued?
      super
    end
  end

  Product.prepend(ProductDecorator)
end

Always consider whether you need super. Omitting it replaces the original method entirely — which can silently break behavior the rest of Spree assumes is there.

Add class methods

Use extend from inside prepended:

module Spree
  module ProductDecorator
    def self.prepended(base)
      base.extend ClassMethods
    end

    module ClassMethods
      def search_by_name(query)
        where('LOWER(name) LIKE ?', "%#{query.downcase}%")
      end
    end
  end

  Product.prepend(ProductDecorator)
end

Usage: Spree::Product.search_by_name('shirt').

Make a new attribute available via Ransack

If you added an association or column and want it queryable from the API (?q[brand_id_eq]=...), allowlist it. The preferred, no-decorator way is Spree.ransack from an initializer:

# config/initializers/spree.rb
Spree.ransack.add_attribute(Spree::Product, :brand_id)
Spree.ransack.add_attribute(Spree::Product, :external_id)
Spree.ransack.add_association(Spree::Product, :brand)
Spree.ransack.add_scope(Spree::Product, :featured)

If you’re already inside a decorator (e.g. you just added the association there), appending to the model allowlists works too:

module Spree
  module ProductDecorator
    def self.prepended(base)
      base.whitelisted_ransackable_attributes += %w[brand_id external_id]
      base.whitelisted_ransackable_associations += %w[brand videos]
    end
  end

  Product.prepend(ProductDecorator)
end

Without this, filter params on the attribute are silently dropped — the API returns 200 as if that filter were never sent, not an error. See the spree-api-v3 skill for the full Ransack story.

Permit a new attribute on writes

If the new attribute should be settable via the admin or the API, register it in the permitted-attributes list:

# config/initializers/spree.rb
Rails.application.config.after_initialize do
  Spree::PermittedAttributes.product_attributes << :brand_id
end

This makes the attribute writable in the Rails admin, which builds its strong params from this list. It does not automatically reach every API v3 endpoint: the v3 base ResourceController defaults permitted_params to the matching Spree::PermittedAttributes list, but controllers that enumerate their own params.permit(...) — including Spree::Api::V3::Admin::ProductsController — ignore the global list. To accept the attribute on those endpoints, decorate the controller’s permitted_params (or contribute the attribute upstream).

Controller decorator patterns

First check whether you can avoid this. A new controller that inherits from a Spree base class is more upgrade-safe than a decorator on an existing controller. Controller decorators that override existing actions are the most fragile decorator type — they couple to instance variables and method signatures that can change between Spree minor releases.

Add a before_action

Note: Spree no longer ships a Rails storefront — Spree::CheckoutController / Spree::ProductsController only exist in legacy apps using the separate storefront gem. Modern storefronts are headless via the Store API, so the controllers you’ll decorate are the admin and API ones:

# app/controllers/spree/admin/products_controller_decorator.rb
module Spree::Admin
  module ProductsControllerDecorator
    def self.prepended(base)
      base.before_action :check_editable, only: [:update]
    end

    private

    def check_editable
      if params[:id].blank?
        flash[:error] = Spree.t(:not_found)
        redirect_to spree.admin_products_path
      end
    end
  end

  ProductsController.prepend(ProductsControllerDecorator)
end

Use bin/rails g spree:controller_decorator Spree::Admin::ProductsController (or Spree::Api::V3::Store::CartsController for API controllers) to scaffold the correct file path and prepend line — the generator handles arbitrary namespace depth. Note: API controller decorators must render JSON errors (e.g. render json: { error: ... }, status: :unprocessable_entity), not flash/redirect.

Add a new action

# app/controllers/spree/admin/products_controller_decorator.rb
module Spree::Admin
  module ProductsControllerDecorator
    def self.prepended(base)
      base.before_action :load_product, only: [:quick_view]
    end

    def quick_view
      respond_to do |format|
        format.html { render partial: 'quick_view', locals: { product: @product } }
        format.json { render json: @product }
      end
    end

    private

    def load_product
      @product = current_store.products.friendly.find(params[:id])
    end
  end

  ProductsController.prepend(ProductsControllerDecorator)
end

And the route — Spree controllers live in the engine, so the route must be added to the engine, not your app. Admin routes are drawn through the core engine inside the admin namespace:

# config/routes.rb
Spree::Core::Engine.add_routes do
  namespace :admin, path: Spree.admin_path do
    get 'products/:id/quick_view', to: 'products#quick_view', as: :product_quick_view
  end
end

Modifying an existing action

The most fragile decorator pattern. If you must:

module Spree
  module Admin
    module ProductsControllerDecorator
      def create
        log_product_creation_attempt
        super
        notify_team_of_new_product if @product.persisted?
      end

      private

      def log_product_creation_attempt
        Rails.logger.info "Product creation attempted by #{try_spree_current_user&.email}"
      end

      def notify_team_of_new_product
        ProductNotificationJob.perform_later(@product)
      end
    end

    ProductsController.prepend(ProductsControllerDecorator)
  end
end

The example above is also a case where the better answer is a subscriber on product.created — same outcome, no coupling to controller internals.

Common pitfalls

Forgot to call super

# ❌ Replaces all of Spree's availability logic — easy to silently break
def available?
  in_stock? && active?
end

# ✅ Extends, doesn't replace
def available?
  super && custom_availability_check
end

Instance variables in prepended

# ❌ Doesn't do what it looks like — @custom_setting lives on the decorator module, not on instances
def self.prepended(base)
  @custom_setting = true
end

# ✅ Use class_attribute when you want a setting on instances
def self.prepended(base)
  base.class_attribute :custom_setting, default: true
end

Circular dependencies via constant references

When decorators reference each other (or other Spree models that haven’t been loaded yet), constant lookups can fail at boot. Use string class names for association class_name: arguments:

# ❌ Variant might not be loaded yet at decorator boot
base.has_many :variants

# ✅ String form — resolved lazily
base.has_many :variants, class_name: 'Spree::Variant'

File path / module name mismatch

The autoloader is strict about names. Spree::ProductDecorator MUST live at app/models/spree/product_decorator.rb. The generator gets this right; if you hand-write the file, match it exactly.

Organizing multiple decorators

If you have many customizations on Spree::Product, splitting into focused modules is fine — group by concern:

app/models/spree/
├── product_decorator.rb           # Main file, prepends the others
├── product/
│   ├── brand_decorator.rb         # Brand association
│   ├── inventory_decorator.rb     # Inventory customizations
│   └── seo_decorator.rb           # SEO methods
# app/models/spree/product_decorator.rb
require_dependency 'spree/product/brand_decorator'
require_dependency 'spree/product/inventory_decorator'
require_dependency 'spree/product/seo_decorator'

This is purely organizational — each child file uses the same prepend pattern, just on smaller modules.

Migrating from decorators to modern patterns

If you inherited a decorator that uses after_save for side effects, migrate it to an Events subscriber. Same outcome, no coupling to model internals, won’t break when Spree changes how Spree::Product saves.

Before:

# app/models/spree/product_decorator.rb
module Spree
  module ProductDecorator
    def self.prepended(base)
      base.after_save :sync_to_external_service
    end

    private

    def sync_to_external_service
      ExternalSyncJob.perform_later(self) if saved_change_to_name?
    end
  end

  Product.prepend(ProductDecorator)
end

After:

# app/subscribers/product_sync_subscriber.rb
class ProductSyncSubscriber < Spree::Subscriber
  subscribes_to 'product.updated'

  def call(event)
    product = Spree::Product.find_by_prefix_id(event.payload['id'])
    return unless product

    ExternalSyncJob.perform_later(product)
  end
end

Subscribers are not auto-discovered from app/subscribers/ — only classes in the Spree.subscribers array get wired to the event registry (Spree’s engines add their built-in subscribers there; your app must add its own). Register yours in an initializer:

# config/initializers/spree.rb
Rails.application.config.after_initialize do
  Spree.subscribers << ProductSyncSubscriber
end

Async by default. Testable in isolation. See the spree-events-webhooks skill for the full event catalog and the subscriber API.

When NOT to use a decorator

The decorator is the last resort for structural changes the modern APIs don’t cover. When in doubt, check the table at the top of this skill — there’s a high chance the modern alternative exists.

Where to read further