Model product stock

Advanced license required

Features described on this page require the Xperience by Kentico Advanced license tier.

Product stock management is a critical component of any commerce system. This page covers key considerations for implementing a product stock model in Xperience by Kentico.

The product stock model manages the relationship between products and their available quantities. It provides functionality to track, update, and display stock levels while handling various business scenarios such as purchases, returns, and restocking. We recommend that you implement the product stock model as a custom module to ensure flexibility and maintainability.

To create a custom product stock management module, follow these main steps:

  1. Create the core custom module with database structure and object dependencies.
  2. Build administration interfaces for listing and editing product stock records.
  3. Implement automatic or manual stock record creation processes.
  4. Handle business logic of stock updates for purchases, returns, and reservations.
  5. Implement validation and business rules to ensure data integrity and implement business constraints.
  6. Display product stock information to customers on your storefront.

Product stock module

Create a custom module to handle product stock management. Within the module, create a class with the necessary fields. Each record of this class represents the stock level for a specific product. The fields typically include:

  • Content item ID – unique identifier linking to the product’s content item.
    • You need to manually set the ProductStock object as a child of cms.contentitem in the generated object’s TYPEINFO configuration. This ensures that the stock information is always associated with a product and that ProductStock is automatically deleted together with the parent.

      C#
      Example - ProductStockInfo
      
                         public static readonly ObjectTypeInfo TYPEINFO = new ObjectTypeInfo(typeof(IInfoProvider<ProductStockInfo>), OBJECT_TYPE, "Codesamples.ProductStock", nameof(ProductStockID), null, null, null, null, null, null, null)
                         {
                             TouchCacheDependencies = true,
                             DependsOn =
                             [
                                 new ObjectDependency(nameof(ProductStockContentItemID), "cms.contentitem", ObjectDependencyEnum.Required)
                             ]
                         };
      
  • Product stock value – current quantity available for purchase.

Depending on your requirements, you may use a simple implementation with just content item ID and product stock value, or a more complex model that also includes fields such as reserved stock for pending orders and minimum threshold value.

Management interfaces

The management interface consists of several interconnected components that work together to provide a complete administration experience for product stock records. Create the following UI pages to manage product stock.

Create the application entry point

First, create an application class to register your product stock management interface in the administration:

C#
ProductStockApplication.cs

                     using CMS.Membership;
                     
                     using Kentico.Xperience.Admin.Base;
                     using Kentico.Xperience.Admin.DigitalCommerce.UIPages;
                     
                     using Codesamples.Commerce.Admin;
                     
                     // Registers the application in the admin interface
                     [assembly: UIApplication(
                         identifier: ProductStockApplication.IDENTIFIER,
                         type: typeof(ProductStockApplication),
                         slug: "productstock",
                         name: "Product stock",
                         category: DigitalCommerceApplicationCategories.DIGITAL_COMMERCE,
                         icon: Icons.MoneyBill,
                         templateName: TemplateNames.SECTION_LAYOUT)]
                     
                     namespace Codesamples.Commerce.Admin;
                     
                     /// <summary>
                     /// Product stock management application for the admin interface.
                     /// </summary>
                     // Sets permissions required to access the application
                     // Create and delete permissions are not needed if stock records are created and deleted automatically with products
                     [UIPermission(SystemPermissions.VIEW, "{$base.roles.permissions.view$}")]
                     [UIPermission(SystemPermissions.UPDATE, "{$base.roles.permissions.update}")]
                     public sealed class ProductStockApplication : ApplicationPage
                     {
                         /// <summary>
                         /// Unique identifier for the product stock application.
                         /// </summary>
                         public const string IDENTIFIER = "Codesamples.Application.ProductStock";
                     }

Create the listing page

The listing page displays all product stock records with product names, SKUs, and stock levels. Important: To display product information, you must join the CMS_ContentItemLanguageMetadata and CMS_ContentItemCommonData tables to obtain the product display name and product SKU.

C#
ProductStockList.cs

                     using System;
                     using System.Collections.Generic;
                     using System.Threading.Tasks;
                     
                     using CMS.Base;
                     using CMS.Commerce;
                     using CMS.DataEngine;
                     
                     using Codesamples.Commerce.Admin;
                     
                     using Kentico.Xperience.Admin.Base;
                     
                     [assembly: UIPage(
                         parentType: typeof(ProductStockApplication),
                         slug: "list",
                         uiPageType: typeof(ProductStockList),
                         name: "List of product stock",
                         templateName: TemplateNames.LISTING,
                         order: UIPageOrder.First)]
                     
                     namespace Codesamples.Commerce.Admin;
                     
                     /// <summary>
                     /// Product stock listing page that displays all stock records with product information.
                     /// </summary>
                     public sealed class ProductStockList : ListingPage
                     {
                         private readonly DefaultContentLanguageRetriever defaultContentLanguageRetriever;
                         private readonly IProductQuantityFormatter productQuantityFormatter;
                     
                         public ProductStockList(
                             DefaultContentLanguageRetriever defaultContentLanguageRetriever,
                             IProductQuantityFormatter productQuantityFormatter)
                         {
                             this.defaultContentLanguageRetriever = defaultContentLanguageRetriever;
                             this.productQuantityFormatter = productQuantityFormatter;
                         }
                     
                         /// <summary>
                         /// Specifies which object type this listing page manages.
                         /// </summary>
                         protected override string ObjectType => ProductStockInfo.OBJECT_TYPE;
                     
                         public override async Task ConfigurePage()
                         {
                             // Gets the default language to ensure consistent data retrieval across multilingual content
                             var defaultContentLanguage = await defaultContentLanguageRetriever.Get();
                     
                             // Adds edit action for each row in the listing
                             PageConfiguration.AddEditRowAction<ProductStockEditSection>();
                     
                             // Configures the columns that will be displayed in the listing grid
                             PageConfiguration.ColumnConfigurations
                                             // Product name comes from ContentItemLanguageMetadata table
                                             .AddColumn("ContentItemLanguageMetadataDisplayName", "Product name", searchable: true)
                                             // Stock value from the ProductStockInfo table with custom formatting
                                             .AddColumn(nameof(ProductStockInfo.ProductStockValue), "Stock", formatter: StockFormatter);
                     
                             // Joins necessary tables to retrieve product information
                             // This is required because ProductStockInfo only contains ContentItemID, not product details
                             PageConfiguration.QueryModifiers.AddModifier(query =>
                                 query.Source(
                                     s => s
                                         // Joins with ContentItemLanguageMetadata to get product display names
                                         .Join(
                                             "CMS_ContentItemLanguageMetadata",
                                             new WhereCondition($"[CMS_ContentItemLanguageMetadata].[ContentItemLanguageMetadataContentItemID] = [{ProductStockInfo.TYPEINFO.ClassStructureInfo.TableName}].[{nameof(ProductStockInfo.ProductStockContentItemID)}]")
                                                 // Filters by default language to avoid duplicate rows in multilingual setups
                                                 .And(new WhereCondition().WhereEquals("ContentItemLanguageMetadataContentLanguageID", defaultContentLanguage.ContentLanguageID))
                                         )
                                         // Joins with ContentItemCommonData to get reusable field schema data (like SKU)
                                         .Join(
                                             "CMS_ContentItemCommonData",
                                             new WhereCondition($"[CMS_ContentItemCommonData].[ContentItemCommonDataContentItemID] = [{ProductStockInfo.TYPEINFO.ClassStructureInfo.TableName}].[{nameof(ProductStockInfo.ProductStockContentItemID)}]")
                                                 // Filters by default language for consistency
                                                 .And(new WhereCondition().WhereEquals("ContentItemCommonDataContentLanguageID", defaultContentLanguage.ContentLanguageID))
                                         )
                                 )
                             );
                     
                             // Adds an info callout
                             PageConfiguration.Callouts ??= new List<CalloutConfiguration>();
                             PageConfiguration.Callouts.Add(new CalloutConfiguration
                             {
                                 Type = CalloutType.QuickTip,
                                 Headline = "IMPORTANT",
                                 ContentAsHtml = false,
                                 Content = @"
                                         The product stock implementation is for demo purposes in the documentation only.
                                         It is not used or reflected in the main demo implementation in this CodeSamples project.
                                     ",
                                 Placement = CalloutPlacement.OnDesk
                             });
                     
                             await base.ConfigurePage();
                         }
                     
                         /// <summary>
                         /// Formats the stock value for display using the commerce quantity formatter.
                         /// </summary>
                         private string StockFormatter(object value, IDataContainer dataContainer)
                         {
                             return productQuantityFormatter.Format((decimal)value, new ProductQuantityFormatContext());
                         }
                     }

