Skip to main content

Documentation Index

Fetch the complete documentation index at: https://spreecommerce.org/docs/llms.txt

Use this file to discover all available pages before exploring further.

Overview

Order routing decides which Stock Location fulfills an order at checkout. Spree gives you two extension points:
  • Rules — add a new signal to the existing rules-walking algorithm (proximity, customer tier, refrigerated SKUs, day-of-week dispatch).
  • Strategies — replace the algorithm entirely (delegate to a warehouse management system, run an ML model, call an optimization solver).
This guide covers both. Most extensions are rules — they compose with the built-ins and don’t require rewriting the pipeline. Before starting, make sure you understand how order routing works in Spree.
If the answer is “yes”Pick
Could this work just by reordering or adding signals to the existing algorithm?A rule
Does the data live in another system you have to call?A strategy
Will the algorithm need access to multiple orders simultaneously (batching, capacity-aware)?A strategy
Are you replacing the entire decision (no rules at all)?A strategy

Custom Routing Rules

A rule is one input to the rules-walking algorithm. Each rule subclasses Spree::OrderRoutingRule and implements #rank, returning an array of LocationRanking — one per candidate location.

Step 1: Create the Rule Class

app/models/spree/order_routing/rules/closest_location.rb
module Spree
  module OrderRouting
    module Rules
      class ClosestLocation < Spree::OrderRoutingRule
        preference :max_distance_km, :integer, default: 1000

        def rank(order, locations)
          target = order.ship_address&.coordinates
          return locations.map { |l| LocationRanking.new(location: l, rank: nil) } if target.nil?

          locations.map do |loc|
            distance = loc.distance_from(target)
            ranked = distance && distance <= preferred_max_distance_km ? distance.to_i : nil
            LocationRanking.new(location: loc, rank: ranked)
          end
        end
      end
    end
  end
end

Key Method to Implement

MethodRequiredDescription
rank(order, locations)YesReturns one LocationRanking per input location. Lower rank wins (0 is best); nil means abstain (the rule has no opinion about that location and is skipped for that pass).

Using Preferences

Rules use Spree’s preference system for configuration, the same way Promotion Rules do. Each preference creates getter/setter methods automatically:
preference :max_distance_km, :integer, default: 1000

# Creates:
# preferred_max_distance_km / preferred_max_distance_km=
Available types: :string, :integer, :decimal, :boolean, :array.

Step 2: Activate the Rule on a Channel

Every routing rule belongs to a Channel. Pick the channel(s) you want it active on and insert a row:
store   = Spree::Store.default
channel = store.default_channel

Spree::OrderRouting::Rules::ClosestLocation.create!(
  store: store,
  channel: channel,
  position: 0,                    # before the seeded preferred_location rule
  preferred_max_distance_km: 500
)
Spree’s autoloader picks up models under app/models/ automatically — no separate registration step. To activate the rule across multiple channels, create one row per channel. That keeps each channel’s rule list explicit and lets you tune per-channel preferences independently.

Rank Semantics

The reducer composes rules using “first non-tie wins”:
SituationWhat the reducer does
All rules abstain (nil) for a locationFalls back to StockLocation.default, then by id
One rule returns a unique minimum rankThat location wins; remaining rules skipped
One rule returns a tied minimumThe tied locations carry forward; the next rule weighs in only on those
All rules tie through the whole chainFinal tiebreak: default location, then by id
Practical implications:
  • Returning 0 for everything is a reset. If every location ties at 0, all locations carry forward — the reducer treats it as “no signal” and moves on.
  • Coverage-style metrics negate. When higher-is-better, return -coverage so lower wins. See Spree::OrderRouting::Rules::MinimizeSplits for the canonical example.
  • Abstaining yields to other rules. nil is the right answer when your rule has no opinion — it lets later rules decide.

Step 3: Test the Rule

spec/models/spree/order_routing/rules/closest_location_spec.rb
require 'rails_helper'

