# 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: `spree/spree-decorators` (Agent Skill)
- Install (CLI): `npx skillmds@latest add spree/spree-decorators`
- Raw SKILL.md: https://api.skillmd.com/api/skills/spree/spree-decorators/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- Author: spree (https://skillmd.com/u/spree)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/spree/spree-decorators

---


# 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](https://spreecommerce.org/docs/developer/core-concepts/events) — 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.

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

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

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

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

```ruby
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.rb`
- `Spree::Admin::ProductsController` → `app/controllers/spree/admin/products_controller_decorator.rb`
- `Spree::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):

```bash
bin/rails g migration AddBrandIdToSpreeProducts brand_id:bigint:index
```

```ruby
class AddBrandIdToSpreeProducts < ActiveRecord::Migration[7.2]
  def change
    add_column :spree_products, :brand_id, :bigint
    add_index :spree_products, :brand_id
  end
end
```

Then the decorator:

```ruby
# 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, `extend`s) **always go inside `self.prepended(base)`** and are called on `base`. Instance methods go at module level.

### Add a validation

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

```ruby
module Spree
  module ProductDecorator
    def self.prepended(base)
      base.scope :featured, -> { where("public_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.

Note the SQL string names the real jsonb column: `public_metadata` or `private_metadata`. In Ruby, `metadata` is an alias method for `private_metadata` — but there is no `metadata` **column**, so `where("metadata->>…")` raises `PG::UndefinedColumn`. (For anything the storefront filters on, a real boolean column beats metadata anyway.)

### Add a new instance method

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  def handle(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:

```ruby
# 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_resource` generator. See the `spree-resource` skill.
- **You want to swap how a service computes** → use `Spree.dependencies`. See the `spree-dependencies` skill.
- **You want to react to a Spree event** → write a subscriber. See the `spree-events-webhooks` skill.
- **You want to customize the admin UI** → use the admin partial / slot system. See the `spree-admin` skill.
- **You want a custom payment gateway** → subclass `Spree::PaymentMethod` and register it with `Spree.payment_methods << MyGateway`. See the `spree-payments` skill.
- **You want to override admin tables or navigation** → use the admin extension APIs (`Spree.admin.tables`, the navigation registry). See the `spree-admin` skill.

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-webhooks` skill
- **Dependencies** (for swappable services): the `spree-dependencies` skill
- **API resource generator** (for whole new models): the `spree-resource` skill