Create the edit section

The edit section defines URL parameterization and routing for editing specific product stock records. It acts as a container or entry point for the edit operation:

C#
ProductStockEditSection.cs

                     using System.Threading.Tasks;
                     
                     using CMS.DataEngine;
                     
                     using Kentico.Xperience.Admin.Base;
                     
                     using Codesamples.Commerce.Admin;
                     
                     [assembly: UIPage(
                         parentType: typeof(Codesamples.Commerce.Admin.ProductStockList),
                         slug: PageParameterConstants.PARAMETERIZED_SLUG,
                         uiPageType: typeof(ProductStockEditSection),
                         name: "Product stock section caption",
                         templateName: TemplateNames.SECTION_LAYOUT,
                         order: UIPageOrder.NoOrder)]
                     
                     namespace Codesamples.Commerce.Admin;
                     
                     /// <summary>
                     /// Edit section that handles URL parameterization and routing for editing specific product stock records.
                     /// Acts as a container/entry point for the edit operation.
                     /// </summary>
                     public sealed class ProductStockEditSection : EditSectionPage<ProductStockInfo>
                     {
                         private readonly ProductMetadataRetriever productMetadataRetriever;
                     
                         public ProductStockEditSection(ProductMetadataRetriever productMetadataRetriever)
                         {
                             this.productMetadataRetriever = productMetadataRetriever;
                         }
                     
                         /// <summary>
                         /// Gets the display name for the object being edited (used in breadcrumbs and page titles).
                         /// </summary>
                         protected override async Task<string> GetObjectDisplayName(BaseInfo infoObject)
                         {
                             // Retrieves the product name for display in the edit section header
                             // This ensures the page shows "Edit Product Stock - [Product Name]" instead of just an ID
                             if (infoObject is ProductStockInfo productStockInfo)
                             {
                                 var product = await productMetadataRetriever.GetProductMetadata(productStockInfo);
                                 return product!.DisplayName;
                             }
                     
                             return base.GetObjectDisplayName(infoObject).Result;
                         }
                     }

Create the edit page

Create a UI form for editing product stock records:

  1. Navigate to the Modules application → select your stock management module → Classes tab → select your product stock class → UI forms tab.

  2. Create a New UI form with the name ProductStockEdit (code name).

  3. Switch to the Fields tab and add the fields you want to display on the form:

    • Product stock value field
      • Database column: The field of the product stock class that stores the stock value
      • Field caption: Available stock value
      • Enabled: Yes, selected
      • Form component: Decimal number input
    • (Optional) Product name field (for context, typically read-only)
      • Database column: New field without database column
      • Field name: ProductStockProductName
      • Data type: Text
      • Field caption: Product name
      • Enabled: No, not selected (this makes the field read-only)
      • Form component: Text input
      • You need to ensure that the product name field is populated programmatically in the edit page code since it does not have a database column.

The edit page then renders the actual editing form where users interact with the product stock data:

C#
ProductStockEdit.cs

                     using System;
                     using System.Collections.Generic;
                     using System.Linq;
                     using System.Threading.Tasks;
                     
                     using CMS.ContentEngine;
                     
                     using Kentico.Xperience.Admin.Base;
                     using Kentico.Xperience.Admin.Base.Forms;
                     
                     using Codesamples.Commerce.Admin;
                     
                     [assembly: UIPage(
                         parentType: typeof(ProductStockEditSection),
                         slug: "general",
                         uiPageType: typeof(ProductStockEdit),
                         name: "General",
                         templateName: TemplateNames.EDIT,
                         order: UIPageOrder.First)]
                     
                     namespace Codesamples.Commerce.Admin;
                     
                     /// <summary>
                     /// The actual edit page where the editing form is rendered and user interaction happens.
                     /// Inherits from InfoEditPage&lt;ProductStockInfo&gt; to provide standard CRUD functionality.
                     /// </summary>
                     public sealed class ProductStockEdit : InfoEditPage<ProductStockInfo>
                     {
                         /// <summary>
                         /// Name of the UI form definition created in the admin interface.
                         /// </summary>
                         private const string UI_FORM_COMPONENT_NAME = "codesamples_productstockedit";
                     
                         /// <summary>
                         /// Name of the field used to display the product name.
                         /// </summary>
                         private const string PRODUCT_NAME_COMPONENT_NAME = "ProductStockProductName";
                     
                         private readonly ProductMetadataRetriever productMetadataRetriever;
                     
                         /// <summary>
                         /// Object ID parameter from the URL route (e.g., /edit/123).
                         /// </summary>
                         [PageParameter(typeof(IntPageModelBinder))]
                         public override int ObjectId { get; set; }
                     
                         public ProductStockEdit(
                             IFormComponentMapper formComponentMapper,
                             IFormDataBinder formDataBinder,
                             ProductMetadataRetriever productMetadataRetriever)
                             : base(formComponentMapper, formDataBinder)
                         {
                             this.productMetadataRetriever = productMetadataRetriever;
                         }
                     
                         public override async Task ConfigurePage()
                         {
                             // Specifies which UI form definition to use
                             PageConfiguration.UIFormName = UI_FORM_COMPONENT_NAME;
                             PageConfiguration.Headline = "Edit product stock";
                     
                             await base.ConfigurePage();
                         }
                     
                         /// <summary>
                         /// Retrieves and modifies form items before they are displayed to the user.
                         /// </summary>
                         protected override async Task<ICollection<IFormItem>> GetFormItems()
                         {
                             ICollection<IFormItem> formItems = await base.GetFormItems();
                     
                             // Sets up the product name component to show which product this stock record belongs to
                             await SetupProductNameComponent(formItems);
                     
                             return formItems;
                         }
                     
                         /// <summary>
                         /// Sets up the product name component to display the associated product's name.
                         /// This provides context to administrators about which product they're editing stock for.
                         /// </summary>
                         private async Task SetupProductNameComponent(ICollection<IFormItem> formItems)
                         {
                             // Gets the current product stock record being edited
                             ProductStockInfo? productStockInfo = await GetInfoObject();
                             
                             if (productStockInfo == null)
                             {
                                 return;
                             }
                     
                             // Retrieves the product metadata to get the display name
                             ContentItemLanguageMetadata? productMetadata = await productMetadataRetriever.GetProductMetadata(productStockInfo);
                             
                             if (productMetadata == null)
                             {
                                 return;
                             }
                     
                             // Finds the product name form component and sets its value
                             IFormComponent? productNameComponent = formItems.OfType<IFormComponent>()
                                 .FirstOrDefault(f => f.Name.Equals(PRODUCT_NAME_COMPONENT_NAME, StringComparison.OrdinalIgnoreCase));
                     
                             // Sets the product name (this is typically a read-only field for context)
                             productNameComponent?.SetObjectValue(productMetadata.DisplayName);
                         }
                     }

