docs/plans/6.0-remove-master-variant.md
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
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.
Every Spree product has a hidden variant with is_master: true. This master variant:
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.
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.
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.
Confuses the delegation chain. product.price → default_variant → either master (no variants) or first purchasable variant (has variants). product.sku → master (always). Two different delegation targets on the same model.
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.
| Pattern | Occurrences | Files |
|---|---|---|
is_master references | 32 | 13 |
variants_including_master | 35 | 13 |
.master on product (app + specs) | 255 | 50 |
is_master column from spree_variants. All variants are peers.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.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.default_variant. One delegation target, no branching.master_variant expand renamed to default_variant. Deprecation alias for one release.belongs_to :variant. This is correct. No change.belongs_to :variant. This is correct. No change.POST /carts/:id/items { variant_id: "variant_xxx" }. No change.remove_prices_from_master_variant, remove_stock_items_from_master_variant, set_master_out_of_stock — all deleted.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
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
# 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:
{
"id": "prod_86Rf07xd4z",
"name": "Classic Tee",
"default_variant_id": "variant_k5nR8xLq",
"price": 29.99,
"sku": "TSHIRT-001",
"variants": [...]
}
belongs_to :variant — unchangedbelongs_to :variant — unchangedvariant_id — unchangedmaster nested attributes)default_variant_id columnclass AddDefaultVariantIdToProducts < ActiveRecord::Migration[7.2]
def change
add_column :spree_products, :default_variant_id, :bigint
add_index :spree_products, :default_variant_id
end
end
# 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).
is_master column from spree_variantshas_one :master from Producthas_many :variants_including_master from Producthas_many :variants to remove where(is_master: false) filterbelongs_to :default_variant on Productdefault_variantoption_value_variants presence validationeligible scope (no is_master filter needed)master_variant expand to default_variant (alias old name)cost_price, cost_currency from default_variant instead of master)is_master column migrationmaster_variant expand, variants_including_master)master method from ProductLineItems in completed orders may reference the old master variant. Two options:
is_master = false, they become regular variants. Old orders still work. This is the safe path.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).
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.
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: [...] }].
Today: product.price → default_variant (which may be master or first purchasable variant, with branching).
After: product.price → default_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.
is_master? guards. New code should not branch on master/non-master.variants_including_master for through-associations (prices, stock_items, line_items) — these will become just variants after migration.master. New delegations should go through default_variant.unless: :is_master?. They should apply to all variants equally.default_variant_id management. Auto-promote the next variant (by position) when the current default is deleted or discontinued. No admin action required.
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)?"
Product creation API. No product-level price param. Price lives on variants. The Admin API accepts variants as nested attributes:
# 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.
product.weight delegates to default_variant.weight as a convenience — no product-level columns.None at this time.
spree/core/app/models/spree/product.rb (lines 93-107, 232-248, 305-331, 692-696)spree/core/app/models/spree/variant.rb (lines 618-678)6.0-cart-order-split.md (LineItem still points to variant)