RSpec.describe Spree::OrderRouting::Rules::ClosestLocation, type: :model do
  let(:store) { @default_store }
  let(:channel) { store.default_channel }
  let(:near)  { create(:stock_location, latitude: 40.71, longitude: -74.00) }   # NYC
  let(:far)   { create(:stock_location, latitude: 34.05, longitude: -118.24) }  # LA
  let(:order) { build(:order, store: store, ship_address: build(:address, latitude: 40.75, longitude: -73.99)) }

  subject(:rule) do
    described_class.new(store: store, channel: channel, position: 99, preferred_max_distance_km: 5000)
  end

  it 'ranks the closer location lower' do
    rankings = rule.rank(order, [near, far])
    expect(rankings.find { |r| r.location == near }.rank).to be < rankings.find { |r| r.location == far }.rank
  end

  it 'abstains for every location when the order has no shippable address' do
    addressless_order = build(:order, store: store, ship_address: nil)
    rankings = rule.rank(addressless_order, [near, far])
    expect(rankings.map(&:rank)).to all(be_nil)
  end
end

Common Pitfalls

  • Forgetting position. position is required and acts_as_list-scoped per channel. Use a number that doesn’t collide with the seeded 1 / 2 / 3 (low for “before the defaults”, 100+ for “after the defaults”).
  • Returning fewer rankings than locations. Always return one entry per input location, including abstains (nil rank). The reducer needs to see every location.
  • Trying to “block” a location. Rules rank, they don’t filter. To exclude a location, lower its rank to a value worse than every other rule produces, or abstain everywhere except the locations you want and rely on a later rule to cover the rest.

Custom Routing Strategies

A strategy is a complete algorithm — when rules-walking doesn’t fit your problem, you write a strategy that owns the entire allocation pipeline.

Step 1: Create the Strategy Class

The contract is Spree::OrderRouting::Strategy::Base. There are no defaults — you implement all four methods:
MethodWhen it firesReturns
#for_allocationCart → checkout transition (Order#create_proposed_shipments)Array<Spree::Stock::Package>
#for_sale(fulfillment:)A shipment ships(side effect)
#for_releaseAn in-flight order is canceled before shipping(side effect)
#for_cancellationA shipped order is canceled (return)(side effect)
The four methods bracket the lifecycle: allocation → sale, allocation → release, or allocation → cancellation. Whatever your algorithm does at allocation time, the other three are where you reverse or settle it.
app/models/acme/oms/strategy.rb
module Acme
  module Oms
    class Strategy < Spree::OrderRouting::Strategy::Base
      def for_allocation
        decision = client.allocate(order_payload)
        return [] if decision.assignments.empty?

        decision.assignments.map { |assignment| build_package(assignment) }
      end

      def for_sale(fulfillment:)
        client.notify_shipped(fulfillment_payload(fulfillment))
      end

      def for_release
        client.release(order.number)
      end

      def for_cancellation
        client.cancel_and_restock(order.number)
      end

      private

      def client
        @client ||= Acme::Oms::Client.new(api_key: ENV.fetch('ACME_OMS_API_KEY'))
      end

      def order_payload
        {
          order_number: order.number,
          line_items: order.line_items.map { |li| { sku: li.variant.sku, qty: li.quantity } },
          ship_to: order.ship_address&.country&.iso
        }
      end

      def build_package(assignment)
        location = Spree::StockLocation.find_by!(code: assignment.location_code)
        units = order.inventory_units.where(variant_id: assignment.variant_ids).to_a

        package = Spree::Stock::Packer.new(location, units, Spree.stock_splitters).packages.first
        package.shipping_rates = Spree::Stock::Estimator.new(order).shipping_rates(package)
        package
      end

      def fulfillment_payload(fulfillment)
        # ...
      end
    end
  end
