Back to Spree

Replace Taxons with Categories & Add Collections

docs/plans/6.0-replace-taxons-with-categories.md

5.4.218.5 KB
Original Source

Replace Taxons with Categories & Add Collections

Status: Design finalized, implementation not started Target: Spree 6.0 Depends on: API v3 conventions, ProductType plan (6.0-product-types.md) Author: Damian + Claude Last updated: 2026-03-16

Summary

Split the overloaded Taxon model into two distinct concepts that every modern commerce platform separates:

  • Category — hierarchical product classification (Men > Clothing > T-Shirts). A product's primary organizational home. Manual membership only.
  • Collection — flat, curated or rule-based product groupings ("Summer Sale", "New Arrivals", "Best Sellers"). Supports both manual curation and automatic rules.

Drop the Taxonomy model entirely — it's a grouping layer that adds indirection without value. Categories become a single tree per store (rooted, nested set). Collections are flat, store-scoped, and own the current automatic/rule-based functionality.

This also renames Classification to ProductCategory and TaxonRule to CollectionRule.

Problem

  1. "Taxon" and "Taxonomy" are jargon. No other commerce platform uses these terms. Every new developer has to learn what they mean. The industry-standard separation is Category (hierarchical) + Collection (flat/curated).

  2. Taxon is overloaded. The same model serves as both hierarchical categories AND flat collections (via the "Collections" taxonomy with automatic rules). These have fundamentally different semantics:

    • Categories are hierarchical, manual, and define a product's primary classification
    • Collections are flat, often automatic (rule-based), and used for merchandising (seasonal campaigns, "on sale", "new arrivals")
  3. Taxonomy adds indirection without value. Taxonomy is essentially a named container for a tree of Taxons, scoped to a Store. The root Taxon mirrors the Taxonomy name (kept in sync via callbacks). In practice, merchants have 2-3 taxonomies ("Categories", "Brands", "Collections"). This grouping can be replaced by a kind column on the category tree root or by separating Collections entirely.

  4. Classification tells you nothing. The Product↔Taxon join model name gives no hint about what it joins.

Current State

CurrentAPI namePrefixNotes
Spree::Taxonomyn/a (internal)txnmy_Container for taxon trees, scoped to store
Spree::Taxoncategoriesctg_Nested set tree, has automatic flag + TaxonRule
Spree::Classificationn/an/aProduct↔Taxon join (table: spree_products_taxons)
Spree::TaxonRulen/atxrule_STI: AvailableOn, Sale, Tag
Spree::Categorycategoriesctg_Already exists as < Taxon alias

The API v3 already exposes taxons as categories via the CategorySerializer. The Spree::Category class already exists as a subclass alias. The prefix ctg_ is already in use.

Default taxonomies created per store: "Categories", "Brands", "Collections". Automatic taxons ("On Sale", "New Arrivals") live under the "Collections" taxonomy.

Key Decisions (do not deviate without discussion)

Category

  • Spree::Taxon renamed to Spree::Category, spree_taxons renamed to spree_categories
  • Spree::Taxonomy dropped. Categories are a single nested set per store. The taxonomy_id column becomes store_id directly on the category.
  • Spree::Classification renamed to Spree::ProductCategory, table spree_products_taxons renamed to spree_product_categories, columns taxon_idcategory_id
  • Categories are manual only. The automatic flag, rules_match_policy, sort_order, and taxon_rules association move to Collection.
  • Prefix stays ctg_ (already in use)
  • Nested set stays. awesome_nested_set with parent_id, lft, rgt, depth, children_count. Proven, no reason to change.
  • pretty_name and hierarchical permalink stay. These are valuable for breadcrumbs and SEO.
  • hide_from_nav stays on Category — useful for categories that exist for organization but shouldn't appear in navigation.

