In modern B2B multi-tenant SaaS platforms, giving merchants control over their storefront discovery is a critical feature. However, product sorting is rarely as simple as clicking a “Sort by Price” dropdown. Different clients frequently need to overlay multiple layers of custom sorting rules on their respective results pages. For example, a search results page might need to:
- Bury out-of-stock items at the bottom of the feed.
- Boost featured products to the top.
- Sort the remaining items by price (ascending).
When thousands of independent merchants share the same application code, managing these overlapping, client-specific requirements can quickly turn codebase maintenance into a nightmare.
The Multi-Toggle Trap
When faced with these evolving business rules across multiple storefronts, the most common anti-pattern is to implement an unstructured sequence of conditional sorting statements inside the shared catalog service.
public IEnumerable<Product> SortProducts(IEnumerable<Product> products, bool buryOutOfStock, bool boostFeatured)
{
// Start with a baseline sort
var sorted = products.OrderBy(p => p.Price);
if (buryOutOfStock)
{
sorted = sorted.ThenByDescending(p => p.StockCount > 0);
}
if (boostFeatured)
{
sorted = sorted.ThenByDescending(p => p.IsFeatured);
}
return sorted;
}
The Sequence Blindspot
While this block makes it easy to toggle individual rules on or off using database flags, the sequence of execution is permanently hardcoded.
In stable sorting, the order in which sorting steps are applied determines the final hierarchy. Because this implementation is hardcoded, you cannot customize the precedence. If Client A needs out-of-stock items buried first, but Client B wants featured items boosted above all else, you are at an architectural standstill.
The Architectural Solution: Strategy Meets Pipeline
To solve this for a multi-tenant platform, we can combine two classical design patterns into a single, cohesive architecture:
- The Strategy Pattern (Micro-Rules): We isolate each individual sorting rule (by price, by stock status, by featured status) into its own lightweight, self-contained, and stateless class.
- The Pipeline Pattern (Macro-Coordination): We feed these client-selected strategies into a sequential pipeline. The order of the list determines the exact sequence of execution for that specific tenant.
By pairing these two patterns, we achieve a system where the execution order is fully dynamic, configurable per client, and completely decoupled from the core sorting logic itself.
The Core Components
First, let’s define our lightweight domain model, the strategy interface, and the context object that holds tenant-specific platform parameters.
1. The Domain Model and Context
public class Product
{
public string Name { get; set; }
public decimal Price { get; set; }
public bool IsFeatured { get; set; }
public int StockCount { get; set; }
}
public class ClientContext
{
public string TenantId { get; set; }
public bool IsPremiumTier { get; set; }
}
The design of the ClientContext class acts as an extensible parameter container. Passing this unified context object to our strategies rather than spreading out individual method parameters protects our sorting interface contract against future change. If a newly created strategy requires a brand-new tenant attribute down the line, we only need to add that property here. All previous strategy implementations will remain entirely untouched, simply ignoring the new properties they do not require.
2. The Strategy Interface
By utilizing stable sorting, our strategy interface remains incredibly pure and simple. Each strategy only needs to know how to sort a collection using a single criterion.
public interface ISortStrategy
{
IEnumerable<Product> Sort(IEnumerable<Product> products, ClientContext context);
}
3. Concrete Sorting Strategies
Because the interface is stripped of secondary sorting contracts, our concrete micro-rules are highly isolated, readable, and effortless to test. Notice how strategies can dynamically adapt their internal sorting behaviors based on client attributes like platform subscription tiers.
// Strategy 1: Bury Out-of-Stock items
public class BuryOutOfStockStrategy : ISortStrategy
{
public IEnumerable<Product> Sort(IEnumerable<Product> products, ClientContext context)
{
return products.OrderByDescending(p => p.StockCount > 0);
}
}
// Strategy 2: Dynamically boost featured products only if the client is on the Premium SaaS Tier
public class PremiumTenantBoostStrategy : ISortStrategy
{
public IEnumerable<Product> Sort(IEnumerable<Product> products, ClientContext context)
{
if (context.IsPremiumTier)
{
return products.OrderByDescending(p => p.IsFeatured);
}
return products;
}
}
// Strategy 3: Sort by Price (Ascending)
public class SortByPriceStrategy : ISortStrategy
{
public IEnumerable<Product> Sort(IEnumerable<Product> products, ClientContext context)
{
return products.OrderBy(p => p.Price);
}
}
The Testing Advantage: True Test Isolation
An often overlooked benefit of this architectural style is how it transforms your unit testing suite.
In procedural code, adding a new sorting logic branch means navigating a bloated, multi-nested test file and adjusting mock data setups that risk breaking existing assertions. Because each sorting strategy in our pipeline is a completely decoupled, stateless class, testing is beautifully straightforward.
Each strategy has its own dedicated, tiny test fixture. When a merchant requests a brand-new sorting logic (for instance, sorting by seasonal popularity), you write a new strategy class and a matching test file. You never touch or risk breaking the unit tests of the existing strategies. Regression testing is simplified, and your code coverage naturally remains robust and clean.
The Orchestrator
The ProductFeedSorter coordinates the execution. It accepts the calling client’s ClientContext and their customized pipeline sequence via its constructor.
When executing the sort, it loops through the pipeline in reverse. This reverse execution leverages the mathematical beauty of stable sorting, ensuring that previous sorting hierarchies are perfectly preserved as fallbacks without needing complex LINQ types.
public class ProductFeedSorter
{
private readonly ClientContext _context;
private readonly List<ISortStrategy> _pipeline;
public ProductFeedSorter(ClientContext context, List<ISortStrategy> pipeline)
{
_context = context;
_pipeline = pipeline;
}
public IReadOnlyCollection<Product> Sort(IEnumerable<Product> products)
{
var current = products.ToList();
// Loop backward: least significant sort to most significant sort
for (int i = _pipeline.Count - 1; i >= 0; i--)
{
current = _pipeline[i].Sort(current, _context).ToList();
}
return current;
}
}
A Note on Materialization
For the purposes of these code samples, we materialize the result using .ToList() after each sort is applied to keep the logic simple and clear. In a production environment, this sequential chain can be optimized more efficiently to minimize allocations.
Integration & Execution
The Strategy + Pipeline pattern streamlines the client code by requiring it to ask only for the sorting. All the underlying sorting implementations, configuration rules, and data sources are hidden away in dedicated classes and namespaces, keeping the primary business flow clean and expressive.
Here is the streamlined primary client code:
using System;
using System.Collections.Generic;
using System.Linq;
class Program
{
static void Main()
{
// 1. Gather client context, the tenant's active pipeline, and raw products from our provider
var clientContext = MockDataProvider.GetClientContext();
var sortingStrategiesPipeline = MockDataProvider.GetSortingPipeline();
var rawInventory = MockDataProvider.GetRawInventory();
// 2. Initialize the sorter and execute the sort
var sorter = new ProductFeedSorter(clientContext, sortingStrategiesPipeline);
var sortedProducts = sorter.Sort(rawInventory);
// 3. Render the output feed for this client's storefront results page
Console.WriteLine("--- Final Sorted Feed ---");
foreach (var product in sortedProducts)
{
string stockStatus = product.StockCount <= 0 ? "OUT OF STOCK" : $"Stock: {product.StockCount}";
Console.WriteLine($"Price: ${product.Price} | Premium: {product.IsFeatured} | {stockStatus} | {product.Name}");
}
}
}
To support this execution, all tenant configuration, list building, and isolated test datasets are cleanly packaged inside a separate, dedicated provider class:
using System.Collections.Generic;
public static class MockDataProvider
{
public static ClientContext GetClientContext()
{
return new ClientContext { TenantId = "tenant-client-abc", IsPremiumTier = true };
}
public static List<ISortStrategy> GetSortingPipeline()
{
return new List<ISortStrategy>
{
new BuryOutOfStockStrategy(),
new PremiumTenantBoostStrategy(),
new SortByPriceStrategy()
};
}
public static List<Product> GetRawInventory()
{
return new List<Product>
{
new Product { Name = "Basic Mechanical Keyboard", Price = 80, IsFeatured = false, StockCount = 15 },
new Product { Name = "Premium Wireless Mouse", Price = 120, IsFeatured = true, StockCount = 5 },
new Product { Name = "OutOfStock Ergonomic Chair", Price = 350, IsFeatured = true, StockCount = 0 },
new Product { Name = "Budget Mousepad", Price = 15, IsFeatured = false, StockCount = 100 },
new Product { Name = "Premium 4K Monitor", Price = 450, IsFeatured = true, StockCount = 8 }
};
}
}
Incremental Adoption: The Strangler Fig Pattern
Adopting a new architectural pattern does not require a risky, all-at-once rewrite of your legacy systems. By applying a variation of the Strangler Fig pattern, you can migrate to this clean pipeline progressively and safely.
At worst, you can instantiate the new ProductFeedSorter directly at the end of your legacy sorting method. The incoming, already-sorted collection is simply treated as the raw feed for the new orchestrator, allowing the new strategies to cleanly override the legacy results.
At best, you can use a single configuration setting per tenant to control the sorting pipeline. If the setting flag is enabled for a specific client, the system completely bypasses the legacy sorting logic to execute only the new sorter pipeline. Conversely, if the setting is disabled, the system routes the user through the legacy execution path. This dual-route configuration provides a safe, incremental strategy to test the new architecture on a subset of tenants with zero downtime.
Bridging Code and UI: The Domain-Oriented Editor
One of the most compelling aspects of this backend design is how naturally it dictates and simplifies user interface modeling.
Because each sorting rule is encapsulated in a discrete, self-contained class, our code maps perfectly to the mental model of non-technical business operators. This structural elegance bridges backend complexity with visual usability, turning simple architectural structures into poetry in code.
The application can present the client with a pool of all available sorting strategies inside their merchant dashboard settings panel. The merchant is empowered to select only the options they want and arrange them in the exact order of precedence they dictate for their store. This sequence is saved simply as an ordered list of strategy keys in a database, which the backend then resolves and executes sequentially.

By aligning the backend code directly with the administrative tool’s workflow, we dismantle traditional friction points between development and product management. This shared language enables engineers and product creators to focus on what matters most: building wonderful products together.
Conclusion
By swapping out static, procedural sorting logic for a dynamic Strategy Pipeline, we achieve a highly modular system that solves the core maintainability problems of e-commerce discovery feeds.
The greatest advantage of this pattern lies in its compliance with the Open-Closed Principle (the “O” in SOLID). When business requirements change or marketing demands a brand-new sorting rule, you never have to risk breaking existing logic. You simply create a new class implementing ISortStrategy and drop it into the active pipeline. With equal ease, you can immediately disable underperforming or “bad” strategies at any time simply by removing them from the sequence. It is a safe, predictable, and elegant way to grow and manage a codebase without letting it decay.