Create the product metadata retriever

The ProductMetadataRetriever must be implemented to retrieve product display names and other metadata. This service is essential for displaying correct product information in the management interface:

C#
ProductMetadataRetriever.cs

                     using System.Threading.Tasks;
                     
                     using CMS.ContentEngine;
                     
                     using Kentico.Xperience.Admin.Base;
                     using Kentico.Xperience.Admin.Base.Authentication;
                     using Kentico.Xperience.Admin.Base.Forms;
                     
                     using Microsoft.AspNetCore.Http;
                     using Microsoft.Extensions.DependencyInjection;
                     
                     using Codesamples.Commerce.Admin;
                     
                     namespace Codesamples.Commerce;
                     
                     /// <summary>
                     /// Service responsible for retrieving product metadata (display names, etc.) from content items.
                     /// This is essential for displaying meaningful product information in the admin interface.
                     /// </summary>
                     public sealed class ProductMetadataRetriever
                     {
                         private readonly IContentItemManagerFactory contentItemManagerFactory;
                         private readonly IHttpContextAccessor httpContextAccessor;
                         private readonly DefaultContentLanguageRetriever defaultContentLanguageRetriever;
                     
                         public ProductMetadataRetriever(
                             IContentItemManagerFactory contentItemManagerFactory,
                             IHttpContextAccessor httpContextAccessor,
                             DefaultContentLanguageRetriever defaultContentLanguageRetriever)
                         {
                             this.contentItemManagerFactory = contentItemManagerFactory;
                             this.httpContextAccessor = httpContextAccessor;
                             this.defaultContentLanguageRetriever = defaultContentLanguageRetriever;
                         }
                     
                         /// <summary>
                         /// Retrieves product metadata for a given product stock record.
                         /// </summary>
                         /// <param name="productStockInfo">The product stock record.</param>
                         /// <returns>Content item language metadata containing the product's display name and other properties.</returns>
                         public async Task<ContentItemLanguageMetadata> GetProductMetadata(ProductStockInfo productStockInfo)
                         {
                             // Uses the default language to ensure consistent metadata retrieval
                             ContentLanguageInfo defaultContentLanguage = await defaultContentLanguageRetriever.Get();
                     
                             // Gets the current authenticated user for content manager context
                             var currentUser = await httpContextAccessor.HttpContext!.RequestServices
                                 .GetRequiredService<IAuthenticatedUserAccessor>().Get();
                     
                             // Creates content manager with proper user context for security
                             IContentItemManager contentItemManager = contentItemManagerFactory.Create(currentUser.UserID);
                     
                             // Retrieves the product metadata using the content item ID stored in the stock record
                             // This gets the product name and other language-specific metadata
                             ContentItemLanguageMetadata productMetadata = await contentItemManager.GetContentItemLanguageMetadata(
                                 productStockInfo.ProductStockContentItemID,
                                 defaultContentLanguage.ContentLanguageName);
                     
                             return productMetadata;
                         }
                     }

Create the default content language retriever

Create a service to retrieve the default content language, which is needed for consistent data retrieval:

C#
DefaultContentLanguageRetriever.cs

                     using System.Linq;
                     using System.Threading;
                     using System.Threading.Tasks;
                     
                     using CMS.ContentEngine;
                     using CMS.DataEngine;
                     using CMS.Helpers;
                     
                     namespace Codesamples.Commerce;
                     
                     /// <summary>
                     /// Service for retrieving the default content language.
                     /// This ensures consistent data retrieval across multilingual content scenarios.
                     /// </summary>
                     public sealed class DefaultContentLanguageRetriever
                     {
                         /// <summary>
                         /// Cache duration in minutes (24 hours).
                         /// </summary>
                         private const int ONE_DAY = 24 * 60;
                     
                         private readonly IInfoProvider<ContentLanguageInfo> contentLanguageInfoProvider;
                         private readonly IProgressiveCache progressiveCache;
                         private readonly ICacheDependencyBuilderFactory cacheDependencyBuilderFactory;
                     
                         public DefaultContentLanguageRetriever(
                             IInfoProvider<ContentLanguageInfo> contentLanguageInfoProvider,
                             IProgressiveCache progressiveCache,
                             ICacheDependencyBuilderFactory cacheDependencyBuilderFactory)
                         {
                             this.contentLanguageInfoProvider = contentLanguageInfoProvider;
                             this.progressiveCache = progressiveCache;
                             this.cacheDependencyBuilderFactory = cacheDependencyBuilderFactory;
                         }
                     
                         /// <summary>
                         /// Retrieves the default content language with caching for performance.
                         /// </summary>
                         /// <param name="cancellationToken">Cancellation token.</param>
                         /// <returns>The default ContentLanguageInfo.</returns>
                         public async Task<ContentLanguageInfo> Get(CancellationToken cancellationToken = default)
                         {
                             // Uses progressive cache to avoid repeated database queries
                             return (await progressiveCache.LoadAsync(async (cacheSettings, token) =>
                             {
                                 // Sets up cache dependency to invalidate when languages change
                                 var cacheDependencyBuilder = cacheDependencyBuilderFactory.Create();
                                 cacheSettings.CacheDependency = cacheDependencyBuilder
                                     .ForInfoObjects<ContentLanguageInfo>()
                                         .All()
                                         .Builder()
                                     .Build();
                     
                                 // Queries for the default language (marked as default in the system)
                                 var result = await contentLanguageInfoProvider.Get()
                                    .WhereTrue(nameof(ContentLanguageInfo.ContentLanguageIsDefault))
                                    .TopN(1)
                                    .GetEnumerableTypedResultAsync(cancellationToken: cancellationToken);
                     
                                 return result.FirstOrDefault();
                             },
                             // Caches for one day with automatic invalidation when content languages change
                             new CacheSettings(ONE_DAY, true, nameof(DefaultContentLanguageRetriever), nameof(Get)), cancellationToken))!;
                         }
                     }

Register dependencies

Finally, you need to register the dependencies in your dependency injection container:

C#

// Add these services to your Program.cs or service registration
// These are required for the product stock management interface to function properly
builder.Services.AddTransient<ProductMetadataRetriever>();
builder.Services.AddTransient<DefaultContentLanguageRetriever>();

Product stock creation

Automatic stock creation

Consider implementing automatic stock creation when new products are added:

  • Set up event handlers for product creation
  • Initialize default stock values based on business rules
  • Ensure proper data consistency between products and stock

The following code snippet demonstrates a simple event handler for automatic stock creation using the modern object event handling pattern.

