docs/developer/customization/decorators.mdx
Before reaching for a decorator, check if your use case is better served by a modern alternative:
| Use Case | Instead of Decorator | Use This |
|---|---|---|
| After-save hooks (sync to external service) | Model decorator with after_save | Events subscriber |
| Notify external service on changes | Model decorator with callbacks | Webhooks |
| Custom add-to-cart logic | Service decorator | Dependencies injection |
| Custom API responses | Serializer decorator | Dependencies injection |
| Add admin menu item | Controller decorator | Admin Navigation API |
| Add section to admin form | View decorator/override | Admin Partials injection |
| Add searchable/filterable field | Model decorator with ransackable_attributes | Ransack configuration |
| Add association to core model | - | Decorator (still appropriate) |
| Add validation to core model | - | Decorator (still appropriate) |
| Add new method to core model | - | Decorator (still appropriate) |
All of Spree's models, controllers, helpers, etc can easily be extended or overridden to meet your exact requirements using standard Ruby idioms.
Standard practice for including such changes in your application or extension is to create a file within the relevant app/models/spree or app/controllers/spree directory with the original class name with _decorator appended.
When working with Spree, you'll often need to add functionality to existing models like Spree::Product or Spree::Order. However, you shouldn't modify these files directly because:
Instead, we use decorators - a Ruby pattern that lets you add or modify behavior of existing classes without changing their original source code.
In Ruby, classes are "open" - you can add methods to them at any time. Decorators leverage this by:
Module#prepend to inject your module into the class's inheritance chainsuper to invoke the original method# This is the basic pattern
module Spree
module ProductDecorator
# Add a new method
def my_new_method
"Hello from decorator!"
end
# Override an existing method
def existing_method
# Do something before
result = super # Call the original method
# Do something after
result
end
end
Product.prepend(ProductDecorator)
end
The key line is Product.prepend(ProductDecorator) - this inserts your module at the beginning of the method lookup chain, so your methods are found first.
Spree provides generators to create decorator files with the correct structure:
bin/rails g spree:model_decorator Spree::Product
This creates app/models/spree/product_decorator.rb:
module Spree
module ProductDecorator
def self.prepended(base)
# Class-level configurations go here
end
end
Product.prepend(ProductDecorator)
end
bin/rails g spree:controller_decorator Spree::Admin::ProductsController
This creates app/controllers/spree/admin/products_controller_decorator.rb:
module Spree
module Admin
module ProductsControllerDecorator
def self.prepended(base)
# Class-level configurations go here
end
end
ProductsController.prepend(ProductsControllerDecorator)
end
end
The most common use case is changing the behavior of existing methods. When overriding a method, you can call super to invoke the original implementation:
module Spree
module ProductDecorator
def available?
# Add custom logic before
return false if discontinued?
# Call the original method
super
end
end
Product.prepend(ProductDecorator)
end
Add new instance methods directly in the decorator module:
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
Use the self.prepended(base) callback to add associations:
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
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
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) }
base.scope :on_sale, -> { joins(:variants).where('spree_prices.compare_at_amount > spree_prices.amount') }
end
end
Product.prepend(ProductDecorator)
end
Use extend within the prepended callback to add class methods:
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')
Instead of decorating Spree::ProductsController to add a new action, create your own controller:
module Spree
class ProductQuickViewsController < StoreController
def show
@product = current_store.products.friendly.find(params[:product_id])
render partial: 'spree/products/quick_view', locals: { product: @product }
end
end
end
Spree::Core::Engine.add_routes do
get 'products/:product_id/quick_view', to: 'product_quick_views#show', as: :product_quick_view
end
This approach:
ProductsControllerIf you must add an action to an existing controller:
module Spree
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
Don't forget to add the route:
Spree::Core::Engine.add_routes do
get 'products/:id/quick_view', to: 'products#quick_view', as: :product_quick_view
end
module Spree
module Admin
module ProductsControllerDecorator
def create
# Add custom logic before
log_product_creation_attempt
# Call original method
super
# Add custom logic after
notify_team_of_new_product if @product.persisted?
end
private
def log_product_creation_attempt
Rails.logger.info "Product creation attempted by #{current_spree_user.email}"
end
def notify_team_of_new_product
ProductNotificationJob.perform_later(@product)
end
end
ProductsController.prepend(ProductsControllerDecorator)
end
end
module Spree
module CheckoutControllerDecorator
def self.prepended(base)
base.before_action :check_minimum_order, only: [:update]
end
private
def check_minimum_order
if @order.total < 25.0 && params[:state] == 'payment'
flash[:error] = 'Minimum order amount is $25'
redirect_to checkout_state_path(@order.state)
end
end
end
CheckoutController.prepend(CheckoutControllerDecorator)
end
If you have many customizations for a single class, consider splitting them into focused decorators:
app/models/spree/
├── product_decorator.rb # Main decorator (loads others)
├── product/
│ ├── brand_decorator.rb # Brand association
│ ├── inventory_decorator.rb # Inventory customizations
│ └── seo_decorator.rb # SEO-related methods
# Load focused decorators
require_dependency 'spree/product/brand_decorator'
require_dependency 'spree/product/inventory_decorator'
require_dependency 'spree/product/seo_decorator'
# ❌ Bad - completely replaces original behavior
def available?
in_stock? && active?
end
# ✅ Good - extends original behavior
def available?
super && custom_availability_check
end
# ❌ Bad - instance variables don't work in prepended
def self.prepended(base)
@custom_setting = true # This won't work as expected
end
# ✅ Good - use class attributes or methods
def self.prepended(base)
base.class_attribute :custom_setting, default: true
end
Be careful when decorators depend on each other:
# ❌ Bad - can cause loading issues
# product_decorator.rb
def self.prepended(base)
base.has_many :variants # Variant decorator might not be loaded yet
end
# ✅ Good - use strings for class names
def self.prepended(base)
base.has_many :variants, class_name: 'Spree::Variant'
end
If you have existing decorators that use callbacks for side effects, consider migrating them to Events subscribers for better maintainability.
Before (Decorator with callback):
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 (Events subscriber):
module MyApp
class ProductSyncSubscriber < Spree::Subscriber
subscribes_to 'product.updated'
def handle(event)
product = Spree::Product.find_by(id: event.payload['id'])
return unless product
# The payload includes changes, check if name changed
if event.payload['previous_changes']&.key?('name')
ExternalSyncJob.perform_later(product)
end
end
end
end