Back to Spree

Extending Core Models

docs/developer/tutorial/extending-models.mdx

5.4.211.7 KB
Original Source

In this tutorial, we'll connect our custom Brand model with Spree's core Product model. This is a common pattern when building features that need to integrate with existing Spree functionality.

<Info> This guide assumes you've completed the [Model](/developer/tutorial/model), [Admin](/developer/tutorial/admin), and [File Uploads](/developer/tutorial/file-uploads) tutorials. </Info>

What We're Building

By the end of this tutorial, you'll have:

  • Products associated with Brands
  • A brand selector in the Product admin form
  • Understanding of how to safely extend Spree core models

Choosing the Right Approach

Before extending Spree models, consider which approach fits your needs best:

What you needRecommended approach
Add association (belongs_to, has_many)Decorator (this tutorial)
Add validation or scopeDecorator
Add new instance/class methodDecorator
React to model changes (after save, etc.)Events subscriber
Sync with external service on changesEvents or Webhooks
Add searchable/filterable fieldRansack configuration
Add admin UI elementsAdmin Partials
<Info> This tutorial uses **decorators** because we're adding a structural association between models. For behavioral changes like callbacks, prefer [Events](/developer/core-concepts/events) instead - they're easier to test and maintain. </Info>

Understanding 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
ruby
# 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.

Step 1: Create the Migration

First, add a brand_id column to the products table:

bash
bin/rails g migration AddBrandIdToSpreeProducts brand_id:integer:index

Edit the migration to add an index (but no foreign key constraint, keeping it optional):

ruby
class AddBrandIdToSpreeProducts < ActiveRecord::Migration[8.0]
  def change
    add_column :spree_products, :brand_id, :integer
    add_index :spree_products, :brand_id
  end
end

Run the migration:

bash
bin/rails db:migrate
<Info> We intentionally don't add a foreign key constraint. This keeps the association optional and avoids issues if brands are deleted. Spree follows this pattern for flexibility. </Info>

Step 2: Generate the Product Decorator

Spree provides a generator to create decorator files with the correct structure:

bash
bin/rails g spree:model_decorator Spree::Product

This creates app/models/spree/product_decorator.rb:

ruby
module Spree
  module ProductDecorator
    def self.prepended(base)
      # Class-level configurations go here
    end
  end

  Product.prepend(ProductDecorator)
end

Step 3: Add the Brand Association to Product

Update the decorator to add the belongs_to association:

ruby
module Spree
  module ProductDecorator
    def self.prepended(base)
      base.belongs_to :brand, class_name: 'Spree::Brand', optional: true
    end
  end

  Product.prepend(ProductDecorator)
end

Understanding the Code

  • self.prepended(base) - This callback runs when the module is prepended to a class. The base parameter is the class being decorated (Spree::Product)
  • base.belongs_to - We call class methods on base to add associations, validations, scopes, etc.
  • optional: true - Products don't require a brand (the brand_id can be NULL)

Step 4: Add Products Association to Brand

Now update your Brand model to define the reverse association:

ruby
module Spree
  class Brand < Spree::Base
    # ... existing code ...

    has_many :products, class_name: 'Spree::Product', dependent: :nullify

    # ... rest of your model ...
  end
end
<Info> We use `dependent: :nullify` instead of `dependent: :destroy`. When a brand is deleted, products will have their `brand_id` set to `NULL` rather than being deleted. This is safer for e-commerce data. </Info>

Step 5: Permit the Brand Parameter

For the admin form to save the brand association, we need to permit the brand_id parameter.

Add to your Spree initializer:

ruby
# .. other code ..

Spree::PermittedAttributes.product_attributes << :brand_id

Step 6: Add Brand Selector to Product Admin Form

Create a partial to inject the brand selector into the product form. Spree's admin product form has injection points for customization.

Create the partial:

erb
<div class="card mb-6">
  <div class="card-header">
    <h5 class="card-title"><%= Spree.t(:brand) %></h5>
  </div>
  <div class="card-body">
  <%= f.spree_select :brand_id,
      Spree::Brand.order(:name).pluck(:name, :id),
      { include_blank: true, label: Spree.t(:brand) } %>