C#
Example - Automatic stock creation

                     using System.Linq;
                     
                     using CMS.ContentEngine;
                     using CMS.DataEngine;
                     
                     namespace Codesamples.Commerce.Admin;
                     
                     /// <summary>
                     /// Event handler triggered after a new content item is created.
                     /// Automatically creates a stock record for product content items.
                     /// </summary>
                     public class ProductStockCreationHandler
                     {
                         /// <summary>
                         /// An instance of the product stock info provider, obtained via dependency injection.
                         /// </summary>
                         private readonly IInfoProvider<ProductStockInfo> productStockInfoProvider;
                     
                         public ProductStockCreationHandler(IInfoProvider<ProductStockInfo> productStockInfoProvider)
                         {
                             this.productStockInfoProvider = productStockInfoProvider;
                         }
                     
                         /// <summary>
                         /// Handles the Create.After event for content items.
                         /// </summary>
                         public void HandleContentItemCreated(object? sender, CreateContentItemEventArgs args)
                         {
                             // Only handle product content items
                             if (IsProductContentItem(args.ContentTypeName) && args.ID.HasValue)
                             {
                                 CreateProductStockRecord(args.ID.Value);
                             }
                         }
                     
                         /// <summary>
                         /// Checks if the content type is a product.
                         /// </summary>
                         private static bool IsProductContentItem(string contentTypeName)
                         {
                             // Replace "Codesamples.ProductSKU" with your actual product content type name
                             return contentTypeName.Equals("Codesamples.ProductSKU", System.StringComparison.OrdinalIgnoreCase);
                         }
                     
                         /// <summary>
                         /// Creates a product stock record asynchronously.
                         /// </summary>
                         private void CreateProductStockRecord(int contentItemId)
                         {
                             // Checks if stock record already exists
                             var existingProductStock =
                                 productStockInfoProvider
                                     .Get()
                                     .WhereEquals(nameof(ProductStockInfo.ProductStockContentItemID), contentItemId)
                                     .GetEnumerableTypedResult();
                     
                             if (!existingProductStock.Any())
                             {
                                 // Creates a new product stock record
                                 productStockInfoProvider.Set(new ProductStockInfo
                                 {
                                     ProductStockContentItemID = contentItemId,
                                     ProductStockValue = 0,
                                 });
                             }
                         }
                     }

To use this event handler, you must register it in your application’s service collection. Create a custom module and override the OnPreInit method:

C#
Register the event handler

                     using Microsoft.Extensions.DependencyInjection;
                     
                     using CMS;
                     using CMS.ContentEngine;
                     using CMS.Core;
                     using CMS.DataEngine;
                     
                     using Codesamples.Commerce.Admin;
                     
                     [assembly: RegisterModule(typeof(ProductStockModule))]
                     
                     namespace Codesamples.Commerce.Admin;
                     
                     /// <summary>
                     /// Module for product stock management.
                     /// Registers event handlers for automatic stock creation.
                     /// </summary>
                     public class ProductStockModule : Module
                     {
                         public ProductStockModule() : base(nameof(ProductStockModule))
                         {
                         }
                     
                         /// <summary>
                         /// OnPreInit allows you to access IServiceCollection via ModulePreInitParameters.
                         /// </summary>
                         protected override void OnPreInit(ModulePreInitParameters parameters)
                         {
                             base.OnPreInit(parameters);
                     
                             // Registers services
                             parameters.Services.AddTransient<ProductMetadataRetriever>();
                             parameters.Services.AddTransient<DefaultContentLanguageRetriever>();
                             parameters.Services.AddTransient<ProductStockCreationHandler>();
                         }
                     
                         protected override void OnInit(ModuleInitParameters parameters)
                         {
                             base.OnInit(parameters);
                     
                             // Registers the product stock creation event handler
                             var handler = parameters.Services.GetRequiredService<ProductStockCreationHandler>();
                             ContentItemEvents.Create.After += handler.HandleContentItemCreated;
                         }
                     }

Business logic implementation

After establishing the product stock module and management interfaces, implement the business logic that connects stock management with your commerce operations. This includes reducing stock during purchases, restoring stock for returns, and optionally implementing temporary stock reservations during checkout.

Purchase transactions

Stock reduction is a critical operation that occurs after an order is successfully placed. The timing and approach depend on your business requirements—you may reduce stock immediately during checkout, after payment confirmation, or when an order reaches a specific status.

The following service methods demonstrate stock reduction logic. The ReduceStockForOrder method accepts a dictionary mapping content item IDs to quantities, making it suitable for integration with order creation workflows:

C#
Example - Reduce stock for order

                         private readonly IInfoProvider<ProductStockInfo> productStockInfoProvider;
                         private readonly ILogger<ProductStockService> logger;
                     
                         public ProductStockService(
                             IInfoProvider<ProductStockInfo> productStockInfoProvider,
                             ILogger<ProductStockService> logger)
                         {
                             this.productStockInfoProvider = productStockInfoProvider;
                             this.logger = logger;
                         }
                     
                         /// <summary>
                         /// Reduces stock levels for all items in an order after successful purchase.
                         /// </summary>
                         /// <param name="orderedItems">Dictionary mapping ContentItemID to ordered quantity.</param>
                         /// <param name="orderNumber">Order number for logging purposes.</param>
                         /// <param name="cancellationToken">Cancellation token.</param>
                         public async Task ReduceStockForOrder(
                             Dictionary<int, decimal> orderedItems,
                             string orderNumber,
                             CancellationToken cancellationToken = default)
                         {
                             foreach (KeyValuePair<int, decimal> orderedItem in orderedItems)
                             {
                                 try
                                 {
                                     // Retrieves the product stock information for the ordered item
                                     ProductStockInfo? productStockInfo = (await productStockInfoProvider
                                         .Get()
                                         .WhereEquals(nameof(ProductStockInfo.ProductStockContentItemID), orderedItem.Key)
                                         .GetEnumerableTypedResultAsync(cancellationToken: cancellationToken))
                                         .FirstOrDefault();
                     
                                     // Digital products may not have any associated stock
                                     if (productStockInfo != null)
                                     {
                                         // Decreases the stock value by the ordered quantity
                                         productStockInfo.ProductStockValue -= orderedItem.Value;
                     
                                         // Updates the product stock record
                                         await productStockInfoProvider.SetAsync(productStockInfo, cancellationToken);
                     
                                         logger.LogInformation(
                                             "Reduced stock for ContentItemID {ContentItemID} by {Quantity}. New stock: {NewStock}",
                                             orderedItem.Key, orderedItem.Value, productStockInfo.ProductStockValue);
                                     }
                                 }
                                 catch (Exception ex)
                                 {
                                     logger.LogError(ex, "Failed to reduce stock for ContentItemID {ContentItemID} in order {OrderNumber}",
                                         orderedItem.Key, orderNumber);
                                     throw;
                                 }
                             }
                         }

For scenarios where you have shopping cart data available, use the ReduceStockFromCart method, which automatically extracts product identifiers and quantities from the cart model:

C#
Example - Reduce stock from cart

                         private readonly IInfoProvider<ProductStockInfo> productStockInfoProvider;
                         private readonly ILogger<ProductStockService> logger;
                     
                         public ProductStockService(
                             IInfoProvider<ProductStockInfo> productStockInfoProvider,
                             ILogger<ProductStockService> logger)
                         {
                             this.productStockInfoProvider = productStockInfoProvider;
                             this.logger = logger;
                         }
                     
                         /// <summary>
                         /// Reduces stock levels based on shopping cart data after order creation.
                         /// Extracts ContentItemIDs from cart items and reduces corresponding stock.
                         /// </summary>
                         /// <param name="cartData">Shopping cart data containing items to process.</param>
                         /// <param name="orderNumber">Order number for logging purposes.</param>
                         /// <param name="cancellationToken">Cancellation token.</param>
                         public async Task ReduceStockFromCart(
                             ShoppingCartDataModel cartData,
                             string orderNumber,
                             CancellationToken cancellationToken = default)
                         {
                             // Collects ContentItemIDs and quantities from the cart
                             Dictionary<int, decimal> orderedItems = cartData.Items
                                 .ToDictionary(
                                     item => item.ProductIdentifier.Identifier,  // ContentItemID
                                     item => (decimal)item.Quantity);
                     
                             await ReduceStockForOrder(orderedItems, orderNumber, cancellationToken);
                         }

Important considerations for production implementations

  • Implement proper validation before reducing stock to prevent negative values
  • Consider using stock reservations to hold inventory during checkout
  • Handle edge cases such as partial order fulfillment or digital products without stock records
  • Log all stock changes for audit trails and troubleshooting

Returns and restocking

Stock restoration is necessary when products are returned by customers or when receiving new inventory. The restoration logic should mirror your return policy – some businesses restore stock immediately upon return initiation, while others wait for physical receipt and inspection of returned items.

The RestoreStockForReturn method increases stock levels for individual product returns, useful for processing customer returns one item at a time:

C#
Example - Restore stock for return

                         private readonly IInfoProvider<ProductStockInfo> productStockInfoProvider;
                         private readonly ILogger<ProductStockService> logger;
                     
                         public ProductStockService(
                             IInfoProvider<ProductStockInfo> productStockInfoProvider,
                             ILogger<ProductStockService> logger)
                         {
                             this.productStockInfoProvider = productStockInfoProvider;
                             this.logger = logger;
                         }
                     
                         /// <summary>
                         /// Restores stock levels when products are returned.
                         /// </summary>
                         /// <param name="contentItemId">ContentItemID of the returned product.</param>
                         /// <param name="quantity">Quantity being returned.</param>
                         /// <param name="orderNumber">Order number for logging purposes.</param>
                         /// <param name="cancellationToken">Cancellation token.</param>
                         public async Task RestoreStockForReturn(
                             int contentItemId,
                             decimal quantity,
                             string orderNumber,
                             CancellationToken cancellationToken = default)
                         {
                             ProductStockInfo? productStockInfo = (await productStockInfoProvider
                                 .Get()
                                 .WhereEquals(nameof(ProductStockInfo.ProductStockContentItemID), contentItemId)
                                 .GetEnumerableTypedResultAsync(cancellationToken: cancellationToken))
                                 .FirstOrDefault();
                     
                             if (productStockInfo != null)
                             {
                                 // Increases the stock value by the returned quantity
                                 productStockInfo.ProductStockValue += quantity;
                     
                                 await productStockInfoProvider.SetAsync(productStockInfo, cancellationToken);
                     
                                 logger.LogInformation(
                                     "Restored stock for ContentItemID {ContentItemID} by {Quantity} (order {OrderNumber}). New stock: {NewStock}",
                                     contentItemId, quantity, orderNumber, productStockInfo.ProductStockValue);
                             }
                             else
                             {
                                 logger.LogWarning(
                                     "No stock record found for ContentItemID {ContentItemID} during return processing",
                                     contentItemId);
                             }
                         }

For bulk restocking operations, such as receiving new inventory shipments, use the RestockProducts method which processes multiple products in a single operation:

C#
Example - Restock products

                         private readonly IInfoProvider<ProductStockInfo> productStockInfoProvider;
                         private readonly ILogger<ProductStockService> logger;
                     
                         public ProductStockService(
                             IInfoProvider<ProductStockInfo> productStockInfoProvider,
                             ILogger<ProductStockService> logger)
                         {
                             this.productStockInfoProvider = productStockInfoProvider;
                             this.logger = logger;
                         }
                     
                         /// <summary>
                         /// Adds stock during restocking operations (e.g., receiving new inventory).
                         /// </summary>
                         /// <param name="restockItems">Dictionary mapping ContentItemID to quantity being added.</param>
                         /// <param name="cancellationToken">Cancellation token.</param>
                         public async Task RestockProducts(
                             Dictionary<int, decimal> restockItems,
                             CancellationToken cancellationToken = default)
                         {
                             foreach (KeyValuePair<int, decimal> restockItem in restockItems)
                             {
                                 ProductStockInfo? productStockInfo = (await productStockInfoProvider
                                     .Get()
                                     .WhereEquals(nameof(ProductStockInfo.ProductStockContentItemID), restockItem.Key)
                                     .GetEnumerableTypedResultAsync(cancellationToken: cancellationToken))
                                     .FirstOrDefault();
                     
                                 if (productStockInfo != null)
                                 {
                                     productStockInfo.ProductStockValue += restockItem.Value;
                                     await productStockInfoProvider.SetAsync(productStockInfo, cancellationToken);
                     
                                     logger.LogInformation(
                                         "Restocked ContentItemID {ContentItemID} by {Quantity}. New stock: {NewStock}",
                                         restockItem.Key, restockItem.Value, productStockInfo.ProductStockValue);
                                 }
                                 else
                                 {
                                     // Create new stock record if one doesn't exist
                                     var newStockInfo = new ProductStockInfo
                                     {
                                         ProductStockContentItemID = restockItem.Key,
                                         ProductStockValue = restockItem.Value
                                     };
                                     await productStockInfoProvider.SetAsync(newStockInfo, cancellationToken);
                     
                                     logger.LogInformation(
                                         "Created new stock record for ContentItemID {ContentItemID} with quantity {Quantity}",
                                         restockItem.Key, restockItem.Value);
                                 }
                             }
                         }

This method automatically creates new stock records if they don’t exist, making it useful for adding products to your inventory system for the first time.

Stock reservations

Stock reservations provide a mechanism to temporarily hold inventory for customers during the checkout process. This prevents overselling while allowing automatic cleanup of abandoned shopping carts. Without reservations, two customers could simultaneously checkout with the last unit of a product, resulting in an oversold situation.

Implementation requirement

To implement stock reservations, add a ProductStockReservedValue field to your ProductStockInfo class. This field tracks reserved quantities separately from available stock:

C#
Example - Reserved value field

                         /// <summary>
                         /// Reserved stock quantity (temporarily held for pending orders).
                         /// </summary>
                         /// <remarks>
                         /// This field is optional and only needed if implementing stock reservations.
                         /// Reserved stock is subtracted from available stock during checkout to prevent overselling.
                         /// </remarks>
                         [DatabaseField]
                         public virtual decimal ProductStockReservedValue
                         {
                             get => ValidationHelper.GetDecimal(GetValue(nameof(ProductStockReservedValue)), 0);
                             set => SetValue(nameof(ProductStockReservedValue), value);
                         }

