DKNet EF Core Specifications - Build reusable, composable, and type-safe queries with dynamic predicates and the Specification Pattern
This skill helps GitHub Copilot generate code using DKNet's EF Core Specifications package (DKNet.EfCore.Specifications) for building reusable, type-safe, and dynamic queries.
DKNet.EfCore.Specifications provides:
.And(), .Or()NuGet Package: DKNet.EfCore.Specifications
dotnet add package DKNet.EfCore.Specifications
Specifications encapsulate query logic for reusability and testability.
using DKNet.EfCore.Specifications;
/// <summary>
/// Specification for filtering active products.
/// </summary>
public class ActiveProductsSpec : Specification<Product>
{
/// <summary>
/// Initializes a new instance of the <see cref="ActiveProductsSpec"/> class.
/// </summary>
public ActiveProductsSpec()
{
// Define filter criteria
WithFilter(p => !p.IsDeleted && p.IsActive);
// Add ordering
AddOrderBy(p => p.Name);
}
}
// Usage
var spec = new ActiveProductsSpec();
var products = await repository.ToListAsync(spec, cancellationToken);
/// <summary>
/// Specification for products by category with optional price filter.
/// </summary>
public class ProductsByCategorySpec : Specification<Product>
{
/// <summary>
/// Initializes a new instance of the <see cref="ProductsByCategorySpec"/> class.
/// </summary>
/// <param name="categoryId">The category identifier.</param>
/// <param name="minPrice">Optional minimum price filter.</param>
public ProductsByCategorySpec(Guid categoryId, decimal? minPrice = null)
{
// Base filter
var predicate = PredicateBuilder.New<Product>(
p => !p.IsDeleted && p.CategoryId == categoryId);
// Add optional price filter
if (minPrice.HasValue)
{
predicate = predicate.And(p => p.Price >= minPrice.Value);
}
WithFilter(predicate);
// Include related entities
AddInclude(p => p.Category);
AddInclude(p => p.Reviews);
// Add ordering
AddOrderBy(p => p.Price);
AddOrderByDescending(p => p.Rating);
}
}
/// <summary>
/// Comprehensive specification for product search.
/// </summary>
public class ProductSearchSpec : Specification<Product>
{
/// <summary>
/// Initializes a new instance of the <see cref="ProductSearchSpec"/> class.
/// </summary>
/// <param name="searchTerm">Search term for name/description.</param>
/// <param name="categoryId">Optional category filter.</param>
/// <param name="minPrice">Optional minimum price.</param>
/// <param name="maxPrice">Optional maximum price.</param>
/// <param name="isActive">Filter by active status.</param>
public ProductSearchSpec(
string? searchTerm = null,
Guid? categoryId = null,
decimal? minPrice = null,
decimal? maxPrice = null,
bool isActive = true)
{
// Build dynamic predicate
var predicate = PredicateBuilder.New<Product>(p => !p.IsDeleted);
if (isActive)
{
predicate = predicate.And(p => p.IsActive);
}
if (!string.IsNullOrWhiteSpace(searchTerm))
{
predicate = predicate.And(p =>
p.Name.Contains(searchTerm) ||
p.Description.Contains(searchTerm));
}
if (categoryId.HasValue)
{
predicate = predicate.And(p => p.CategoryId == categoryId.Value);
}
if (minPrice.HasValue)
{
predicate = predicate.And(p => p.Price >= minPrice.Value);
}
if (maxPrice.HasValue)
{
predicate = predicate.And(p => p.Price <= maxPrice.Value);
}
WithFilter(predicate);
// Include related data
AddInclude(p => p.Category);
AddInclude(p => p.Reviews);
// Add ordering
AddOrderBy(p => p.Name);
// Ignore global query filters if needed
// IgnoreQueryFilters();
}
}
Build type-safe queries from runtime conditions using string property names.
using DKNet.EfCore.Specifications.Dynamics;
using LinqKit;
// Build predicate dynamically
var predicate = PredicateBuilder.New<Product>(p => !p.IsDeleted);
// Add conditions dynamically based on filter input
if (!string.IsNullOrEmpty(request.Name))
{
predicate = predicate.DynamicAnd("Name", Ops.Contains, request.Name);
}
if (request.MinPrice.HasValue)
{
predicate = predicate.DynamicAnd("Price", Ops.GreaterThanOrEqual, request.MinPrice.Value);
}
if (request.CategoryId.HasValue)
{
predicate = predicate.DynamicAnd("CategoryId", Ops.Equal, request.CategoryId.Value);
}
// CRITICAL: Must use .AsExpandable() with LinqKit predicates
var products = await context.Products
.AsNoTracking()
.AsExpandable() // Required!
.Where(predicate)
.ToListAsync(cancellationToken);
// Comparison operations
predicate.DynamicAnd("Price", Ops.Equal, 100);
predicate.DynamicAnd("Price", Ops.NotEqual, 0);
predicate.DynamicAnd("Price", Ops.GreaterThan, 50);
predicate.DynamicAnd("Price", Ops.GreaterThanOrEqual, 50);
predicate.DynamicAnd("Price", Ops.LessThan, 200);
predicate.DynamicAnd("Price", Ops.LessThanOrEqual, 200);
// String operations (auto-converted to Equal/NotEqual for non-strings)
predicate.DynamicAnd("Name", Ops.Contains, "laptop");
predicate.DynamicAnd("Name", Ops.NotContains, "refurbished");
predicate.DynamicAnd("Name", Ops.StartsWith, "Apple");
predicate.DynamicAnd("Name", Ops.EndsWith, "Pro");
// Collection operations
var categoryIds = new[] { guid1, guid2, guid3 };
predicate.DynamicAnd("CategoryId", Ops.In, categoryIds);
predicate.DynamicAnd("Status", Ops.NotIn, new[] { "Cancelled", "Deleted" });
// Build OR conditions using builder pattern
var predicate = PredicateBuilder.New<Product>(p => !p.IsDeleted);
predicate = predicate.DynamicAnd(builder => builder
.With("Name", Ops.Contains, "laptop")
.Or("Description", Ops.Contains, "laptop"));
// Multiple OR groups
predicate = predicate.DynamicAnd(builder => builder
.With("CategoryId", Ops.Equal, electronicsId)
.Or("CategoryId", Ops.Equal, computersId))
.DynamicAnd(builder => builder
.With("Price", Ops.LessThan, 1000)
.Or("OnSale", Ops.Equal, true));
// Use with EF Core
var products = await context.Products
.AsExpandable()
.Where(predicate)
.ToListAsync(cancellationToken);
// Query nested properties using dot notation
predicate.DynamicAnd("Category.Name", Ops.Equal, "Electronics");
predicate.DynamicAnd("Address.City", Ops.Contains, "New York");
predicate.DynamicAnd("Order.Customer.Email", Ops.EndsWith, "@example.com");
Combine specifications using LinqKit's .And() and .Or():
// Define base specifications
var activeSpec = new ActiveProductsSpec();
var categorySpec = new ProductsByCategorySpec(categoryId);
// Combine with AND
var combinedFilter = activeSpec.FilterQuery
.And(categorySpec.FilterQuery);
var spec = new Specification<Product>();
spec.WithFilter(combinedFilter);
// Use combined specification
var products = await repository.ToListAsync(spec, cancellationToken);
When you have a simple, reusable query:
public class AvailableProductsSpec : Specification<Product>
{
public AvailableProductsSpec()
{
WithFilter(p => !p.IsDeleted && p.IsActive && p.Stock > 0);
AddOrderBy(p => p.Name);
}
}
// Usage
var products = await repository.ToListAsync(
new AvailableProductsSpec(),
cancellationToken);
When you need to pass parameters:
public class ProductsInPriceRangeSpec : Specification<Product>
{
public ProductsInPriceRangeSpec(decimal minPrice, decimal maxPrice)
{
WithFilter(p => p.Price >= minPrice && p.Price <= maxPrice);
AddInclude(p => p.Category);
}
}
// Usage
var spec = new ProductsInPriceRangeSpec(100, 500);
var products = await repository.ToListAsync(spec, cancellationToken);
When building queries from API request parameters:
public class ProductFilterSpec : Specification<Product>
{
public ProductFilterSpec(ProductFilterRequest request)
{
var predicate = PredicateBuilder.New<Product>(p => !p.IsDeleted);
// Dynamically add filters based on request
if (!string.IsNullOrEmpty(request.SearchTerm))
{
predicate = predicate.DynamicAnd(builder => builder
.With("Name", Ops.Contains, request.SearchTerm)
.Or("Description", Ops.Contains, request.SearchTerm)
.Or("Sku", Ops.Equal, request.SearchTerm));
}
if (request.CategoryIds?.Any() == true)
{
predicate = predicate.DynamicAnd("CategoryId", Ops.In, request.CategoryIds);
}
if (request.MinPrice.HasValue)
{
predicate = predicate.DynamicAnd("Price", Ops.GreaterThanOrEqual, request.MinPrice.Value);
}
if (request.MaxPrice.HasValue)
{
predicate = predicate.DynamicAnd("Price", Ops.LessThanOrEqual, request.MaxPrice.Value);
}
WithFilter(predicate);
AddInclude(p => p.Category);
}
}
When you need related data:
public class OrderWithDetailsSpec : Specification<Order>
{
public OrderWithDetailsSpec(Guid orderId)
{
WithFilter(o => o.Id == orderId);
// Include related entities
AddInclude(o => o.Customer);
AddInclude(o => o.OrderItems);
AddInclude(o => o.ShippingAddress);
AddInclude(o => o.BillingAddress);
}
}
// Usage
var order = await repository.FirstOrDefaultAsync(
new OrderWithDetailsSpec(orderId),
cancellationToken);
// ❌ Bad - Will throw runtime error
var predicate = PredicateBuilder.New<Product>(p => p.IsActive);
var products = await context.Products
.Where(predicate)
.ToListAsync();
// ✅ Good - Required for LinqKit
var predicate = PredicateBuilder.New<Product>(p => p.IsActive);
var products = await context.Products
.AsExpandable() // REQUIRED!
.Where(predicate)
.ToListAsync();
// ✅ Good - Better performance for read-only
var products = await context.Products
.AsNoTracking()
.AsExpandable()
.Where(predicate)
.ToListAsync(cancellationToken);
// ✅ Good
var products = await repository.ToListAsync(spec, cancellationToken);
// ❌ Bad
var products = await repository.ToListAsync(spec, default);
// ✅ Good - Checks for null/empty before adding filter
if (!string.IsNullOrWhiteSpace(searchTerm))
{
predicate = predicate.DynamicAnd("Name", Ops.Contains, searchTerm);
}
// ❌ Bad - Might add empty filter
predicate = predicate.DynamicAnd("Name", Ops.Contains, searchTerm);
// ✅ Good - Testable, reusable
public async Task<IReadOnlyList<Product>> GetActiveProductsAsync(
CancellationToken cancellationToken)
{
var spec = new ActiveProductsSpec();
return await ToListAsync(spec, cancellationToken);
}
// ❌ Bad - Not reusable, harder to test
public async Task<IReadOnlyList<Product>> GetActiveProductsAsync(
CancellationToken cancellationToken)
{
return await _context.Products
.Where(p => !p.IsDeleted && p.IsActive)
.ToListAsync(cancellationToken);
}
// ❌ Runtime error: Expression tree may not contain a dynamic operation
var products = await context.Products
.Where(PredicateBuilder.New<Product>(p => p.IsActive))
.ToListAsync();
// ✅ Works correctly
var products = await context.Products
.AsExpandable()
.Where(PredicateBuilder.New<Product>(p => p.IsActive))
.ToListAsync();
// ❌ Bad - Ops.Contains only works with strings
predicate.DynamicAnd("Price", Ops.Contains, 100); // Runtime error!
// ✅ Good - Use appropriate operation for type
predicate.DynamicAnd("Price", Ops.Equal, 100);
// Note: DKNet automatically converts string operations to Equal/NotEqual
// for non-string types, so this is actually safe, but use the right op
// ❌ Bad - Might fail if value is null
predicate.DynamicAnd("OptionalField", Ops.Equal, nullableValue);
// ✅ Good - Check for null
if (nullableValue.HasValue)
{
predicate.DynamicAnd("OptionalField", Ops.Equal, nullableValue.Value);
}
using Shouldly;
using Xunit;
public class ActiveProductsSpecTests
{
[Fact]
public void ActiveProductsSpec_ShouldFilterCorrectly()
{
// Arrange
var spec = new ActiveProductsSpec();
var products = GetTestProducts();
// Act
var filtered = products
.AsQueryable()
.AsExpandable()
.Where(spec.FilterQuery!)
.ToList();
// Assert
filtered.ShouldNotBeEmpty();
filtered.ShouldAllBe(p => !p.IsDeleted && p.IsActive);
}
private static List<Product> GetTestProducts()
{
return new List<Product>
{
new() { Id = Guid.NewGuid(), Name = "Product 1", IsActive = true, IsDeleted = false },
new() { Id = Guid.NewGuid(), Name = "Product 2", IsActive = false, IsDeleted = false },
new() { Id = Guid.NewGuid(), Name = "Product 3", IsActive = true, IsDeleted = true }
};
}
}
efcore-repos - Using specifications with repositoriesefcore-abstractions - Base entities used in specificationsdknet-overview - Overall architecture contextWhen to Use This Skill: Reference this skill when building EF Core queries, implementing search/filter functionality, or creating reusable query logic with the Specification Pattern.