Ardalis.Specification для C# (.NET): полное руководство с примерами

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

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 инкапсулирует всю логику: фильтрацию по

Рекомендаций