The reservation workflow consists of three key operations:

  1. Reserve stock when a customer begins checkout—this checks available stock (total minus already reserved) and marks the requested quantity as reserved:

    C#
    Example - Reserve stock
    
                          private readonly IInfoProvider<ProductStockInfo> productStockInfoProvider;
                          private readonly ILogger<StockReservationService> logger;
    
                          public StockReservationService(
                              IInfoProvider<ProductStockInfo> productStockInfoProvider,
                              ILogger<StockReservationService> logger)
                          {
                              this.productStockInfoProvider = productStockInfoProvider;
                              this.logger = logger;
                          }
    
                          /// <summary>
                          /// Reserves stock for items in a shopping cart during checkout.
                          /// Reserved stock is temporarily held and not available for other customers.
                          /// </summary>
                          /// <param name="items">Dictionary mapping ContentItemID to quantity to reserve.</param>
                          /// <param name="reservationMinutes">How long to hold the reservation (default: 15 minutes).</param>
                          /// <param name="cancellationToken">Cancellation token.</param>
                          /// <returns>Reservation result with success status and reservation ID.</returns>
                          public async Task<StockReservationResult> ReserveStock(
                              Dictionary<int, decimal> items,
                              int reservationMinutes = DEFAULT_RESERVATION_MINUTES,
                              CancellationToken cancellationToken = default)
                          {
                              var result = new StockReservationResult
                              {
                                  ReservationId = Guid.NewGuid(),
                                  ExpiresAt = DateTime.UtcNow.AddMinutes(reservationMinutes)
                              };
    
                              // First, check if all items have sufficient available stock
                              foreach (KeyValuePair<int, decimal> item in items)
                              {
                                  ProductStockInfo? stockInfo = await GetStockInfo(item.Key, cancellationToken);
    
                                  if (stockInfo == null)
                                  {
                                      // Products without stock records (digital products) are always available
                                      continue;
                                  }
    
                                  // Calculate available stock (total stock minus already reserved stock)
                                  decimal availableStock = stockInfo.ProductStockValue - stockInfo.ProductStockReservedValue;
    
                                  if (availableStock < item.Value)
                                  {
                                      result.Failures.Add(new StockReservationFailure
                                      {
                                          ContentItemId = item.Key,
                                          RequestedQuantity = item.Value,
                                          AvailableQuantity = Math.Max(0, availableStock)
                                      });
                                  }
                              }
    
                              // If any items failed, return without making reservations
                              if (result.Failures.Count > 0)
                              {
                                  result.Success = false;
                                  return result;
                              }
    
                              // All items available - create reservations
                              foreach (KeyValuePair<int, decimal> item in items)
                              {
                                  ProductStockInfo? stockInfo = await GetStockInfo(item.Key, cancellationToken);
    
                                  if (stockInfo != null)
                                  {
                                      // Increase reserved stock
                                      stockInfo.ProductStockReservedValue += item.Value;
                                      await productStockInfoProvider.SetAsync(stockInfo, cancellationToken);
    
                                      logger.LogInformation(
                                          "Reserved {Quantity} units of ContentItemID {ContentItemID}. ReservationId: {ReservationId}",
                                          item.Value, item.Key, result.ReservationId);
                                  }
                              }
    
                              result.Success = true;
                              return result;
                          }
    
  2. Release reservations when a checkout is abandoned or cancelled, making the stock available again for other customers:

    C#
    Example - Release reservation
    
                          private readonly IInfoProvider<ProductStockInfo> productStockInfoProvider;
                          private readonly ILogger<StockReservationService> logger;
    
                          public StockReservationService(
                              IInfoProvider<ProductStockInfo> productStockInfoProvider,
                              ILogger<StockReservationService> logger)
                          {
                              this.productStockInfoProvider = productStockInfoProvider;
                              this.logger = logger;
                          }
    
                          /// <summary>
                          /// Releases a stock reservation, making the stock available again.
                          /// Call this when a checkout is abandoned or cancelled.
                          /// </summary>
                          /// <param name="items">Dictionary mapping ContentItemID to quantity to release.</param>
                          /// <param name="reservationId">Reservation ID for logging purposes.</param>
                          /// <param name="cancellationToken">Cancellation token.</param>
                          public async Task ReleaseReservation(
                              Dictionary<int, decimal> items,
                              Guid reservationId,
                              CancellationToken cancellationToken = default)
                          {
                              foreach (KeyValuePair<int, decimal> item in items)
                              {
                                  ProductStockInfo? stockInfo = await GetStockInfo(item.Key, cancellationToken);
    
                                  if (stockInfo != null)
                                  {
                                      // Decrease reserved stock (ensure it doesn't go negative)
                                      stockInfo.ProductStockReservedValue = Math.Max(0, stockInfo.ProductStockReservedValue - item.Value);
                                      await productStockInfoProvider.SetAsync(stockInfo, cancellationToken);
    
                                      logger.LogInformation(
                                          "Released reservation of {Quantity} units for ContentItemID {ContentItemID}. ReservationId: {ReservationId}",
                                          item.Value, item.Key, reservationId);
                                  }
                              }
                          }
    
  3. Confirm reservations when an order is successfully placed, converting the reserved stock to sold stock by reducing both total and reserved values:

    C#
    Example - Confirm reservation
    
                          private readonly IInfoProvider<ProductStockInfo> productStockInfoProvider;
                          private readonly ILogger<StockReservationService> logger;
    
                          public StockReservationService(
                              IInfoProvider<ProductStockInfo> productStockInfoProvider,
                              ILogger<StockReservationService> logger)
                          {
                              this.productStockInfoProvider = productStockInfoProvider;
                              this.logger = logger;
                          }
    
                          /// <summary>
                          /// Confirms a reservation by converting reserved stock to sold stock.
                          /// Call this when an order is successfully placed.
                          /// </summary>
                          /// <param name="items">Dictionary mapping ContentItemID to quantity to confirm.</param>
                          /// <param name="reservationId">Reservation ID for logging purposes.</param>
                          /// <param name="cancellationToken">Cancellation token.</param>
                          public async Task ConfirmReservation(
                              Dictionary<int, decimal> items,
                              Guid reservationId,
                              CancellationToken cancellationToken = default)
                          {
                              foreach (KeyValuePair<int, decimal> item in items)
                              {
                                  ProductStockInfo? stockInfo = await GetStockInfo(item.Key, cancellationToken);
    
                                  if (stockInfo != null)
                                  {
                                      // Decrease both total stock and reserved stock
                                      stockInfo.ProductStockValue -= item.Value;
                                      stockInfo.ProductStockReservedValue = Math.Max(0, stockInfo.ProductStockReservedValue - item.Value);
    
                                      await productStockInfoProvider.SetAsync(stockInfo, cancellationToken);
    
                                      logger.LogInformation(
                                          "Confirmed reservation of {Quantity} units for ContentItemID {ContentItemID}. ReservationId: {ReservationId}. Remaining stock: {Stock}",
                                          item.Value, item.Key, reservationId, stockInfo.ProductStockValue);
                                  }
                              }
                          }
    

Reservation expiration

Implement a scheduled task to automatically release expired reservations (typically after 15-30 minutes of inactivity). This prevents abandoned carts from holding stock indefinitely. The StockReservationResult.ExpiresAt property tracks when reservations should be released.

Validation and business rules

Robust validation prevents data integrity issues and provides a better customer experience by catching problems before they occur. Stock validation should happen at multiple points: when adding items to cart, during cart updates, before checkout, and before finalizing orders.

Cart validation

Before allowing customers to proceed to checkout, validate that all cart items have sufficient stock. The ValidateCartStock method performs batch validation for all items, checking both out-of-stock conditions and insufficient quantities:

C#
Example - Validate cart stock

                         /// <summary>
                         /// Validates that all items in a cart have sufficient stock.
                         /// Call this before proceeding to checkout.
                         /// </summary>
                         /// <param name="cartItems">Dictionary mapping ContentItemID to requested quantity.</param>
                         /// <param name="productNames">Optional dictionary mapping ContentItemID to product names for error messages.</param>
                         /// <param name="cancellationToken">Cancellation token.</param>
                         /// <returns>Validation result with any errors found.</returns>
                         public async Task<StockValidationResult> ValidateCartStock(
                             Dictionary<int, decimal> cartItems,
                             Dictionary<int, string>? productNames = null,
                             CancellationToken cancellationToken = default)
                         {
                             var result = new StockValidationResult { IsValid = true };
                     
                             // Get all stock records in a single query for efficiency
                             IEnumerable<ProductStockInfo> stockRecords = await productStockInfoProvider
                                 .Get()
                                 .WhereIn(nameof(ProductStockInfo.ProductStockContentItemID), cartItems.Keys.ToList())
                                 .GetEnumerableTypedResultAsync(cancellationToken: cancellationToken);
                     
                             Dictionary<int, ProductStockInfo> stockByContentItemId = stockRecords
                                 .ToDictionary(s => s.ProductStockContentItemID);
                     
                             foreach (KeyValuePair<int, decimal> cartItem in cartItems)
                             {
                                 string productName = productNames?.GetValueOrDefault(cartItem.Key) ?? $"Product {cartItem.Key}";
                     
                                 if (!stockByContentItemId.TryGetValue(cartItem.Key, out ProductStockInfo? stockInfo))
                                 {
                                     // No stock record - might be digital product (skip) or configuration error
                                     logger.LogWarning(
                                         "No stock record found for ContentItemID {ContentItemID}. Assuming unlimited stock.",
                                         cartItem.Key);
                                     continue;
                                 }
                     
                                 // Calculate available stock (accounting for reservations if used)
                                 decimal availableStock = stockInfo.ProductStockValue - stockInfo.ProductStockReservedValue;
                     
                                 if (availableStock <= 0)
                                 {
                                     result.IsValid = false;
                                     result.Errors.Add(new StockValidationError
                                     {
                                         ContentItemId = cartItem.Key,
                                         ProductName = productName,
                                         ErrorType = StockValidationErrorType.OutOfStock,
                                         RequestedQuantity = cartItem.Value,
                                         AvailableQuantity = 0,
                                         Message = $"{productName} is currently out of stock."
                                     });
                                 }
                                 else if (availableStock < cartItem.Value)
                                 {
                                     result.IsValid = false;
                                     result.Errors.Add(new StockValidationError
                                     {
                                         ContentItemId = cartItem.Key,
                                         ProductName = productName,
                                         ErrorType = StockValidationErrorType.InsufficientStock,
                                         RequestedQuantity = cartItem.Value,
                                         AvailableQuantity = availableStock,
                                         Message = $"Only {availableStock} units of {productName} available. You requested {cartItem.Value}."
                                     });
                                 }
                             }
                     
                             return result;
                         }
                     
                     /// <summary>
                     /// Result of stock validation for cart items.
                     /// </summary>
                     public class StockValidationResult
                     {
                         /// <summary>
                         /// Indicates whether all items passed validation.
                         /// </summary>
                         public bool IsValid { get; set; }
                     
                         /// <summary>
                         /// List of validation errors for items that failed.
                         /// </summary>
                         public List<StockValidationError> Errors { get; set; } = [];
                     }
                     
                     /// <summary>
                     /// Details about a stock validation failure.
                     /// </summary>
                     public class StockValidationError
                     {
                         /// <summary>
                         /// ContentItemID of the product that failed validation.
                         /// </summary>
                         public int ContentItemId { get; set; }
                     
                         /// <summary>
                         /// Name of the product for display purposes.
                         /// </summary>
                         public string ProductName { get; set; } = string.Empty;
                     
                         /// <summary>
                         /// Type of validation error.
                         /// </summary>
                         public StockValidationErrorType ErrorType { get; set; }
                     
                         /// <summary>
                         /// Requested quantity that failed validation.
                         /// </summary>
                         public decimal RequestedQuantity { get; set; }
                     
                         /// <summary>
                         /// Available quantity (if applicable).
                         /// </summary>
                         public decimal AvailableQuantity { get; set; }
                     
                         /// <summary>
                         /// Human-readable error message.
                         /// </summary>
                         public string Message { get; set; } = string.Empty;
                     }

This method returns detailed validation results including error types and messages suitable for display to customers. Use the results to show specific error messages such as “Product X is out of stock” or “Only 3 units available, you requested 5.”

Threshold monitoring

Low stock alerts help you proactively manage inventory and avoid stockouts. Implement threshold checking after each stock reduction to trigger notifications to inventory managers:

C#
Example - Check low stock threshold

                         /// <summary>
                         /// Checks if a product's stock has fallen below the threshold.
                         /// Call this after stock reductions to trigger alerts.
                         /// </summary>
                         /// <param name="contentItemId">ContentItemID of the product.</param>
                         /// <param name="threshold">Stock threshold to check against (default: 10).</param>
                         /// <param name="cancellationToken">Cancellation token.</param>
                         /// <returns>True if stock is below threshold; false otherwise.</returns>
                         public async Task<bool> CheckLowStockThreshold(
                             int contentItemId,
                             decimal threshold = DEFAULT_LOW_STOCK_THRESHOLD,
                             CancellationToken cancellationToken = default)
                         {
                             ProductStockInfo? stockInfo = (await productStockInfoProvider
                                 .Get()
                                 .WhereEquals(nameof(ProductStockInfo.ProductStockContentItemID), contentItemId)
                                 .GetEnumerableTypedResultAsync(cancellationToken: cancellationToken))
                                 .FirstOrDefault();
                     
                             if (stockInfo == null)
                             {
                                 return false;
                             }
                     
                             bool isLowStock = stockInfo.ProductStockValue <= threshold;
                     
                             if (isLowStock)
                             {
                                 logger.LogWarning(
                                     "Low stock alert: ContentItemID {ContentItemID} has only {Stock} units (threshold: {Threshold})",
                                     contentItemId, stockInfo.ProductStockValue, threshold);
                             }
                     
                             return isLowStock;
                         }

Retrieve all products below the threshold for reporting and dashboard displays:

C#
Example - Get low stock products

                         /// <summary>
                         /// Retrieves all products with stock below the specified threshold.
                         /// </summary>
                         /// <param name="threshold">Stock threshold (default: 10).</param>
                         /// <param name="cancellationToken">Cancellation token.</param>
                         /// <returns>List of ProductStockInfo records below threshold.</returns>
                         public async Task<IEnumerable<ProductStockInfo>> GetLowStockProducts(
                             decimal threshold = DEFAULT_LOW_STOCK_THRESHOLD,
                             CancellationToken cancellationToken = default)
                         {
                             return await productStockInfoProvider
                                 .Get()
                                 .WhereLessOrEquals(nameof(ProductStockInfo.ProductStockValue), threshold)
                                 .WhereGreaterThan(nameof(ProductStockInfo.ProductStockValue), 0) // Exclude out of stock
                                 .GetEnumerableTypedResultAsync(cancellationToken: cancellationToken);
                         }

