Back to Spree

Remove Master Variant

docs/plans/6.0-remove-master-variant.md

5.4.214.4 KB
Original Source

Remove Master Variant

Status: Design finalized, implementation not started Target: Spree 6.0 Depends on: None (standalone, but coordinate with Cart/Order split) Author: Damian + Claude Last updated: 2026-03-16

Summary

Eliminate the hidden "master" variant pattern. Every product will have at least one regular variant. Simple products (no options) have exactly one variant. Products with options have N variants. A default_variant_id FK on Product replaces the is_master flag as the way to identify the "face" of the product.

This removes the single most confusing Spree-specific pattern for new developers and eliminates an entire class of bugs caused by master/non-master branching.

Problem

Every Spree product has a hidden variant with is_master: true. This master variant:

  1. Stores price, SKU, weight for simple products — then gets stripped of prices and stock when real variants are created (via remove_prices_from_master_variant, remove_stock_items_from_master_variant callbacks). It becomes a ghost row.

  2. Forces two scopes everywhere. variants (excludes master, 99% of use cases) vs variants_including_master (includes master, needed for through-associations like prices, stock_items, line_items). Pick the wrong one and you either miss data or double-count.

  3. Creates has_variants? branching throughout the codebase. Every piece of code that touches pricing, stock, availability, or display must check if has_variants? ... else ... master .... This is the hallmark of a leaky abstraction.

  4. Confuses the delegation chain. product.pricedefault_variant → either master (no variants) or first purchasable variant (has variants). product.skumaster (always). Two different delegation targets on the same model.

  5. Breaks stock state. Master gets stock items on creation, they're deleted when real variants appear. Delete all variants → master has no stock items → broken state.

No other modern commerce platform uses this pattern. The industry standard is: every product has at least one regular variant, one of which is designated the default.

Blast radius

PatternOccurrencesFiles
is_master references3213
variants_including_master3513
.master on product (app + specs)25550

