Search and Filtering Patterns
Build powerful search and filtering features using PearDrop read models, with pagination, sorting, and dynamic filters.
Search Architecture
User Input (filters)
↓
SearchQuery (with filter params)
↓
QueryHandler
↓
IAppReadModels (LINQ filtering)
↓
Paginated Results
Basic Search Query
Define Search Query with Filters
Location: Infrastructure/Queries/SearchProductsQuery.cs
using BluQube.Queries;
namespace YourApp.Infrastructure.Queries;
public sealed record SearchProductsQuery(
string? SearchTerm,
Guid? CategoryId,
decimal? MinPrice,
decimal? MaxPrice,
bool? InStock,
int PageNumber,
int PageSize) : IQuery<SearchProductsQueryResult>;
public sealed record SearchProductsQueryResult(
List<ProductDto> Products,
int TotalCount,
int PageNumber,
int PageSize,
int TotalPages)
{
public sealed record ProductDto(
Guid Id,
string Name,
string Sku,
decimal Price,
Guid CategoryId,
string CategoryName,
int StockQuantity,
bool IsActive);
}
Implement Search Query Handler
Location: Infrastructure/Queries/QueryHandlers/SearchProductsQueryHandler.cs
using BluQube.Queries;
using Microsoft.EntityFrameworkCore;
namespace YourApp.Infrastructure.Queries.QueryHandlers;
public sealed class SearchProductsQueryHandler
: IQueryProcessor<SearchProductsQuery, SearchProductsQueryResult>
{
private readonly IAppReadModels readModels;
public SearchProductsQueryHandler(IAppReadModels readModels)
{
this.readModels = readModels;
}
public async Task<QueryResult<SearchProductsQueryResult>> Handle(
SearchProductsQuery request,
CancellationToken cancellationToken = default)
{
// Start with base query
var query = readModels.Products.AsQueryable();
// Apply filters dynamically
if (!string.IsNullOrWhiteSpace(request.SearchTerm))
{
var searchLower = request.SearchTerm.ToLower();
query = query.Where(p =>
p.Name.ToLower().Contains(searchLower) ||
p.Sku.ToLower().Contains(searchLower));
}
if (request.CategoryId.HasValue)
{
query = query.Where(p => p.CategoryId == request.CategoryId.Value);
}
if (request.MinPrice.HasValue)
{
query = query.Where(p => p.Price >= request.MinPrice.Value);
}
if (request.MaxPrice.HasValue)
{
query = query.Where(p => p.Price <= request.MaxPrice.Value);
}
if (request.InStock.HasValue && request.InStock.Value)
{
query = query.Where(p => p.StockQuantity > 0);
}
// Get total count before pagination
var totalCount = await query.CountAsync(cancellationToken);
// Apply pagination
var products = await query
.OrderBy(p => p.Name)
.Skip((request.PageNumber - 1) * request.PageSize)
.Take(request.PageSize)
.Select(p => new SearchProductsQueryResult.ProductDto(
p.Id,
p.Name,
p.Sku,
p.Price,
p.CategoryId,
p.CategoryName,
p.StockQuantity,
p.IsActive))
.ToListAsync(cancellationToken);
var totalPages = (int)Math.Ceiling(totalCount / (double)request.PageSize);
return QueryResult<SearchProductsQueryResult>.Succeeded(
new SearchProductsQueryResult(
products,
totalCount,
request.PageNumber,
request.PageSize,
totalPages));
}
}
Advanced Filtering Patterns
Multi-Field Text Search
// Search across multiple fields with OR logic
if (!string.IsNullOrWhiteSpace(request.SearchTerm))
{
var searchLower = request.SearchTerm.ToLower();
query = query.Where(p =>
p.Name.ToLower().Contains(searchLower) ||
p.Sku.ToLower().Contains(searchLower) ||
p.Description.ToLower().Contains(searchLower) ||
p.CategoryName.ToLower().Contains(searchLower) ||
p.Manufacturer.ToLower().Contains(searchLower));
}
Date Range Filtering
public sealed record SearchOrdersQuery(
DateOnly? FromDate,
DateOnly? ToDate,
OrderStatus? Status,
int PageNumber,
int PageSize) : IQuery<SearchOrdersQueryResult>;
// In handler:
if (request.FromDate.HasValue)
{
query = query.Where(o => o.OrderDate >= request.FromDate.Value);
}
if (request.ToDate.HasValue)
{
query = query.Where(o => o.OrderDate <= request.ToDate.Value);
}
Enum Filtering with Multiple Values
public sealed record SearchOrdersQuery(
List<OrderStatus>? Statuses, // Multiple statuses
int PageNumber,
int PageSize) : IQuery<SearchOrdersQueryResult>;
// In handler:
if (request.Statuses != null && request.Statuses.Any())
{
query = query.Where(o => request.Statuses.Contains(o.Status));
}
Tag/Category Multi-Select
public sealed record SearchProductsQuery(
List<Guid>? CategoryIds, // Multiple categories
int PageNumber,
int PageSize) : IQuery<SearchProductsQueryResult>;
// In handler:
if (request.CategoryIds != null && request.CategoryIds.Any())
{
query = query.Where(p => request.CategoryIds.Contains(p.CategoryId));
}
Dynamic Sorting
Query with Sort Options
public enum ProductSortField
{
Name,
Price,
StockQuantity,
CreatedDate
}
public enum SortDirection
{
Ascending,
Descending
}
public sealed record SearchProductsQuery(
string? SearchTerm,
ProductSortField SortBy,
SortDirection SortDirection,
int PageNumber,
int PageSize) : IQuery<SearchProductsQueryResult>;
Implement Dynamic Sorting
public async Task<QueryResult<SearchProductsQueryResult>> Handle(
SearchProductsQuery request,
CancellationToken cancellationToken = default)
{
var query = readModels.Products.AsQueryable();
// Apply filters...
// Apply dynamic sorting
query = request.SortBy switch
{
ProductSortField.Name => request.SortDirection == SortDirection.Ascending
? query.OrderBy(p => p.Name)
: query.OrderByDescending(p => p.Name),
ProductSortField.Price => request.SortDirection == SortDirection.Ascending
? query.OrderBy(p => p.Price)
: query.OrderByDescending(p => p.Price),
ProductSortField.StockQuantity => request.SortDirection == SortDirection.Ascending
? query.OrderBy(p => p.StockQuantity)
: query.OrderByDescending(p => p.StockQuantity),
ProductSortField.CreatedDate => request.SortDirection == SortDirection.Ascending
? query.OrderBy(p => p.CreatedDate)
: query.OrderByDescending(p => p.CreatedDate),
_ => query.OrderBy(p => p.Name)
};
// Apply pagination...
}
Pagination Patterns
Offset-Based Pagination (Most Common)
// Calculate skip/take
var skip = (request.PageNumber - 1) * request.PageSize;
var take = request.PageSize;
// Get total count
var totalCount = await query.CountAsync(cancellationToken);
// Get page of results
var items = await query
.Skip(skip)
.Take(take)
.ToListAsync(cancellationToken);
// Calculate total pages
var totalPages = (int)Math.Ceiling(totalCount / (double)request.PageSize);
return new SearchResult(items, totalCount, request.PageNumber, request.PageSize, totalPages);
Cursor-Based Pagination (Large Datasets)
public sealed record SearchProductsQuery(
Guid? LastProductId, // Cursor: ID of last item from previous page
int PageSize) : IQuery<SearchProductsQueryResult>;
// In handler:
var query = readModels.Products
.OrderBy(p => p.Id); // Must order by cursor field
if (request.LastProductId.HasValue)
{
query = query.Where(p => p.Id > request.LastProductId.Value);
}
var products = await query
.Take(request.PageSize + 1) // Fetch one extra to check if more exist
.ToListAsync(cancellationToken);
var hasMore = products.Count > request.PageSize;
if (hasMore)
{
products = products.Take(request.PageSize).ToList();
}
return new SearchProductsQueryResult(
products,
hasMore,
products.LastOrDefault()?.Id);
Blazor Search Component
Search Form with Filters
Component: ProductSearch.razor
@page "/products/search"
@inject IQueryRunner QueryRunner
<div class="search-container">
<h1>Product Search</h1>
<div class="search-filters">
<div class="filter-row">
<input type="text"
class="form-control"
placeholder="Search products..."
@bind="searchTerm"
@bind:event="oninput"
@onkeydown="HandleKeyDown" />
<button class="btn-primary" @onclick="Search">
<i class="icon-search"></i> Search
</button>
</div>
<div class="filter-row">
<select class="form-control" @bind="selectedCategoryId">
<option value="">All Categories</option>
@foreach (var category in categories)
{
<option value="@category.Id">@category.Name</option>
}
</select>
<input type="number"
class="form-control"
placeholder="Min Price"
@bind="minPrice" />
<input type="number"
class="form-control"
placeholder="Max Price"
@bind="maxPrice" />
<label class="checkbox-label">
<input type="checkbox" @bind="inStockOnly" />
In Stock Only
</label>
</div>
<div class="filter-row">
<select class="form-control" @bind="sortBy">
<option value="@ProductSortField.Name">Name</option>
<option value="@ProductSortField.Price">Price</option>
<option value="@ProductSortField.StockQuantity">Stock</option>
</select>
<select class="form-control" @bind="sortDirection">
<option value="@SortDirection.Ascending">Ascending</option>
<option value="@SortDirection.Descending">Descending</option>
</select>
<button class="btn-secondary" @onclick="ClearFilters">Clear</button>
</div>
</div>
@if (isLoading)
{
<div class="loading-spinner">Searching...</div>
}
else if (results != null)
{
<div class="search-results">
<div class="results-header">
<span>@results.TotalCount results found</span>
<span>Page @results.PageNumber of @results.TotalPages</span>
</div>
@if (!results.Products.Any())
{
<div class="empty-state">
<p>No products found matching your criteria.</p>
</div>
}
else
{
<div class="products-grid">
@foreach (var product in results.Products)
{
<div class="product-card">
<h3>@product.Name</h3>
<p class="sku">SKU: @product.Sku</p>
<p class="price">$@product.Price.ToString("N2")</p>
<p class="category">@product.CategoryName</p>
<p class="stock @(product.StockQuantity > 0 ? "in-stock" : "out-of-stock")">
@(product.StockQuantity > 0 ? $"{product.StockQuantity} in stock" : "Out of stock")
</p>
</div>
}
</div>
<div class="pagination">
<button class="btn-secondary"
@onclick="PreviousPage"
disabled="@(results.PageNumber <= 1)">
Previous
</button>
<span>Page @results.PageNumber of @results.TotalPages</span>
<button class="btn-secondary"
@onclick="NextPage"
disabled="@(results.PageNumber >= results.TotalPages)">
Next
</button>
</div>
}
</div>
}
</div>
Code-Behind
File: ProductSearch.razor.cs
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
namespace YourApp.App.Client.Components.Pages.Products;
public partial class ProductSearch
{
private string searchTerm = string.Empty;
private Guid? selectedCategoryId;
private decimal? minPrice;
private decimal? maxPrice;
private bool inStockOnly = false;
private ProductSortField sortBy = ProductSortField.Name;
private SortDirection sortDirection = SortDirection.Ascending;
private int currentPage = 1;
private const int PageSize = 24;
private bool isLoading = false;
private SearchProductsQueryResult? results;
private List<CategoryDto> categories = new();
protected override async Task OnInitializedAsync()
{
await LoadCategories();
await Search();
}
private async Task LoadCategories()
{
var result = await QueryRunner.Send(new GetAllCategoriesQuery());
if (result.IsSuccess)
{
categories = result.Data!.Categories;
}
}
private async Task Search()
{
isLoading = true;
var query = new SearchProductsQuery(
searchTerm,
selectedCategoryId,
minPrice,
maxPrice,
inStockOnly ? true : null,
sortBy,
sortDirection,
currentPage,
PageSize);
var result = await QueryRunner.Send(query);
if (result.IsSuccess)
{
results = result.Data!;
}
isLoading = false;
}
private async Task HandleKeyDown(KeyboardEventArgs e)
{
if (e.Key == "Enter")
{
currentPage = 1;
await Search();
}
}
private async Task PreviousPage()
{
if (currentPage > 1)
{
currentPage--;
await Search();
}
}
private async Task NextPage()
{
if (results != null && currentPage < results.TotalPages)
{
currentPage++;
await Search();
}
}
private async Task ClearFilters()
{
searchTerm = string.Empty;
selectedCategoryId = null;
minPrice = null;
maxPrice = null;
inStockOnly = false;
sortBy = ProductSortField.Name;
sortDirection = SortDirection.Ascending;
currentPage = 1;
await Search();
}
}
Performance Optimization
Create Database Indexes
// In entity configuration
builder.HasIndex(p => p.Name);
builder.HasIndex(p => p.CategoryId);
builder.HasIndex(p => p.Price);
builder.HasIndex(p => new { p.CategoryId, p.IsActive }); // Composite index
Use Projections (Select Only What You Need)
// ❌ DON'T: Load entire entity
var products = await query.ToListAsync();
// ✅ DO: Project to DTO
var products = await query
.Select(p => new ProductDto(
p.Id,
p.Name,
p.Sku,
p.Price,
p.CategoryName,
p.StockQuantity))
.ToListAsync();
Debounce Text Search
Avoid searching on every keystroke - wait for user to pause:
private Timer? debounceTimer;
private void OnSearchTextChanged(ChangeEventArgs e)
{
searchTerm = e.Value?.ToString() ?? string.Empty;
debounceTimer?.Dispose();
debounceTimer = new Timer(500); // 500ms delay
debounceTimer.Elapsed += async (s, e) =>
{
await InvokeAsync(async () =>
{
currentPage = 1;
await Search();
});
};
debounceTimer.AutoReset = false;
debounceTimer.Start();
}
Best Practices
✅ DO
- Index frequently filtered/sorted columns - Critical for performance
- Validate page number and size - Prevent abuse
- Use projections - Select only needed fields
- Add total count to results - For pagination UI
- Debounce text search - Reduce API calls
- Cache filter options - Categories, statuses, etc.
❌ DON'T
- Allow unlimited page sizes - Cap at reasonable maximum (e.g., 100)
- Use
Skip()for huge offsets - Consider cursor pagination - Load entire entities - Use Select() to project
- Search on every keystroke - Debounce input
- Forget to order before pagination - Results must be deterministic
Testing Search Queries
public class SearchProductsQueryHandlerTests
{
[Fact]
public async Task Should_FilterBySearchTerm()
{
// Arrange
var readModels = CreateMockReadModels();
var handler = new SearchProductsQueryHandler(readModels);
var query = new SearchProductsQuery("Widget", null, null, null, null, 1, 10);
// Act
var result = await handler.Handle(query, CancellationToken.None);
// Assert
Assert.True(result.IsSuccess);
Assert.All(result.Data!.Products, p =>
Assert.Contains("Widget", p.Name, StringComparison.OrdinalIgnoreCase));
}
[Fact]
public async Task Should_FilterByPriceRange()
{
// Arrange
var readModels = CreateMockReadModels();
var handler = new SearchProductsQueryHandler(readModels);
var query = new SearchProductsQuery(null, null, 10m, 50m, null, 1, 10);
// Act
var result = await handler.Handle(query, CancellationToken.None);
// Assert
Assert.True(result.IsSuccess);
Assert.All(result.Data!.Products, p =>
{
Assert.True(p.Price >= 10m);
Assert.True(p.Price <= 50m);
});
}
}
Next Steps
- Complete CRUD Feature - Build full features
- Form Validation Patterns - Validate search inputs
- Security & Authorization - Filter by user permissions
See also: LINQ documentation and EF Core query performance best practices.