⚙️
Паттерн пагинации и фильтрации через один запрос и нормальные DTOПагинация в API часто превращается либо в магию репозиториев, либо в жуткий копипаст. Лучше держать всё предельно простым: один запрос с фильтрами, подсчётом и проекцией в DTO.
Что вообще хотим от пагинацииКогда фронт просит список пользователей, ему нужно не только «20 штук с offset», а нормальные данные
• Текущая страница.
• Размер страницы.
• Список элементов.
• Общее количество записей под этими фильтрами.
Контейнер для любого списка: пользователей, заказов или логов.
public sealed record Paginated<T>(
IReadOnlyList<T> Items,
int Page,
int Size,
int Total
);
• Items то, что реально отображаем.
• Page номер текущей страницы.
• Size сколько элементов на странице.
• Total сколько записей всего под заданным фильтром.
Зачем DTO, а не сущность из EF• EF сущность часто содержит поля, которые нельзя светить наружу (пароли, внутренние флаги, технические поля).
• DTO можно менять отдельно от внутренней модели база может жить своей жизнью, а API остаётся стабильным.
• EF Core умеет проецировать прямо в DTO через
Select, не тянуть все поля сущности и не включать трекинг.
В итоге хендлер не выкидывает наружу сырые сущности, а отдаёт ровно то, что нужно клиенту.
Как выглядит запрос сверхуpublic sealed record SearchUsersQuery(
string? Q,
int Page = 1,
int Size = 20
) : IRequest<Paginated<UserSummary>>;
• Q — строка поиска.
• Page — номер страницы.
• Size — размер страницы.
Контроллер ничего не знает о базе и EF он просто пробрасывает запрос в Application слой:
[HttpGet]
public async Task<ActionResult<Paginated<UserSummary>>> Search(
[FromQuery] string? q,
[FromQuery] int page,
[FromQuery] int size,
ISender sender,
CancellationToken ct
)
{
var result = await sender.Send(new SearchUsersQuery(q, page, size), ct);
return Ok(result);
}
Что делает хендлер под капотом
public async Task<Paginated<UserSummary>> Handle(
SearchUsersQuery query,
CancellationToken ct)
{
var users = _db.Users.AsQueryable();
if (!string.IsNullOrWhiteSpace(query.Q))
{
var q = query.Q.Trim();
users = users.Where(u =>
u.Email.Contains(q) ||
u.Name.Contains(q));
}
var total = await users.CountAsync(ct);
var items = await users
.OrderBy(u => u.Email)
.Skip((query.Page - 1) * query.Size)
.Take(query.Size)
.AsNoTracking()
.Select(u => new UserSummary(
u.Id,
u.Email,
u.IsActive
))
.ToListAsync(ct);
return new Paginated<UserSummary>(
items,
query.Page,
query.Size,
total
);
}
•
AsQueryable() чтобы можно было постепенно навешивать фильтры.
• Фильтрация по Q делается в базе, а не в памяти. Email и Name фильтруются прямо в SQL.
•
CountAsync считает Total для уже отфильтрованного набора, без
Skip/
Take. Это количество строк, которые удовлетворяют фильтрам.
•
Skip и
Take делают пагинацию на стороне БД через
OFFSET /
FETCH или аналог, а не в памяти приложения.
•
AsNoTracking() говорит EF Core не отслеживать сущности в change tracker, что ускоряет чистые запросы на чтение.
•
Select сразу проецирует в UserSummary EF не создаёт полноценные сущности, не подгружает лишние поля и не собирает сложные графы.
Всё это превращается в один адекватный SQL запрос, а не в серию
SELECT * плюс ручная фильтрация и подсчёты.
Паттерн пагинации это не про сложность, а наоборот про простоту и предсказуемость. Отдельные DTO, контейнер
Paginated<T>, один явный запрос с фильтрами, подсчётом и
AsNoTracking() дают API, которое не врёт клиенту, хорошо масштабируется и остаётся читабельным через год.
📍 Навигация:
Вакансии •
Задачи •
Собесы🐸Библиотека шарписта#sharp_view