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.
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 thespree-projectskill —bin/rails/bundle exec rakefrom the app root, paths without thebackend/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:
Spree::ProductsController→app/controllers/spree/products_controller_decorator.rbSpree::Admin::ProductsController→app/controllers/spree/admin/products_controller_decorator.rbSpree::Api::V3::Store::ProductsController→app/controllers/spree/api/v3/store/products_controller_decorator.rb
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
- You want a whole new model + API endpoint → use the
spree:api_resourcegenerator. See thespree-resourceskill. - You want to swap how a service computes → use
Spree.dependencies. See thespree-dependenciesskill. - You want to react to a Spree event → write a subscriber. See the
spree-events-webhooksskill. - You want to customize the admin UI → use the admin partial / slot system. See the
spree-adminskill. - You want a custom payment gateway → subclass
Spree::PaymentMethodand register it withSpree.payment_methods << MyGateway. See thespree-paymentsskill. - You want to override admin tables or navigation → use the admin extension APIs (
Spree.admin.tables, the navigation registry). See thespree-adminskill.
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
- Decorator docs:
node_modules/@spree/docs/dist/developer/customization/decorators.md(also at https://spreecommerce.org/docs/developer/customization/decorators) - Extending models tutorial:
node_modules/@spree/docs/dist/developer/tutorial/extending-models.md— the canonical brand-on-product walkthrough - Events (for behavioral customizations): the
spree-events-webhooksskill - Dependencies (for swappable services): the
spree-dependenciesskill - API resource generator (for whole new models): the
spree-resourceskill