Inventory and Stock Management¶
Merchello tracks stock levels per product variant per warehouse. This guide covers the stock lifecycle, multi-warehouse stock, availability checking, and the notifications that fire at each stage.
Invariant: All stock mutations must go through
IInventoryService. Never adjustStock,ReservedStock, orTrackStockdirectly on aProductWarehouse-- the service enforces the lifecycle rules below, handles optimistic concurrency, and publishes the notifications that other subsystems listen on.
Source: IInventoryService.cs, ProductWarehouse.cs.
Core Concepts¶
Stock Tracking¶
Each product-warehouse combination (ProductWarehouse) has:
- Stock -- the total physical units in the warehouse.
- ReservedStock -- units reserved by pending orders (not yet shipped).
- TrackStock -- whether stock tracking is enabled. When
false, the product has unlimited availability. - ReorderPoint -- optional threshold that triggers a low stock notification when stock falls to or below this level.
Available stock is calculated as: Stock - ReservedStock
When TrackStock = false, the service returns int.MaxValue for available stock and skips all stock modifications silently (operations succeed immediately).
Stock Lifecycle¶
The stock lifecycle follows a clear four-step pattern from order placement to completion (or cancellation):
Customer places order --> Reserve
Order ships --> Allocate
Order cancelled --> Release (undo reservation)
Shipment returned --> Reverse (undo allocation)
1. Reserve¶
When a customer places an order, stock is reserved so other customers cannot purchase the same units:
var result = await inventoryService.ReserveStockAsync(
productId,
warehouseId,
quantity,
cancellationToken);
What happens: ReservedStock += quantity
The service validates that Stock - ReservedStock >= quantity before reserving. If insufficient stock is available, the operation fails with an error message.
2. Allocate¶
When the order ships, reserved stock is converted to a physical deduction:
var result = await inventoryService.AllocateStockAsync(
productId,
warehouseId,
quantity,
cancellationToken);
What happens: Stock -= quantity AND ReservedStock -= quantity
After allocation, if the remaining stock falls to or below the ReorderPoint, a LowStockNotification is automatically published. If stock reaches zero and this was the default variant, Merchello automatically reassigns the default variant to another available variant.
3. Release (Cancel)¶
When an order is cancelled before shipping, the reservation is released:
var result = await inventoryService.ReleaseReservationAsync(
productId,
warehouseId,
quantity,
cancellationToken);
What happens: ReservedStock -= quantity (clamped to zero)
4. Reverse (Return)¶
When a shipped item is returned, the allocation is reversed:
var result = await inventoryService.ReverseAllocationAsync(
productId,
warehouseId,
quantity,
cancellationToken);
What happens: Stock += quantity (ReservedStock is not modified because allocation already removed it)
Checking Availability¶
Single Product¶
// Returns available units, or int.MaxValue if not tracked
int available = await inventoryService.GetAvailableStockAsync(
productId,
warehouseId,
cancellationToken);
Order Validation¶
Before processing an order, validate that all line items have sufficient stock:
var result = await inventoryService.ValidateStockAvailabilityAsync(order, cancellationToken);
if (!result.Success)
{
// result.Messages contains per-item stock errors
// e.g. "Insufficient stock for Blue T-Shirt. Available: 2, Required: 5"
}
Basket Validation (Bulk)¶
For checking availability of all items in a basket at once (single database round-trip):
var items = basketLineItems.Select(li => (li.ProductId, li.WarehouseId, li.Quantity));
var result = await inventoryService.ValidateBasketStockAsync(items, cancellationToken);
if (!result.IsValid)
{
foreach (var issue in result.UnavailableItems)
{
// issue.ProductName, issue.RequestedQuantity, issue.AvailableQuantity
}
}
This method aggregates quantities per product-warehouse combination (handles split quantities) and loads all stock data in a single query for efficiency.
Check If Tracking Is Enabled¶
bool isTracked = await inventoryService.IsStockTrackedAsync(
productId,
warehouseId,
cancellationToken);
Concurrency Handling¶
All stock operations use optimistic concurrency with retry logic. If two operations try to modify the same product-warehouse stock simultaneously, one will receive a DbUpdateConcurrencyException. The service automatically retries up to 3 times with increasing delay (10ms, 20ms, 30ms).
If all retries fail, the operation returns an error message like "Stock reservation failed due to concurrent updates. Please try again."
Notifications¶
Every stock operation publishes notifications that you can hook into for custom logic. Each operation has a "before" (cancellable) and "after" notification:
| Operation | Before (Cancellable) | After |
|---|---|---|
| Reserve | StockReservingNotification |
StockReservedNotification |
| Release | StockReleasingNotification |
StockReleasedNotification |
| Allocate | StockAllocatingNotification |
StockAllocatedNotification |
| Reverse | -- | StockAdjustedNotification |
| Low Stock | -- | LowStockNotification |
Cancelling a Stock Operation¶
The "before" notifications are cancellable. Your handler can prevent the operation:
public class MyStockHandler : INotificationHandler<StockReservingNotification>
{
public Task HandleAsync(StockReservingNotification notification, CancellationToken ct)
{
if (ShouldPreventReservation(notification.ProductId))
{
notification.Cancel("Custom reason: product is temporarily held");
}
return Task.CompletedTask;
}
}
Low Stock Alerts¶
The LowStockNotification fires after allocation when remaining stock drops to or below the ReorderPoint. It includes the product ID, warehouse ID, product name, remaining stock, and reorder point. Use this to trigger email alerts, Slack messages, or automatic reorder workflows.
Multi-Warehouse Stock¶
When a product exists in multiple warehouses, Merchello selects the best warehouse using a strict priority order that must be preserved by any custom grouping logic:
ProductRootWarehouse.Priority-- warehouses linked to the product with a priority value.- Service region eligibility -- the warehouse must be able to ship to the customer's country/region.
- Stock availability -- the warehouse must have
Stock - ReservedStock >= requested quantity.
Tip: Use
IWarehouseService.SelectWarehouseForProduct()to get the best warehouse for a product and shipping destination. This is what the checkout order grouping strategy uses internally (DefaultOrderGroupingStrategy.cs:84).
Key Points¶
- Stock is tracked per product variant per warehouse, not at the product root level.
- When
TrackStock = false, all stock operations are no-ops that succeed silently. - Available stock =
Stock - ReservedStock. Never useStockalone. - All mutations return
CrudResult<bool>-- always checkresult.Success. - Concurrency conflicts are handled automatically with up to 3 retries.
- Low stock notifications fire automatically when stock drops to or below the reorder point after allocation.