Collection

  • New model Spree::Collection — flat (no tree), store-scoped
  • New join table Spree::ProductCollection (replaces the automatic taxon's use of Classification)
  • Owns automatic/rule-based membership — the automatic flag, rules_match_policy, and rules association move here from Taxon
  • Spree::TaxonRule renamed to Spree::CollectionRule — STI subtypes: AvailableOn, Sale, Tag (plus future rules)
  • Prefix: coll_
  • Both manual and automatic collections. Manual collections have ProductCollection records managed by admins. Automatic collections have CollectionRule records that regenerate membership.
  • Sort order on Collection, not Category. The SORT_ORDERS constant (manual, best_selling, price asc, etc.) moves to Collection.

Taxonomy → dropped

  • No replacement model. The "kind" of category tree (Categories vs Brands) is handled by the root category's name, or by a simple kind string column on Category if explicit grouping is needed.
  • Store scoping moves to Category directly. Every category has store_id (required). Children auto-copy from parent on create. Simple where(store_id:) scoping, no joins needed.

Design Details

Category model

ruby
class Spree::Category < Spree.base_class
  has_prefix_id :ctg

  include Spree::TranslatableResource
  include Spree::TranslatableResourceSlug
  include Spree::Metafields
  include Spree::Metadata
  include Spree::MemoizedData

  acts_as_nested_set dependent: :destroy, counter_cache: :children_count

  # Every category has store_id — children copy from parent on create
  belongs_to :store, class_name: 'Spree::Store'
  validates :store, presence: true
  before_validation :copy_store_from_parent, if: -> { store_id.blank? && parent.present? }

  # Products
  has_many :product_categories, class_name: 'Spree::ProductCategory', dependent: :destroy_async
  has_many :products, through: :product_categories

  # ProductType integration (from 6.0-product-types.md)
  has_many :product_type_categories, class_name: 'Spree::ProductTypeCategory', dependent: :destroy
  has_many :product_types, through: :product_type_categories

  # Promotions
  has_many :promotion_rule_categories, class_name: 'Spree::PromotionRuleCategory', dependent: :destroy
  has_many :promotion_rules, through: :promotion_rule_categories

  # Attachments
  has_one_attached :image
  has_one_attached :square_image

  # Validations
  validates :name, presence: true, uniqueness: { scope: %i[parent_id store_id], case_sensitive: false }
  validates :hide_from_nav, inclusion: { in: [true, false] }

  # Translations
  TRANSLATABLE_FIELDS = %i[name pretty_name description permalink].freeze
  translates(*TRANSLATABLE_FIELDS, column_fallback: !Spree.always_use_translations?)
  translates :description, backend: :action_text

  # Scopes
  scope :for_store, ->(store) { where(store_id: store.id) }

  # No automatic/rules — that's Collection's job
end

Collection model

ruby
class Spree::Collection < Spree.base_class
  has_prefix_id :coll

  include Spree::SingleStoreResource
  include Spree::TranslatableResource
  include Spree::TranslatableResourceSlug
  include Spree::Metafields
  include Spree::Metadata

  RULES_MATCH_POLICIES = %w[all any].freeze
  SORT_ORDERS = %w[
    manual best_selling
    price_asc price_desc
    available_on_desc available_on_asc
    name_asc name_desc
  ].freeze

  acts_as_list scope: :store_id

  belongs_to :store, class_name: 'Spree::Store'

  # Products
  has_many :product_collections, class_name: 'Spree::ProductCollection', dependent: :destroy_async
  has_many :products, through: :product_collections

  # Automatic rules
  has_many :collection_rules, class_name: 'Spree::CollectionRule', dependent: :destroy
  accepts_nested_attributes_for :collection_rules, allow_destroy: true,
    reject_if: proc { |attrs| attrs['value'].blank? }

  # Attachments
  has_one_attached :image
  has_one_attached :square_image

  # Validations
  validates :name, presence: true
  validates :store, presence: true
  validates :rules_match_policy, inclusion: { in: RULES_MATCH_POLICIES }, presence: true
  validates :sort_order, inclusion: { in: SORT_ORDERS }, presence: true

  # Translations
  TRANSLATABLE_FIELDS = %i[name description permalink].freeze
  translates(*TRANSLATABLE_FIELDS, column_fallback: !Spree.always_use_translations?)

  scope :manual, -> { where.not(automatic: true) }
  scope :automatic, -> { where(automatic: true) }

  # Regenerate product membership from rules (moved from Taxon)
  def regenerate_products
    return unless automatic?
    Spree::Collections::RegenerateProducts.call(collection: self)
  end

  def products_matching_rules(opts = {})
    return Spree::Product.none unless automatic? && collection_rules.any?
    # Same logic as current Taxon#products_matching_rules
  end
end

Join tables

ruby
# Renamed from Spree::Classification
class Spree::ProductCategory < Spree.base_class
  self.table_name = 'spree_product_categories'
  acts_as_list scope: :category

  belongs_to :product, class_name: 'Spree::Product', counter_cache: :category_count, touch: true
  belongs_to :category, class_name: 'Spree::Category', counter_cache: :product_count, touch: true

  validates :category, :product, presence: true
  validates :category_id, uniqueness: { scope: :product_id }
end

# New
class Spree::ProductCollection < Spree.base_class
  self.table_name = 'spree_product_collections'
  acts_as_list scope: :collection

  belongs_to :product, class_name: 'Spree::Product', touch: true
  belongs_to :collection, class_name: 'Spree::Collection', touch: true

  validates :collection, :product, presence: true
  validates :collection_id, uniqueness: { scope: :product_id }
end

CollectionRule (renamed from TaxonRule)

ruby
class Spree::CollectionRule < Spree.base_class
  has_prefix_id :crule

  MATCH_POLICIES = %w[is_equal_to is_not_equal_to contains does_not_contain].freeze

  belongs_to :collection, class_name: 'Spree::Collection', inverse_of: :collection_rules, touch: true

  validates :collection, :type, :value, presence: true
  validates :match_policy, inclusion: { in: MATCH_POLICIES }, presence: true

  after_commit :regenerate_collection_products,
    if: -> { saved_change_to_value? || destroyed? || saved_change_to_match_policy? }
end

# STI subtypes:
# Spree::CollectionRules::AvailableOn — products created/available within N days
# Spree::CollectionRules::Sale — products currently on sale
# Spree::CollectionRules::Tag — products with matching tags

Product changes

ruby
class Spree::Product < Spree.base_class
  # Categories (renamed from taxons/classifications)
  has_many :product_categories, dependent: :delete_all, inverse_of: :product
  has_many :categories, through: :product_categories
  # Backward compat alias (deprecation period)
  alias_method :taxons, :categories
  alias_method :classifications, :product_categories

  # Collections (new)
  has_many :product_collections, dependent: :delete_all, inverse_of: :product
  has_many :collections, through: :product_collections

  # main_taxon → primary_category
  def primary_category
    @primary_category ||= categories.first
  end

  # brand_taxon → brand (lookup by taxonomy name replaced by category kind or brand association)
  # See Open Questions for brand handling
end

Store changes

ruby
class Spree::Store < Spree.base_class
  # Drop: has_many :taxonomies
  # Drop: has_many :taxons, through: :taxonomies

  has_many :categories, class_name: 'Spree::Category'
  has_many :root_categories, -> { roots }, class_name: 'Spree::Category'
  has_many :collections, class_name: 'Spree::Collection'
end

API

Store API already uses categories naming. Changes:

# Categories (existing endpoints, just renamed internally)
GET    /api/v3/store/categories          # was taxons
GET    /api/v3/store/categories/:id
GET    /api/v3/store/products/:id?expand=categories

# Collections (new)
GET    /api/v3/store/collections
GET    /api/v3/store/collections/:id
GET    /api/v3/store/collections/:id/products

# Admin API
GET    /api/v3/admin/categories
POST   /api/v3/admin/categories
PATCH  /api/v3/admin/categories/:id
DELETE /api/v3/admin/categories/:id

GET    /api/v3/admin/collections
POST   /api/v3/admin/collections
PATCH  /api/v3/admin/collections/:id
DELETE /api/v3/admin/collections/:id

Collection response:

json
{
  "id": "coll_k5nR8xLq",
  "name": "Summer Sale",
  "automatic": true,
  "sort_order": "best_selling",
  "rules_match_policy": "all",
  "rules": [
    { "id": "crule_abc", "type": "Spree::CollectionRules::Tag", "value": "summer", "match_policy": "contains" }
  ],
  "image_url": "https://...",
  "products_count": 42,
  "slug": "summer-sale"
}

Migration Path

Phase 1: Table renames + new Collection table

Single migration:

ruby
class RenameTaxonsToCategories < ActiveRecord::Migration[7.2]
  def change
    # Rename core tables
    rename_table :spree_taxons, :spree_categories
    rename_table :spree_products_taxons, :spree_product_categories
    rename_table :spree_taxon_rules, :spree_collection_rules

    # Rename FK columns
    rename_column :spree_product_categories, :taxon_id, :category_id
    rename_column :spree_collection_rules, :taxon_id, :collection_id

    # Add store_id to categories (replacing taxonomy_id)
    add_column :spree_categories, :store_id, :bigint
    add_index :spree_categories, :store_id

    # Rename counter caches
    rename_column :spree_categories, :classification_count, :product_count
    rename_column :spree_products, :classification_count, :category_count

    # Create collections table
    create_table :spree_collections do |t|
      t.string :name, null: false
      t.text :description
      t.string :permalink
      t.boolean :automatic, null: false, default: false
      t.string :rules_match_policy, null: false, default: 'all'
      t.string :sort_order, null: false, default: 'manual'
      t.boolean :hide_from_nav, null: false, default: false
      t.integer :position
      t.integer :products_count, null: false, default: 0
      t.references :store, null: false
      t.string :meta_title
      t.string :meta_description
      t.string :meta_keywords
      t.jsonb :public_metadata
      t.jsonb :private_metadata
      t.timestamps
    end

    add_index :spree_collections, [:store_id, :permalink], unique: true

    # Create product_collections join table
    create_table :spree_product_collections do |t|
      t.references :product, null: false
      t.references :collection, null: false
      t.integer :position
      t.timestamps
    end

    add_index :spree_product_collections, [:collection_id, :product_id], unique: true
  end
end

Phase 2: Data migration (rake task)

ruby
# rake spree:migrate_taxons_to_categories_and_collections
#
# For each Taxonomy:
#   1. Set store_id on all its root + descendant taxons (now categories)
#   2. If taxonomy is "Collections" (by name):
#      - Move its automatic child taxons to spree_collections
#      - Move their TaxonRules to CollectionRules
#      - Move their Classifications to ProductCollections
#      - Move manual child taxons to ProductCollections
#      - Delete the taxonomy root category
#   3. For other taxonomies ("Categories", "Brands"):
#      - Root category keeps the taxonomy name
#      - Remove taxonomy_id column references
#   4. Drop taxonomy_id from categories

Phase 3: Model renames + deprecation aliases

  • Spree::TaxonSpree::Category (alias Spree::Taxon = Spree::Category for one release)
  • Spree::Taxonomy → deprecated, no replacement
  • Spree::ClassificationSpree::ProductCategory (alias for one release)
  • Spree::TaxonRuleSpree::CollectionRule
  • Spree::TaxonRules::*Spree::CollectionRules::*
  • Update all serializers, controllers, services, factories, specs

Phase 4: Cleanup (6.1)

  • Remove all aliases and deprecation shims
  • Remove taxonomy_id column from categories
  • Remove spree_taxonomies table
  • Remove automatic, rules_match_policy, sort_order columns from categories (moved to collections)

Constraints on Current Work

  • Use categories in all new API code. The Store API already does this. Don't introduce new taxon references in API v3.
  • Use Spree::Category alias in new code where possible. It exists today as < Taxon.
  • Don't add new TaxonRule subtypes. New rule types should be designed for the Collection model.
  • Don't add new Taxonomy-dependent features. Taxonomy is going away.
  • Keep the ctg_ prefix. Already in use, will carry over.
  • New "collection-like" features go on Taxon's automatic path for now — they'll migrate cleanly to Collection.
  • ProductTypeCategory join table (from 6.0-product-types.md) should be named anticipating the Category rename — use category_id column, not taxon_id.

Open Questions

  1. Brand handling. Currently brand_taxon looks up a taxon under the "Brands" taxonomy. Options:

    • (a) Keep brands as a category tree root named "Brands" — simplest, brands are just categories
    • (b) Add a dedicated Spree::Brand model — cleaner but more migration work
    • (c) Brand as a metafield — too unstructured
    • Leaning toward (a): brands are categories under a "Brands" root. product.brand resolves to the first category under the Brands root.
  2. Category kind/type column. Should root categories have a kind column (e.g., categories, brands) to replace the Taxonomy name as a semantic grouping? This would make "find the brands root" a query on kind rather than a string match on name. Low cost, high value.

  3. Collection pagination of products. Should GET /collections/:id/products support full Ransack filtering and Pagy pagination, or should it return the collection's pre-sorted product list? Probably both — Ransack filters scoped to the collection's product set.

  4. Automatic collection scheduling. Current AvailableOn rule regenerates on product changes. Should collections support scheduled regeneration (e.g., daily cron) for time-based rules? The current after_commit approach may miss edge cases.

  5. Collection nesting. The plan says collections are flat. Should we support one level of nesting (parent collection)? Some platforms support hierarchical collections with filter inheritance. Could be a future enhancement.

References

  • Industry pattern: hierarchical Category + flat Collection (manual and/or rule-based) is the standard across modern headless commerce platforms
  • Current Spree API: already uses categories naming in Store API v3
  • Related plan: 6.0-product-types.md (ProductTypeCategory join table)