</div>

Register this partial to appear in the product form. Add to your initializer:

<Tabs> <Tab title="Spree 5.2+"> ```ruby config/initializers/spree.rb Rails.application.config.after_initialize do Spree.admin.partials.product_form_sidebar << 'spree/admin/products/brand_field' end ``` </Tab> <Tab title="Spree 5.1 and below"> ```ruby config/initializers/spree.rb Rails.application.config.spree_admin.product_form_sidebar_partials << 'spree/admin/products/brand_field' ``` </Tab> </Tabs>

Step 7: Add Translation

Add the translation for the brand label:

yaml
en:
  spree:
    brand: Brand

Testing the Association

Verify everything works in the Rails console:

ruby
# Create a brand and product
brand = Spree::Brand.create!(name: 'Nike')
product = Spree::Product.first
product.update!(brand: brand)

# Test associations
product.brand        # => #<Spree::Brand id: 1, name: "Nike"...>
brand.products       # => [#<Spree::Product...>]
brand.products.count # => 1

Decorator Best Practices

<CardGroup cols={2}> <Card title="Use prepended callback" icon="code"> Always use `self.prepended(base)` for class-level additions like associations, validations, and scopes. </Card> <Card title="Keep decorators focused" icon="crosshairs"> Each decorator should have a single responsibility. Create multiple decorators if needed. </Card> <Card title="Call super when overriding" icon="arrow-up"> When overriding methods, call `super` to preserve original behavior unless you intentionally want to replace it. </Card> <Card title="Test decorated behavior" icon="flask-vial"> Write tests specifically for your decorated functionality to catch regressions. </Card> </CardGroup>

Common Decorator Patterns

Adding Validations

ruby
def self.prepended(base)
  base.validates :custom_field, presence: true
end

Adding Scopes

ruby
def self.prepended(base)
  base.scope :featured, -> { where(featured: true) }
end

Adding Callbacks

<Warning> For callbacks that trigger side effects (syncing to external services, sending notifications, etc.), use [Events subscribers](/developer/core-concepts/events) instead of decorator callbacks. Events are easier to test and won't break during Spree upgrades. </Warning>

Decorator approach (use only for simple, internal logic):

ruby
def self.prepended(base)
  base.before_save :normalize_name
end

def normalize_name
  self.name = name.strip.titleize if name.present?
end

Events approach (recommended for side effects):

ruby
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

      ExternalSyncService.sync(product)
    end
  end
end

Adding Class Methods

ruby
def self.prepended(base)
  base.extend ClassMethods
end

module ClassMethods
  def my_class_method
    # Class method logic
  end
end

Complete Files

Product Decorator

ruby
module Spree
  module ProductDecorator
    def self.prepended(base)
      base.belongs_to :brand, class_name: 'Spree::Brand', optional: true
    end
  end

  Product.prepend(ProductDecorator)
end

Brand Model (Updated)

ruby
module Spree
  class Brand < Spree::Base
    has_many :products, class_name: 'Spree::Product', dependent: :nullify

    has_one_attached :logo
    has_rich_text :description

    validates :name, presence: true
  end
end
<Info> SEO features like slugs, meta titles, and FriendlyId are covered in the [Slugs](/developer/core-concepts/slugs) documentation. </Info>

Spree Initializer Additions

ruby
# Permit brand_id in product params
Spree::PermittedAttributes.product_attributes << :brand_id
<Tabs> <Tab title="Spree 5.2+"> ```ruby config/initializers/spree.rb Rails.application.config.after_initialize do # Add brand field to product form Spree.admin.partials.product_form << 'spree/admin/products/brand_field' end ``` </Tab> <Tab title="Spree 5.1 and below"> ```ruby config/initializers/spree.rb # Add brand field to product form Rails.application.config.spree_admin.product_form_partials << 'spree/admin/products/brand_field' ``` </Tab> </Tabs>

Next Step

Now that Brands are connected to Products, let's expose them through the Store API:

<Card title="6. Store API" icon="code" href="/developer/tutorial/store-api"> Create API endpoints for brands and extend the Product API response </Card>