Skip to main content
Decorators should be a last resort. They tightly couple your code to Spree internals and can break during upgrades. Before using decorators, consider these modern alternatives that are safer and easier to maintain:

When to Use Decorators vs Modern Alternatives

Before reaching for a decorator, check if your use case is better served by a modern alternative:
Use CaseInstead of DecoratorUse This
After-save hooks (sync to external service)Model decorator with after_saveEvents subscriber
Notify external service on changesModel decorator with callbacksWebhooks
Custom add-to-cart logicService decoratorDependencies injection
Custom API responsesSerializer decoratorDependencies injection
Add admin menu itemController decoratorAdmin Navigation API
Add section to admin formView decorator/overrideAdmin Partials injection
Add searchable/filterable fieldModel decorator with ransackable_attributesRansack 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)
Decorators are still appropriate for structural changes like adding associations, validations, scopes, and new methods to models. Use modern alternatives for behavioral changes like callbacks, hooks, and side effects.

Overview

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.

Why Use Decorators?

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:
  1. Upgrades - Your changes would be lost when updating Spree
  2. Maintainability - It’s hard to track what you’ve customized
  3. Conflicts - Direct modifications can conflict with Spree’s code
Instead, we use decorators - a Ruby pattern that lets you add or modify behavior of existing classes without changing their original source code.

How Decorators Work

In Ruby, classes are “open” - you can add methods to them at any time. Decorators leverage this by:
  1. Creating a module with your new methods
  2. Using Module#prepend to inject your module into the class’s inheritance chain
  3. Your methods run first, and can call super 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.

Generating Decorators

Spree provides generators to create decorator files with the correct structure:

Model Decorator Generator

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

Controller Decorator Generator

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

Decorating Models

Changing Behavior of Existing Methods

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:
app/models/spree/product_decorator.rb
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
Always consider whether you need to call super when overriding methods. Omitting it completely replaces the original behavior, which may break functionality.

Adding New Methods

Add new instance methods directly in the decorator module:
app/models/spree/product_decorator.rb
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

Adding Associations

Use the self.prepended(base) callback to add associations:
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

Adding Validations

app/models/spree/product_decorator.rb
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

Adding Scopes

app/models/spree/product_decorator.rb
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

Adding Class Methods

Use extend within the prepended callback to add class methods:
app/models/spree/product_decorator.rb
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')

Decorating Controllers

Consider creating a new controller instead of decorating. Creating your own controller that inherits from Spree’s base controllers is more maintainable and less likely to break during upgrades. Use controller decorators only when you must modify existing Spree actions.
Instead of decorating Spree::ProductsController to add a new action, create your own controller:
app/controllers/spree/product_quick_views_controller.rb
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
config/routes.rb
Spree::Core::Engine.add_routes do
  get 'products/:product_id/quick_view', to: 'product_quick_views#show', as: :product_quick_view
end
This approach:
  • Won’t break when Spree updates ProductsController
  • Is easier to test in isolation
  • Makes your customizations clearly visible in your codebase

Adding a New Action via Decorator

If you must add an action to an existing controller:
app/controllers/spree/products_controller_decorator.rb
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:
config/routes.rb
Spree::Core::Engine.add_routes do
  get 'products/:id/quick_view', to: 'products#quick_view', as: :product_quick_view
end

Modifying Existing Actions

High risk of breaking during upgrades. When you override an existing Spree action, your code depends on Spree’s internal implementation details. If Spree changes the action’s behavior, instance variables, or method signatures in a future version, your decorator may silently break or cause unexpected bugs. Use Events for post-action side effects instead.
app/controllers/spree/admin/products_controller_decorator.rb
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
Better alternative: For post-action side effects like notifications, use Events subscribers instead. Subscribe to product.created to be notified when products are created, without coupling to controller internals.

Adding Before Actions

app/controllers/spree/checkout_controller_decorator.rb
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

Best Practices

Use the prepended callback

Always use self.prepended(base) for class-level additions like associations, validations, scopes, and callbacks.

Keep decorators focused

Each decorator should have a single responsibility. Create multiple decorators for different concerns if needed.

Call super when overriding

When overriding methods, call super to preserve original behavior unless you intentionally want to replace it entirely.

Test decorated behavior

Write tests specifically for your decorated functionality to catch regressions during upgrades.

Organizing Multiple Decorators

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
app/models/spree/product_decorator.rb
# Load focused decorators
require_dependency 'spree/product/brand_decorator'
require_dependency 'spree/product/inventory_decorator'
require_dependency 'spree/product/seo_decorator'

Common Pitfalls

Forgetting to Call Super

# ❌ Bad - completely replaces original behavior
def available?
  in_stock? && active?
end

# ✅ Good - extends original behavior
def available?
  super && custom_availability_check
end

Using Instance Variables in prepended

# ❌ 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

Circular Dependencies

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

Migrating from Decorators to Modern Patterns

If you have existing decorators that use callbacks for side effects, consider migrating them to Events subscribers for better maintainability.

Example: Migrating an After-Save Callback

Before (Decorator with callback):
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 (Events subscriber):
app/subscribers/my_app/product_sync_subscriber.rb
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

Benefits of Migration

Loose coupling

Your code doesn’t depend on Spree internals. Events provide a stable interface.

Easier upgrades

Events-based code is less likely to break when Spree is updated.

Better testability

Subscribers can be tested in isolation without loading the full model.

Async by default

Subscribers run via ActiveJob, keeping your requests fast.