Skip to main content

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.

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

Key Method to Implement

Using Preferences

Rules use Spree’s preference system for configuration, the same way Promotion Rules do. Each preference creates getter/setter methods automatically:
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:
Register the rule kind so it’s available to add and passes the type validation. Spree’s autoloader picks up the model under app/models/; the registry is the curated allowlist (it also drives admin pickers):
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”: 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

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: 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
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

First register the class so it’s selectable (this is the allowlist the model validation checks against, and the source for admin strategy pickers):
Then select it by class name string — set on Spree::Store (default) or Spree::Channel (override). Setting an unregistered class fails validation. Activate on the whole store:
Override on one channel only:
Resolution order: channel.preferred_order_routing_strategystore.preferred_order_routing_strategy → the default Strategy::Rules. Only a value pointing to a registered Strategy::Base subclass is used; anything unset, unregistered, or invalid is skipped, so a misconfiguration (or a strategy you’ve since unregistered) falls back to the default instead of breaking checkout.

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

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. 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