Spree Extensions & Decorators
Before writing code
Fetch live docs:
- Fetch https://spreecommerce.org/docs/developer/customization/decorators for the modern
prependpattern. - Fetch https://spreecommerce.org/docs/developer/customization/v4/deface to confirm Deface deprecation status.
- Inspect any official Spree extension gem (
spree_stripe,spree_klaviyo) for current engine scaffolding. - Check the v5.2 announcement for the generator suite added then.
- Verify the current
Spree::Dependenciesregistry by readinglib/spree/dependencies.rbin the live gem.
Conceptual Architecture
What an Extension Is
A Spree extension is a Rails engine packaged as a gem. It can ship:
- Models / migrations / decorators
- Controllers / routes
- Views / assets / JavaScript
- Subscribers / job classes
- Initializers wiring into
Spree::Dependenciesand event bus - Admin navigation entries
Used the same way as spree_stripe, spree_klaviyo, spree_paypal_checkout.
Four Customization Surfaces (in preference order)
- Events + Webhooks — react to
Spree::Eventpublications viaSpree::Subscriberor external HTTP receivers. Stable across upgrades. - Dependencies — swap services via
Spree::Dependencies.foo_service = MyService. Spree publishes a well-defined registry of replaceable services. - Admin Navigation + Partials — declarative
Spree.admin.navigationAPI + partial injection slots. No code modification. - Decorators (last resort) —
prependto extend a core class. Most fragile; ties you to internals.
Reach for the highest-numbered tool only when lower-numbered tools can't express what you need.
Generating a New Extension
gem install spree_cmd # or use the bundled generator
bin/rails g spree:extension spree_my_extension
(Verify the current generator name and flags — spree_cmd may have been replaced by the in-spree generator suite added in v5.2.)
Produces:
spree_my_extension/
├── app/
│ ├── controllers/
│ ├── models/
│ ├── views/
│ ├── subscribers/ # event subscribers
│ ├── decorators/ # _decorator.rb files
│ └── assets/
├── config/
│ ├── routes.rb
│ ├── locales/
│ └── initializers/spree_my_extension.rb
├── db/migrate/ # migrations
├── lib/
│ ├── spree_my_extension/
│ │ ├── engine.rb
│ │ └── version.rb
│ └── spree_my_extension.rb
├── spec/ # RSpec test suite
├── spree_my_extension.gemspec
└── Gemfile
The Engine File
# lib/spree_my_extension/engine.rb
module SpreeMyExtension
class Engine < ::Rails::Engine
require 'spree/core'
isolate_namespace SpreeMyExtension if defined?(SpreeMyExtension)
engine_name 'spree_my_extension'
initializer 'spree_my_extension.environment', before: :load_config_initializers do |app|
# Boot-time wiring (registries, dependencies)
end
def self.activate
cache_klasses = "#{config.root}/app/**/*_decorator*.rb"
Dir.glob(cache_klasses) do |c|
Rails.configuration.cache_classes ? require(c) : load(c)
end
end
config.to_prepare(&method(:activate).to_proc)
end
end
The to_prepare hook ensures decorators reload in development.
The Decorator Pattern
Modern Spree decorators use Module#prepend, not class_eval reopening. File naming convention: app/models/spree/product_decorator.rb (suffix _decorator.rb).
# app/models/spree/product_decorator.rb
module SpreeMyExtension::ProductDecorator
def self.prepended(base)
base.has_many :promotional_videos, class_name: 'SpreeMyExtension::PromotionalVideo'
base.validates :seo_title, length: { maximum: 70 }, allow_nil: true
base.scope :featured, -> { where(featured: true) }
end
def display_name
seo_title.presence || super # `super` walks up the prepend chain
end
end
Spree::Product.prepend(SpreeMyExtension::ProductDecorator) unless Spree::Product.include?(SpreeMyExtension::ProductDecorator)
Why prepend not include: prepend inserts the module above the class in the method lookup chain — super walks back to the original implementation. This is what allows overriding methods.
Decorator Generators (v5.2+)
bin/rails g spree:model_decorator Spree::Product
bin/rails g spree:controller_decorator Spree::Admin::ProductsController
Generates the boilerplate file with the right naming convention.
Spree::Dependencies — Swappable Services
# config/initializers/spree.rb
Spree::Dependencies.cart_add_item_service = MyApp::CartAddItemService
Spree::Dependencies.shipping_rate_estimator = MyApp::CustomEstimator
Spree::Dependencies.order_updater_class = MyApp::OrderUpdater
Common replaceable services (verify against lib/spree/dependencies.rb):
cart_add_item_service,cart_remove_item_service,cart_update_serviceorder_updater_class,order_recalculator,order_cancellershipping_rate_estimator,stock_splitter,package_factorytax_calculatorpayment_create_service,payment_capture_servicepricing_options_factory
Always implement the same public contract as the service you replace.
When to Decorate vs. Use Dependencies
| Need | Approach |
|---|---|
| Add a column to Product | Migration (or Metafield, no decorator) |
| Add a validation to Product | Decorator |
| Add a method to Product | Decorator |
| Change cart-add behavior globally | Spree::Dependencies.cart_add_item_service = ... |
| React to an order completing | Subscriber |
| Change how shipping rates are computed | Replace shipping_rate_estimator |
| Add an admin menu item | Spree.admin.navigation.register(...) |
| Override an admin view | Partial injection slots; avoid Deface in v5 |
Deface (Deprecated in v5)
Deface used CSS-selector view overrides on ERB:
# DEPRECATED — only works on legacy v4 ERB frontend
Deface::Override.new(
virtual_path: 'spree/admin/products/index',
name: 'add_button',
insert_top: "table.product-list",
text: '<th>Custom Column</th>'
)
In v5+:
- Admin views: use partial injection slots (
store_products_nav_partials, etc.) - Storefront views: customize the Next.js storefront directly
Versioning Extensions
Pin the gemspec to Spree version ranges:
# spree_my_extension.gemspec
spec.add_dependency 'spree', '~> 5.4'
Test against multiple Spree minor versions in CI before releasing.
Implementation Guidance
Adding a Field to a Spree Model
First, try Metafields (no migration, upgrade-safe):
product.metafields.create!(namespace: 'my_app', key: 'editor_pick', value: 'true')
If you need indexed / queryable data, write a migration:
# db/migrate/20260512_add_editor_pick_to_spree_products.rb
class AddEditorPickToSpreeProducts < ActiveRecord::Migration[7.1]
def change
add_column :spree_products, :editor_pick, :boolean, default: false, null: false
add_index :spree_products, :editor_pick
end
end
Plus a thin decorator if you need scopes/validations:
module MyApp::ProductDecorator
def self.prepended(base)
base.scope :editor_picks, -> { where(editor_pick: true) }
end
end
Spree::Product.prepend(MyApp::ProductDecorator)
Replacing a Service Object
# app/services/my_app/cart_add_item_service.rb
module MyApp
class CartAddItemService < Spree::Cart::AddItem
def call(order:, variant:, quantity: 1, options: {})
# custom logic
result = super
# post-processing
result
end
end
end
# config/initializers/spree.rb
Spree::Dependencies.cart_add_item_service = MyApp::CartAddItemService
Always extend rather than rewrite — call super to preserve core behavior.
Avoiding Common Decorator Pitfalls
- Don't decorate to call observers — emit/subscribe to events instead.
- Don't decorate controllers to add filters — use Rails middleware or before_action in a sibling controller.