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).
| 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 subclassesSpree::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
| Method | Required | Description |
|---|---|---|
rank(order, locations) | Yes | Returns 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::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: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”:| Situation | What the reducer does |
|---|---|
All rules abstain (nil) for a location | Falls back to StockLocation.default, then by id |
| One rule returns a unique minimum rank | That location wins; remaining rules skipped |
| One rule returns a tied minimum | The tied locations carry forward; the next rule weighs in only on those |
| All rules tie through the whole chain | Final tiebreak: default location, then by id |
- Returning
0for everything is a reset. If every location ties at0, all locations carry forward — the reducer treats it as “no signal” and moves on. - Coverage-style metrics negate. When higher-is-better, return
-coverageso lower wins. SeeSpree::OrderRouting::Rules::MinimizeSplitsfor the canonical example. - Abstaining yields to other rules.
nilis 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.positionis required andacts_as_list-scoped per channel. Use a number that doesn’t collide with the seeded1/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 (
nilrank). 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 isSpree::OrderRouting::Strategy::Base. There are no defaults — you implement all four methods:
| Method | When it fires | Returns |
|---|---|---|
#for_allocation | Cart → checkout transition (Order#create_proposed_shipments) | Array<Spree::Stock::Package> |
#for_sale(fulfillment:) | A shipment ships | (side effect) |
#for_release | An in-flight order is canceled before shipping | (side effect) |
#for_cancellation | A shipped order is canceled (return) | (side effect) |
app/models/acme/oms/strategy.rb
- 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_allocationreturnsSpree::Stock::Packageobjects. The order’screate_proposed_shipmentsturns those intoShipments by callingpackage.to_shipment. Returning shipments directly will break the call site.- Reuse the existing primitives.
Spree::Stock::Packer,Spree::Stock::Estimator, andSpree::Stock::InventoryUnitBuilderhandle 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 onSpree::Store (default) or Spree::Channel (override).
Activate on the whole store:
channel.preferred_order_routing_strategy → store.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
Common Pitfalls
- Forgetting the lifecycle hooks.
for_releaseandfor_cancellationraiseNotImplementedErrorby 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.activeand respect the order’s reserved units. HardcodingSpree::StockLocation.firstwill 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 theorderandfulfillmentarguments — 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.| Concern | Reservation system | Order routing |
|---|---|---|
| When it fires | Cart mutation (add item, change qty, enter checkout) | Cart → checkout transition |
| What it decides | How many units of a variant are held for this cart | Which StockLocation fulfills the order |
| Granularity | Per-variant | Per-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
StockReservationfrom 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. AvailabilityValidatoris 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
- How orders work — Order lifecycle and the routing context
- Inventory & Stock Locations — Where the locations live
- Build Custom Promotion Rules & Actions — A similar STI-based extension pattern