Business rule considerations

  • Negative stock prevention – Always validate before reducing stock to ensure values don’t go below zero
  • Concurrent access – The database handles basic concurrency, but consider implementing optimistic locking for high-traffic scenarios
  • Threshold alerts – Set different thresholds based on product type, sales velocity, or supplier lead times
  • Data integrity – Regularly reconcile stock records with physical inventory counts

Display product stock

Displaying accurate stock information on your storefront helps customers make informed purchasing decisions and sets appropriate expectations. Balance transparency with sales optimization—showing exact quantities for low stock creates urgency, while “In Stock” messages for abundant inventory avoid unnecessary anxiety.

Stock display model

The StockDisplayModel encapsulates all information needed to display stock status on product pages. It includes availability flags, quantity information, and pre-calculated display messages:

C#
Example - Stock display model

                     /// <summary>
                     /// View model for displaying product stock information on the storefront.
                     /// </summary>
                     public class StockDisplayModel
                     {
                         /// <summary>
                         /// ContentItemID of the product.
                         /// </summary>
                         public int ContentItemId { get; set; }
                     
                         /// <summary>
                         /// Indicates whether the product is in stock.
                         /// </summary>
                         public bool IsInStock { get; set; }
                     
                         /// <summary>
                         /// Available quantity (null for digital/unlimited products).
                         /// </summary>
                         public decimal? AvailableQuantity { get; set; }
                     
                         /// <summary>
                         /// Indicates low stock condition (less than threshold).
                         /// </summary>
                         public bool IsLowStock { get; set; }
                     
                         /// <summary>
                         /// Display message for stock status.
                         /// </summary>
                         public string StatusMessage { get; set; } = string.Empty;
                     
                         /// <summary>
                         /// CSS class for styling based on stock status.
                         /// </summary>
                         public string StatusCssClass { get; set; } = string.Empty;
                     
                         /// <summary>
                         /// Maximum quantity that can be added to cart.
                         /// </summary>
                         public int MaxOrderQuantity { get; set; }
                     }

Stock display service

The StockDisplayService transforms raw stock data into a presentation-ready model with appropriate messages and styling classes. It handles different scenarios including digital products (unlimited stock), out of stock, low stock warnings, and normal availability:

C#
Example - Stock display service

                     /// <summary>
                     /// Service for preparing stock information for display on the storefront.
                     /// </summary>
                     public class StockDisplayService
                     {
                         /// <summary>
                         /// Threshold below which stock is considered "low".
                         /// </summary>
                         private const decimal LOW_STOCK_THRESHOLD = 5;
                     
                         /// <summary>
                         /// Maximum quantity selector value for unlimited stock products.
                         /// </summary>
                         private const int MAX_UNLIMITED_QUANTITY = 99;
                     
                         private readonly ProductStockService productStockService;
                     
                         public StockDisplayService(ProductStockService productStockService)
                         {
                             this.productStockService = productStockService;
                         }
                     
                         /// <summary>
                         /// Gets stock display information for a single product.
                         /// </summary>
                         /// <param name="contentItemId">ContentItemID of the product.</param>
                         /// <param name="cancellationToken">Cancellation token.</param>
                         /// <returns>Stock display model for rendering on the storefront.</returns>
                         public async Task<StockDisplayModel> GetStockDisplayModel(
                             int contentItemId,
                             CancellationToken cancellationToken = default)
                         {
                             decimal? stockLevel = await productStockService.GetStockLevel(contentItemId, cancellationToken);
                     
                             var model = new StockDisplayModel
                             {
                                 ContentItemId = contentItemId
                             };
                     
                             // Handle digital/unlimited products (no stock record)
                             if (stockLevel == null)
                             {
                                 model.IsInStock = true;
                                 model.AvailableQuantity = null;
                                 model.IsLowStock = false;
                                 model.StatusMessage = "In Stock";
                                 model.StatusCssClass = "stock-available";
                                 model.MaxOrderQuantity = MAX_UNLIMITED_QUANTITY;
                                 return model;
                             }
                     
                             // Handle out of stock
                             if (stockLevel <= 0)
                             {
                                 model.IsInStock = false;
                                 model.AvailableQuantity = 0;
                                 model.IsLowStock = false;
                                 model.StatusMessage = "Out of Stock";
                                 model.StatusCssClass = "stock-unavailable";
                                 model.MaxOrderQuantity = 0;
                                 return model;
                             }
                     
                             // Handle low stock
                             if (stockLevel <= LOW_STOCK_THRESHOLD)
                             {
                                 model.IsInStock = true;
                                 model.AvailableQuantity = stockLevel;
                                 model.IsLowStock = true;
                                 model.StatusMessage = $"Only {stockLevel} left - order soon!";
                                 model.StatusCssClass = "stock-low";
                                 model.MaxOrderQuantity = (int)stockLevel;
                                 return model;
                             }
                     
                             // Handle normal stock
                             model.IsInStock = true;
                             model.AvailableQuantity = stockLevel;
                             model.IsLowStock = false;
                             model.StatusMessage = "In Stock";
                             model.StatusCssClass = "stock-available";
                             model.MaxOrderQuantity = (int)Math.Min(stockLevel.Value, MAX_UNLIMITED_QUANTITY);
                     
                             return model;
                         }
                     }

The service automatically categorizes stock levels: - Out of stock (0 units) – Displays unavailable message, disables add to cart - Low stock (≤5 units) – Shows urgent “Only X left” message to encourage purchase - In stock (>5 units) – Simple availability message without specific quantities - Unlimited (no stock record) – Treats as always available (e.g., digital products)

View component implementation

Use an ASP.NET Core view component to render stock information consistently across your storefront. The component calls the display service and renders the appropriate view:

C#
Example - Product stock view component

                     /// <summary>
                     /// View component for displaying product stock status on product pages.
                     /// </summary>
                     /// <example>
                     /// Usage in Razor view:
                     /// <code>
                     /// @await Component.InvokeAsync("ProductStock", new { contentItemId = Model.ContentItemId })
                     /// </code>
                     /// </example>
                     public class ProductStockViewComponent : ViewComponent
                     {
                         private readonly StockDisplayService stockDisplayService;
                     
                         public ProductStockViewComponent(StockDisplayService stockDisplayService)
                         {
                             this.stockDisplayService = stockDisplayService;
                         }
                     
                         /// <summary>
                         /// Renders the stock display component.
                         /// </summary>
                         /// <param name="contentItemId">ContentItemID of the product.</param>
                         /// <returns>View result with stock display model.</returns>
                         public async Task<IViewComponentResult> InvokeAsync(int contentItemId)
                         {
                             StockDisplayModel model = await stockDisplayService.GetStockDisplayModel(contentItemId);
                             return View("~/Views/Shared/Components/ProductStock/Default.cshtml", model);
                         }
                     }

Usage examples in Razor views:

cshtml
Product detail page

                     @* Product detail page - Model would be your product detail view model *@
                     <div class="product-availability">
                         @await Component.InvokeAsync("ProductStock", new { contentItemId = Model.ContentItemId })
                     </div>
cshtml
Product listing

                     @* Product listing - Model would be your product listing view model containing a collection of products *@
                     @foreach (var product in Model.Products)
                     {
                         <div class="product-card">
                             <h3>@product.Name</h3>
                             @await Component.InvokeAsync("ProductStock", new { contentItemId = product.ContentItemId })
                         </div>
                     }