Key Decisions (do not deviate without discussion)

  • Remove is_master column from spree_variants. All variants are peers.
  • Add default_variant_id FK on spree_products. Points to the variant used for price display, default add-to-cart, and property delegation. Required (not nullable) — every product must have a default variant.
  • Every product has at least one variant. Creating a product auto-creates one variant (like today's master, but without the special flag). Simple products have one variant with no option values. Products with options have N variants.
  • Option values on variants become truly optional. Today validates :option_value_variants, presence: true, unless: :is_master?. After: option values are optional on all variants. A simple product's sole variant just has no option values.
  • product.variants returns ALL variants (no is_master filter). Drop the variants_including_master scope entirely.
  • Delegation simplification. All product-level convenience methods (price, sku, weight, cost_price, etc.) delegate to default_variant. One delegation target, no branching.
  • API: master_variant expand renamed to default_variant. Deprecation alias for one release.
  • LineItem stays belongs_to :variant. This is correct. No change.
  • StockItem stays belongs_to :variant. This is correct. No change.
  • Add-to-cart stays variant-centric. POST /carts/:id/items { variant_id: "variant_xxx" }. No change.
  • Remove all ghost callbacks. remove_prices_from_master_variant, remove_stock_items_from_master_variant, set_master_out_of_stock — all deleted.

Design Details

Product model (after)

ruby
class Spree::Product < Spree.base_class
  # Single variants association — no is_master filter
  has_many :variants,
           -> { order(:position) },
           inverse_of: :product,
           class_name: 'Spree::Variant',
           dependent: :destroy

  # Default variant — the "face" of the product
  belongs_to :default_variant,
             class_name: 'Spree::Variant',
             optional: true  # nullable during creation, set in after_create

  has_many :prices, -> { order('spree_variants.position, spree_variants.id, currency') },
           through: :variants, source: :prices
  has_many :stock_items, through: :variants
  has_many :line_items, through: :variants
  has_many :orders, through: :line_items

  # All delegations go to default_variant — one target, no branching
  [
    :sku, :barcode, :weight, :height, :width, :depth, :dimensions_unit, :weight_unit,
    :price, :price_in, :amount_in, :compare_at_price, :compare_at_amount_in,
    :currency, :cost_currency, :cost_price, :track_inventory
  ].each do |method_name|
    delegate method_name, :"#{method_name}=", to: :default_variant
  end

  delegate :display_amount, :display_price, :has_default_price?, :track_inventory?,
           :display_compare_at_price, :images, to: :default_variant

  after_create :ensure_default_variant
  after_create :set_default_variant

  validates :default_variant, presence: true, on: :update

  # Renamed from has_variants? — "does this product have options to choose from?"
  def has_multiple_variants?
    return variants.size > 1 if variants.loaded?

    variant_count > 1
  end

  # Simple — always at least one variant
  def purchasable?
    variants.any?(&:purchasable?)
  end

  def in_stock?
    variants.any?(&:in_stock?)
  end

  def on_sale?(currency)
    prices.where(currency: currency).any?(&:discounted?)
  end

  # Auto-promote next variant when default is deleted/discontinued
  after_save :auto_promote_default_variant

  private

  def ensure_default_variant
    return if variants.any?

    variants.create!
  end

  def set_default_variant
    update_column(:default_variant_id, variants.first.id) if default_variant_id.nil?
  end

  def auto_promote_default_variant
    return if default_variant&.available?

    next_variant = variants.active.order(:position).first
    update_column(:default_variant_id, next_variant.id) if next_variant
  end
end

Variant model (after)

ruby
class Spree::Variant < Spree.base_class
  belongs_to :product, class_name: 'Spree::Product', touch: true

  # Option values are optional on ALL variants
  # Simple product's variant: no option values
  # Multi-variant product: each variant has option values
  has_many :option_value_variants, class_name: 'Spree::OptionValueVariant'
  has_many :option_values, through: :option_value_variants

  # No more is_master guards
  after_create :create_stock_items

  # Removed:
  # - validates :option_value_variants, presence: true, unless: :is_master?
  # - after_create :set_master_out_of_stock, unless: :is_master?
  # - after_commit :remove_prices_from_master_variant, unless: :is_master?
  # - after_commit :remove_stock_items_from_master_variant, unless: :is_master?
  # - is_master column and all references

  scope :eligible, -> { all }  # All variants are eligible (no master filtering needed)

  def exchange_name
    option_values.any? ? options_text : name
  end

  def descriptive_name
    option_values.any? ? "#{name} - #{options_text}" : name
  end
end

API changes

ruby
# Product serializer
# Before:
one :master, key: :master_variant, ...
# After:
one :default_variant, resource: Spree.api.variant_serializer,
    if: proc { expand?('default_variant') }

# Deprecation alias: expand=master_variant still works for one release

Store API response — default_variant_id replaces master_variant:

json
{
  "id": "prod_86Rf07xd4z",
  "name": "Classic Tee",
  "default_variant_id": "variant_k5nR8xLq",
  "price": 29.99,
  "sku": "TSHIRT-001",
  "variants": [...]
}

What stays the same

  • LineItem belongs_to :variant — unchanged
  • StockItem belongs_to :variant — unchanged
  • Add-to-cart takes variant_id — unchanged
  • Prices belong to variants — unchanged
  • Nested attributes for variants on Product — unchanged (just remove the separate master nested attributes)

Migration Path

Phase 1: Add default_variant_id column

ruby
class AddDefaultVariantIdToProducts < ActiveRecord::Migration[7.2]
  def change
    add_column :spree_products, :default_variant_id, :bigint
    add_index :spree_products, :default_variant_id
  end
end

Phase 2: Data migration (rake task)

ruby
# rake spree:remove_master_variant
#
# For each product:
#
# Case 1: Product has real variants (variant_count > 0)
#   - Master variant has been stripped of prices/stock (ghost)
#   - Set default_variant_id to first non-master variant (by position)
#   - Delete the master variant (it has no prices, no stock, no line items)
#   - If master HAS line items (old completed orders), keep it but set is_master=false
#
# Case 2: Product has NO real variants (simple product)
#   - Master is the only variant, holds all data
#   - Set is_master = false (convert to regular variant)
#   - Set default_variant_id to this variant
#
# For all products:
#   - Update variant_count to reflect actual count (all variants, no master exclusion)

This task must be idempotent and reversible (soft — we can't re-create deleted masters, but we can log what was deleted).

Phase 3: Model changes

  1. Remove is_master column from spree_variants
  2. Remove has_one :master from Product
  3. Remove has_many :variants_including_master from Product
  4. Change has_many :variants to remove where(is_master: false) filter
  5. Add belongs_to :default_variant on Product
  6. Update all delegations to use default_variant
  7. Remove ghost callbacks from Variant
  8. Remove option_value_variants presence validation
  9. Update eligible scope (no is_master filter needed)
  10. Update factories: product factory creates one variant instead of master

Phase 4: API + serializer updates

  • Rename master_variant expand to default_variant (alias old name)
  • Update admin serializer (cost_price, cost_currency from default_variant instead of master)
  • Update SDK types via typelizer pipeline

Phase 5: Cleanup (6.1)

  • Remove is_master column migration
  • Remove deprecation aliases (master_variant expand, variants_including_master)
  • Remove master method from Product

Edge Cases

Completed orders referencing master variant

LineItems in completed orders may reference the old master variant. Two options:

  • (a) Keep master variants that have line items — set is_master = false, they become regular variants. Old orders still work. This is the safe path.
  • (b) Migrate line items to point to the default variant. Risky — changes historical data.

Decision: (a). Former masters with line items survive as regular variants. They just lose the is_master flag. The default_variant_id points to the first real variant (or to this converted master for simple products).

Product creation flow

Today: create product → ensure_master auto-builds a master variant with product-level price.

After: create product with variants nested attributes → first variant becomes default_variant. If no variants provided, one empty variant is auto-created (simple product). Price is set per-variant, not on the product.

Nested attributes

Today: accepts_nested_attributes_for :master allows setting master's properties inline.

After: accepts_nested_attributes_for :variants handles everything. No separate master nested attributes. Admin API accepts variants: [{ sku: ..., prices: [...] }].

Price delegation

Today: product.pricedefault_variant (which may be master or first purchasable variant, with branching).

After: product.pricedefault_variant.price (always the same record, no branching). Product has no price column — it's purely a delegation convenience. If the merchant wants to change which variant is "default", they update default_variant_id.

Constraints on Current Work

  • Stop adding is_master? guards. New code should not branch on master/non-master.
  • Use variants_including_master for through-associations (prices, stock_items, line_items) — these will become just variants after migration.
  • Don't add new delegations to master. New delegations should go through default_variant.
  • New variant callbacks should not use unless: :is_master?. They should apply to all variants equally.

Resolved Questions

  1. default_variant_id management. Auto-promote the next variant (by position) when the current default is deleted or discontinued. No admin action required.

  2. Variant count semantics. Rename has_variants? to has_multiple_variants? — returns variant_count > 1. Clearer intent: "does this product have more than one variant (i.e., options to choose from)?"

  3. Product creation API. No product-level price param. Price lives on variants. The Admin API accepts variants as nested attributes:

ruby
# POST /api/v3/admin/products
{
  name: "T-Shirt",
  shipping_category_id: "sc_abc",
  status: "draft",
  variants: [
    {
      sku: "TSHIRT-S",
      options: [{ name: "size", value: "Small" }],
      prices: [
        { currency: "USD", amount: 29.99 },
        { currency: "EUR", amount: 27.99 }
      ],
      stock_items: [
        { location_id: "loc_abc", quantity: 10, backorderable: true }
      ]
    }
  ]
}

If no variants are provided, one default variant is auto-created (simple product). Price must then be set on that variant via a follow-up call or via a top-level price convenience param that forwards to the auto-created variant.

  1. Weight/dimensions stay on Variant. The variant is the purchasable/shippable unit. A Small and XL t-shirt weigh differently. product.weight delegates to default_variant.weight as a convenience — no product-level columns.

Open Questions

None at this time.

References

  • Industry standard: every product has at least one regular variant, no hidden "master". The variant is the purchasable unit.
  • Current master variant implementation: spree/core/app/models/spree/product.rb (lines 93-107, 232-248, 305-331, 692-696)
  • Current ghost callbacks: spree/core/app/models/spree/variant.rb (lines 618-678)
  • Related plan: 6.0-cart-order-split.md (LineItem still points to variant)