end
Notes:
  • Strategies are plain Ruby classes, not ActiveRecord models. Live under app/models/ so the autoloader picks them up; or anywhere on the load path if you’d rather organize them as services.
  • for_allocation returns Spree::Stock::Package objects. The order’s create_proposed_shipments turns those into Shipments by calling package.to_shipment. Returning shipments directly will break the call site.
  • Reuse the existing primitives. Spree::Stock::Packer, Spree::Stock::Estimator, and Spree::Stock::InventoryUnitBuilder handle packing, rate estimation, and inventory unit construction. Custom strategies are about the location decision, not re-implementing the packing pipeline.

Step 2: Register the Strategy

Strategies are selected by class name string — set on Spree::Store (default) or Spree::Channel (override). Activate on the whole store:
Spree::Store.default.update!(
  preferred_order_routing_strategy: 'Acme::Oms::Strategy'
)
Override on one channel only:
store = Spree::Store.default
store.channels.find_by(code: 'pos').update!(
  preferred_order_routing_strategy: 'Acme::Oms::Strategy'
)
Resolution order: channel.preferred_order_routing_strategystore.preferred_order_routing_strategy. The class is safe_constantize-d and rejected if it doesn’t subclass Strategy::Base.

Step 3: Test the Strategy

Strategy tests are integration tests — build an order, instantiate the strategy, exercise the four methods, assert on the resulting shipments and mocked side effects.
spec/models/acme/oms/strategy_spec.rb
require 'rails_helper'

RSpec.describe Acme::Oms::Strategy, type: :model do
  let(:store)    { @default_store }
  let(:variant)  { create(:variant) }
  let(:location) { create(:stock_location, code: 'NYC') }
  let(:order)    { create(:order_with_line_items, store: store, line_items_attributes: [{ variant: variant, quantity: 1 }]) }

  before { location.stock_item_or_create(variant).update!(count_on_hand: 10) }

  subject(:strategy) { described_class.new(order: order) }

  describe '#for_allocation' do
    it 'returns packages from the OMS-chosen locations' do
      stub_oms_decision(assignments: [{ location_code: 'NYC', variant_ids: [variant.id] }])

      packages = strategy.for_allocation
      expect(packages.map(&:stock_location)).to all(eq(location))
    end

    it 'returns no packages when the OMS has nothing to assign' do
      stub_oms_decision(assignments: [])
      expect(strategy.for_allocation).to eq([])
    end
  end
end

Common Pitfalls

  • Forgetting the lifecycle hooks. for_release and for_cancellation raise NotImplementedError by default. If your algorithm doesn’t need post-allocation hooks, override them as no-ops explicitly.
  • Ignoring inventory. Even custom strategies should query Spree::StockLocation.active and respect the order’s reserved units. Hardcoding Spree::StockLocation.first will work in tests and break in production.
  • Side effects in for_sale / for_release. These fire from state-machine callbacks where the order may already be partially mutated. Treat the methods as side-effect endpoints; pull what you need from the order and fulfillment arguments — don’t reload state mid-call.

Coexistence with Stock Reservations

Stock reservations and order routing are independent systems in 5.5 — they make decisions at different times and protect different invariants.
ConcernReservation systemOrder routing
When it firesCart mutation (add item, change qty, enter checkout)Cart → checkout transition
What it decidesHow many units of a variant are held for this cartWhich StockLocation fulfills the order
GranularityPer-variantPer-order
Spree::Stock::Quantifier already subtracts active reservations from count_on_hand per-variant across all locations, so global “can we sell this variant?” math is correct even when the reservation and the routing decision land on different locations. What this means for you when writing custom rules and strategies:
  • Don’t read StockReservation from inside a routing rule’s #rank. The reservations were created against arbitrary stock_items at cart time and don’t reflect the routing decision.
  • Don’t relocate reservations from a custom strategy’s for_allocation. That’s the path 6.0 codifies; doing it ad-hoc in 5.5 races against the cart services that own reservation lifecycle.
  • AvailabilityValidator is the safety net. If a routing decision picks a location that’s actually short on stock, the validator catches it before the order completes.

Next Steps