Skip to main content

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

// 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();

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


See also: LINQ documentation and EF Core query performance best practices.