Ardalis.Specification для C# (.NET): полное руководство с примерами
В современной разработке на C# (.NET) одной из ключевых задач является организация чистого, тестируемого и поддерживаемого кода для работы с данными. Паттерн Specification (Спецификация) — это мощный инструмент, который позволяет инкапсулировать бизнес-правила и логику запросов в виде многократно используемых, композируемых и тестируемых классов. Библиотека Ardalis.Specification является де-факто стандартом реализации этого паттерна в экосистеме .NET, предоставляя разработчикам элегантный способ абстрагироваться от конкретных ORM (Entity Framework Core, Dapper и т.д.) и строить гибкие запросы к данным.
Зачем это нужно? В типичном приложении, использующем Entity Framework Core, логика запросов часто размазана по всему коду: в контроллерах, сервисах, репозиториях. Это приводит к дублированию кода (например, один и тот же фильтр "активные пользователи" может повторяться в разных местах), сложности тестирования (нужно мокать DbContext) и хрупкости (изменение одного запроса может сломать другой). Ardalis.Specification решает эти проблемы, предлагая единый, строго типизированный способ описания того, какие данные нужно получить, не привязываясь к тому, как именно они будут получены.
Библиотека предоставляет богатый API для построения запросов: от простых фильтраций (Where) до сложных включений связанных данных (Include, ThenInclude), сортировок (OrderBy, ThenBy), пагинации (Skip, Take) и проекций (Select). Она полностью совместима с Entity Framework Core (через пакет Ardalis.Specification.EntityFrameworkCore), но может быть адаптирована и для других провайдеров данных. Благодаря этому, ваш код становится чище, логика запросов — переиспользуемой, а тестирование — значительно проще.
Установка
Для установки библиотеки используется NuGet. В зависимости от вашего ORM, нужно установить основной пакет и пакет-провайдер.
# Основной пакет (содержит базовые классы и интерфейсы)
dotnet add package Ardalis.Specification
# Пакет для Entity Framework Core (содержит реализацию репозитория)
dotnet add package Ardalis.Specification.EntityFrameworkCore
# Если вы используете только базовый пакет без EF Core (например, для тестов или кастомной реализации)
dotnet add package Ardalis.Specification
Быстрый старт
Рассмотрим минимальный рабочий пример. Допустим, у нас есть сущность Product и мы хотим получить все продукты, цена которых больше 100.
// 1. Определяем сущность
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
public bool IsActive { get; set; }
}
// 2. Создаем спецификацию
public class ProductsWithPriceGreaterThanSpec : Specification<Product>
{
public ProductsWithPriceGreaterThanSpec(decimal minPrice)
{
Query.Where(p => p.Price > minPrice);
}
}
// 3. Используем спецификацию с репозиторием
public class ProductService
{
private readonly IRepositoryBase<Product> _repository;
public ProductService(IRepositoryBase<Product> repository)
{
_repository = repository;
}
public async Task<List<Product>> GetExpensiveProductsAsync(decimal minPrice)
{
var spec = new ProductsWithPriceGreaterThanSpec(minPrice);
return await _repository.ListAsync(spec);
}
}
В этом примере мы создали класс-спецификацию, который инкапсулирует логику фильтрации. Репозиторий IRepositoryBase (из пакета Ardalis.Specification.EntityFrameworkCore) автоматически применяет эту спецификацию к вашему DbContext и возвращает результат. Это и есть суть паттерна: отделение описания запроса от его выполнения.
Основные методы и классы
Библиотека предоставляет мощный API для построения запросов. Рассмотрим 8 ключевых методов/классов.
1. Query.Where(Expression<Func<T, bool>> criteria)
Сигнатура: ISpecificationBuilder<T> Where(Expression<Func<T, bool>> criteria)
Что делает: Добавляет условие фильтрации (аналог SQL WHERE). Можно вызывать несколько раз — условия будут объединены через логическое И (AND).
public class ActiveUsersSpec : Specification<User>
{
public ActiveUsersSpec()
{
Query.Where(u => u.IsActive);
}
}
// Использование с несколькими условиями
public class ActiveUsersOlderThanSpec : Specification<User>
{
public ActiveUsersOlderThanSpec(int minAge)
{
Query.Where(u => u.IsActive)
.Where(u => u.Age > minAge);
}
}
2. Query.Include(Expression<Func<T, object>> path)
Сигнатура: ISpecificationBuilder<T> Include(Expression<Func<T, object>> path)
Что делает: Добавляет жадную загрузку связанных данных (аналог Include в EF Core). Позволяет избежать N+1 проблемы.
public class OrderWithItemsSpec : Specification<Order>
{
public OrderWithItemsSpec()
{
Query.Include(o => o.Items);
}
}
// Включение нескольких навигационных свойств
public class OrderWithItemsAndCustomerSpec : Specification<Order>
{
public OrderWithItemsAndCustomerSpec()
{
Query.Include(o => o.Items)
.Include(o => o.Customer);
}
}
3. Query.Include(string path)
Сигнатура: ISpecificationBuilder<T> Include(string path)
Что делает: Добавляет жадную загрузку по строковому пути. Полезно для динамических включений или когда выражение LINQ невозможно составить.
public class BlogWithAuthorSpec : Specification<Blog>
{
public BlogWithAuthorSpec()
{
Query.Include("Author");
}
}
4. Query.ThenInclude(Expression<Func<TPreviousProperty, object>> thenIncludeExpression)
Сигнатура: ISpecificationBuilder<T> ThenInclude(Expression<Func<TPreviousProperty, object>> thenIncludeExpression)
Что делает: Добавляет вложенную жадную загрузку (аналог ThenInclude в EF Core). Используется после Include для загрузки свойств свойств.
public class OrderWithItemsAndProductSpec : Specification<Order>
{
public OrderWithItemsAndProductSpec()
{
Query.Include(o => o.Items)
.ThenInclude(item => item.Product);
}
}
5. Query.OrderBy(Expression<Func<T, object>> orderExpression)
Сигнатура: ISpecificationBuilder<T> OrderBy(Expression<Func<T, object>> orderExpression)
Что делает: Добавляет сортировку по возрастанию. Можно комбинировать с ThenBy для множественной сортировки.
public class ProductsSortedByNameSpec : Specification<Product>
{
public ProductsSortedByNameSpec()
{
Query.OrderBy(p => p.Name);
}
}
// Множественная сортировка
public class ProductsSortedByCategoryThenNameSpec : Specification<Product>
{
public ProductsSortedByCategoryThenNameSpec()
{
Query.OrderBy(p => p.Category)
.ThenBy(p => p.Name);
}
}
6. Query.OrderByDescending(Expression<Func<T, object>> orderExpression)
Сигнатура: ISpecificationBuilder<T> OrderByDescending(Expression<Func<T, object>> orderExpression)
Что делает: Добавляет сортировку по убыванию. Работает аналогично OrderBy, но в обратном порядке.
public class NewestProductsSpec : Specification<Product>
{
public NewestProductsSpec()
{
Query.OrderByDescending(p => p.CreatedAt);
}
}
7. Query.Skip(int skip) / Query.Take(int take)
Сигнатура: ISpecificationBuilder<T> Skip(int skip) и ISpecificationBuilder<T> Take(int take)
Что делает: Реализуют пагинацию. Skip пропускает указанное количество записей, Take ограничивает количество возвращаемых записей. Должны использоваться вместе с сортировкой для предсказуемых результатов.
public class PagedProductsSpec : Specification<Product>
{
public PagedProductsSpec(int pageIndex, int pageSize)
{
Query.OrderBy(p => p.Id)
.Skip((pageIndex - 1) * pageSize)
.Take(pageSize);
}
}
8. Query.Select(Expression<Func<T, TResult>> selector)
Сигнатура: ISpecificationBuilder<T, TResult> Select(Expression<Func<T, TResult>> selector)
Что делает: Позволяет выполнить проекцию — преобразовать сущность в DTO или анонимный тип. Используется для уменьшения объема передаваемых данных.
public class ProductDto
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
}
public class ProductDtoSpec : Specification<Product, ProductDto>
{
public ProductDtoSpec()
{
Query.Select(p => new ProductDto
{
Id = p.Id,
Name = p.Name,
Price = p.Price
});
}
}
Реальный практический пример
Рассмотрим более сложный пример — систему управления заказами. У нас есть сущности Customer, Order, OrderItem и Product. Мы хотим реализовать функциональность получения "активных заказов" (статус "Active") для конкретного клиента, с включением информации о товарах и их категориях, отсортированных по дате создания, с пагинацией и проекцией в DTO.
// Сущности
public class Customer
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public ICollection<Order> Orders { get; set; } = new List<Order>();
}
public class Order
{
public int Id { get; set; }
public int CustomerId { get; set; }
public Customer Customer { get; set; } = null!;
public string Status { get; set; } = string.Empty; // "Active", "Completed", "Cancelled"
public DateTime CreatedAt { get; set; }
public ICollection<OrderItem> Items { get; set; } = new List<OrderItem>();
}
public class OrderItem
{
public int Id { get; set; }
public int OrderId { get; set; }
public Order Order { get; set; } = null!;
public int ProductId { get; set; }
public Product Product { get; set; } = null!;
public int Quantity { get; set; }
public decimal UnitPrice { get; set; }
}
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Category { get; set; } = string.Empty;
public decimal Price { get; set; }
}
// DTO
public class ActiveOrderDto
{
public int OrderId { get; set; }
public DateTime CreatedAt { get; set; }
public List<OrderItemDto> Items { get; set; } = new();
public decimal TotalAmount { get; set; }
}
public class OrderItemDto
{
public string ProductName { get; set; } = string.Empty;
public string Category { get; set; } = string.Empty;
public int Quantity { get; set; }
public decimal UnitPrice { get; set; }
}
// Спецификация
public class ActiveOrdersForCustomerSpec : Specification<Order, ActiveOrderDto>
{
public ActiveOrdersForCustomerSpec(int customerId, int pageIndex, int pageSize)
{
Query.Where(o => o.CustomerId == customerId)
.Where(o => o.Status == "Active")
.Include(o => o.Items)
.ThenInclude(oi => oi.Product)
.OrderByDescending(o => o.CreatedAt)
.Skip((pageIndex - 1) * pageSize)
.Take(pageSize)
.Select(o => new ActiveOrderDto
{
OrderId = o.Id,
CreatedAt = o.CreatedAt,
Items = o.Items.Select(oi => new OrderItemDto
{
ProductName = oi.Product.Name,
Category = oi.Product.Category,
Quantity = oi.Quantity,
UnitPrice = oi.UnitPrice
}).ToList(),
TotalAmount = o.Items.Sum(oi => oi.Quantity * oi.UnitPrice)
});
}
}
// Сервис
public class OrderService
{
private readonly IRepositoryBase<Order> _orderRepository;
public OrderService(IRepositoryBase<Order> orderRepository)
{
_orderRepository = orderRepository;
}
public async Task<List<ActiveOrderDto>> GetActiveOrdersAsync(int customerId, int pageIndex, int pageSize)
{
var spec = new ActiveOrdersForCustomerSpec(customerId, pageIndex, pageSize);
return await _orderRepository.ListAsync(spec);
}
public async Task<int> GetActiveOrdersCountAsync(int customerId)
{
var spec = new ActiveOrdersForCustomerSpec(customerId, 1, int.MaxValue);
return await _orderRepository.CountAsync(spec);
}
}
// Регистрация в Program.cs (минимальный API)
// builder.Services.AddScoped<IRepositoryBase<Order>, EfRepository<Order>>();
// builder.Services.AddScoped<OrderService>();
В этом примере спецификация ActiveOrdersForCustomerSpec инкапсулирует всю логику: фильтрацию по
Также в библиотеке
Redis клиент для PHP: Predis — установка, подключение, примеры
Doctrine ORM для PHP: полное руководство с примерами
Интернационализация (i18n) в Rust: лучшие библиотеки и практики
Отправка Email на C# (.NET): библиотека System.Net.Mail