Custom Order Grouping Strategies¶
Order grouping strategies determine how basket items are split into separate orders during checkout. The default strategy groups items by warehouse, but you can create custom strategies to group by vendor, product category, delivery date, or any other criteria.
Quick Overview¶
To create a custom order grouping strategy:
- Create a class that implements
IOrderGroupingStrategy - Implement
MetadataandGroupItemsAsync() - Register it in
appsettings.json
How Order Grouping Works¶
During checkout, after the customer provides a shipping address, Merchello runs the order grouping strategy:
Customer submits shipping address
-> CheckoutService calls the configured strategy
-> Strategy receives OrderGroupingContext (basket, addresses, products, warehouses)
-> Strategy returns OrderGroupingResult with one or more OrderGroups
-> Each OrderGroup becomes a separate order with its own shipping options
The Default Strategy¶
The built-in DefaultOrderGroupingStrategy (key default-warehouse) groups items by warehouse. It:
- Selects the best warehouse for each product using the standard warehouse selection order:
ProductRootWarehousepriority → service region eligibility → stock availability (Stock - Reserved >= qty). - Groups items shipping from the same warehouse together.
- Handles multi-warehouse fulfillment (splitting a line item across warehouses).
- Resolves flat-rate shipping costs (via
ShippingCostResolver.ResolveBaseCost()) and fetches dynamic carrier rates (viaIShippingQuoteService). - Publishes
OrderGroupingModifyingNotification(cancelable) andOrderGroupingNotification(read-only).
Creating a Vendor Grouping Strategy¶
Here's a complete example that groups items by vendor (from product extended data):
using Merchello.Core.Checkout.Strategies.Interfaces;
using Merchello.Core.Checkout.Strategies.Models;
using Merchello.Core.Shared.Extensions;
using Merchello.Core.Shipping.Models;
public class VendorOrderGroupingStrategy : IOrderGroupingStrategy
{
public OrderGroupingStrategyMetadata Metadata => new(
Key: "vendor-grouping",
DisplayName: "Vendor Grouping",
Description: "Groups order items by vendor for split fulfillment"
);
public Task<OrderGroupingResult> GroupItemsAsync(
OrderGroupingContext context,
CancellationToken cancellationToken = default)
{
// Validation: country code is required
if (string.IsNullOrWhiteSpace(context.ShippingAddress.CountryCode))
{
return Task.FromResult(
OrderGroupingResult.Fail("Country required"));
}
var groups = new Dictionary<string, OrderGroup>();
var errors = new List<string>();
foreach (var lineItem in context.Basket.LineItems.Where(li => li.ProductId.HasValue))
{
// Get the product
if (!context.Products.TryGetValue(lineItem.ProductId!.Value, out var product))
{
errors.Add($"Product {lineItem.Name} not found");
continue;
}
// Get vendor ID from product root extended data (default to "default")
var vendorId = product.ProductRoot?.ExtendedData
.GetValueOrDefault("VendorId")?.UnwrapJsonElement()?.ToString()
?? "default";
// Create or get group for this vendor
if (!groups.TryGetValue(vendorId, out var group))
{
group = new OrderGroup
{
GroupId = GenerateGroupId(vendorId),
GroupName = $"Vendor: {vendorId}",
Metadata = new Dictionary<string, object>
{
["vendorId"] = vendorId // Identifies this as a vendor group
}
};
groups[vendorId] = group;
}
// Add the line item
group.LineItems.Add(new ShippingLineItem
{
LineItemId = lineItem.Id,
Name = lineItem.Name ?? "",
Sku = lineItem.Sku,
Quantity = lineItem.Quantity,
Amount = lineItem.Amount
});
}
if (errors.Count > 0)
{
return Task.FromResult(new OrderGroupingResult
{
Groups = [],
Errors = errors
});
}
return Task.FromResult(new OrderGroupingResult
{
Groups = groups.Values.ToList(),
SubTotal = context.Basket.SubTotal,
Tax = context.Basket.Tax,
Total = context.Basket.Total
});
}
private static Guid GenerateGroupId(string vendorId)
{
using var md5 = System.Security.Cryptography.MD5.Create();
var hash = md5.ComputeHash(
System.Text.Encoding.UTF8.GetBytes($"vendor:{vendorId}"));
return new Guid(hash);
}
}
Registering Your Strategy¶
Tell Merchello to use your strategy in appsettings.json:
The value must match the Key from your strategy's Metadata.
The OrderGroupingContext¶
Your strategy receives everything it needs through the context object:
public class OrderGroupingContext
{
// The basket with all line items
public required Basket Basket { get; init; }
// Customer addresses
public required Address BillingAddress { get; init; }
public required Address ShippingAddress { get; init; }
// Customer identity (if logged in)
public Guid? CustomerId { get; init; }
public string? CustomerEmail { get; init; }
// Pre-loaded product data (avoids N+1 queries in your strategy)
public required IReadOnlyDictionary<Guid, Product> Products { get; init; }
// Pre-loaded warehouse data
public required IReadOnlyDictionary<Guid, Warehouse> Warehouses { get; init; }
// Previously selected shipping options (keyed by GroupId)
public Dictionary<Guid, string> SelectedShippingOptions { get; init; } = [];
// Per-line-item shipping selections (for order edit flows)
public Dictionary<Guid, (Guid WarehouseId, string SelectionKey)> LineItemShippingSelections { get; init; } = [];
// Custom data you can pass from the checkout
public Dictionary<string, object> ExtendedData { get; init; } = [];
}
Tip: Products and warehouses are pre-loaded into dictionaries to avoid N+1 queries. Always use these dictionaries rather than calling services to look up individual products.
The OrderGroup¶
Each group becomes a separate order:
public class OrderGroup
{
// Deterministic ID (same basket should produce same GroupIds)
public Guid GroupId { get; set; }
// Shown to customers (e.g., "Shipment from London", "Vendor: Acme Corp")
public string GroupName { get; set; } = "";
// Optional warehouse (null for non-warehouse fulfillment like drop-shipping)
public Guid? WarehouseId { get; set; }
// Items in this group
public List<ShippingLineItem> LineItems { get; set; } = [];
// Available shipping options for this group
public List<ShippingOptionInfo> AvailableShippingOptions { get; set; } = [];
// Currently selected shipping option key
// Format: "so:{guid}" for flat-rate, "dyn:{provider}:{serviceCode}" for dynamic
public string? SelectedShippingOptionId { get; set; }
// Custom metadata (e.g., vendor info, fulfillment source)
public Dictionary<string, object> Metadata { get; set; } = [];
}
The OrderGroupingResult¶
public class OrderGroupingResult
{
public List<OrderGroup> Groups { get; set; } = [];
public List<string> Errors { get; set; } = [];
public List<string> StockErrors { get; set; } = [];
public bool Success => Errors.Count == 0 && Groups.Count > 0;
public decimal SubTotal { get; set; }
public decimal Tax { get; set; }
public decimal Total { get; set; }
// Convenience factory methods
public static OrderGroupingResult Fail(string error) => new() { Errors = [error] };
public static OrderGroupingResult Fail(IEnumerable<string> errors) => new() { Errors = errors.ToList() };
}
Notifications¶
The default strategy publishes two notifications during grouping:
OrderGroupingModifyingNotification-- Cancelable. Handlers can modify the result or cancel grouping.OrderGroupingNotification-- Read-only observation after grouping is complete.
If your custom strategy needs the same extensibility, publish these notifications yourself:
var modifying = new OrderGroupingModifyingNotification(context, result, Metadata.Key);
if (await notificationPublisher.PublishCancelableAsync(modifying, cancellationToken))
{
return OrderGroupingResult.Fail(modifying.CancelReason ?? "Grouping cancelled");
}
await notificationPublisher.PublishAsync(
new OrderGroupingNotification(context, result, Metadata.Key), cancellationToken);
Important Rules¶
-
GroupIds must be deterministic. The same basket and address combination should always produce the same GroupIds. This ensures shipping selections persist across requests.
-
Validate early. If
ShippingAddress.CountryCodeis empty, fail withOrderGroupingResult.Fail("Country required"). -
Use
UnwrapJsonElement()when reading values fromExtendedData. Dictionary values may beJsonElementrather than CLR types after deserialization. -
Never use
Task.WhenAllfor parallel service calls. Umbraco'sEFCoreScopeusesAsyncLocalstate that breaks with concurrent tasks. -
Use constructor injection only. Strategies are activated via
ActivatorUtilities.CreateInstance. Do not rely on setter injection, service locator calls, or post-construction configuration. See Extension Manager. -
Honor the shipping selection key contract when populating
OrderGroup.SelectedShippingOptionId. Useso:{guid}for flat-rate selections anddyn:{providerKey}:{serviceCode}for dynamic carrier selections -- the checkout parses these intoOrder.ShippingProviderKey/ShippingServiceCode/ShippingServiceName. See Creating Shipping Providers.