Почему я перестал передавать Spring Pageable в контракты слоя приложения

от автора

Контракт use case должен описывать потребность приложения, а не API выбранного persistence фреймворка.

Spring Data пагинация хорошо работает в CRUD приложениях. Для многих проектов Pageable в application service это разумный компромисс. Проблема возникает, когда Pageable становится частью публичного контракта use case:

interface OwnerUseCase {    Page<OwnerView> execute(Pageable pageable);}

Теперь каждый клиент: контроллер, шедулер, message listener, — вынужден знать о Spring Data API. Контракт слоя приложения начинает описывать детали persistence фреймворка, а не потребность приложения.

Независимая от фреймворка модель пагинации

PageQuery, PageResult и SortRequest живут в слое приложения. Никаких зависимостей на Spring Data. Обычные Java records:

public record PageQuery(int page, int size, List<SortRequest> sort) {    public static PageQuery of(int page, int size) {        return new PageQuery(page, size, List.of());    }    public static PageQuery of(int page, int size, List<SortRequest> sort) {        return new PageQuery(page, size, sort);    }}public record PageResult<T>(List<T> content, int page, int size, long total) {}

SortRequest использует доменные имена полей. Никакого SQL, никаких деталей инфраструктуры:

public record SortRequest(String field, Direction direction) {    public enum Direction { ASC, DESC }    private static final Set<String> ALLOWED_FIELDS = Set.of("id", "name");    public SortRequest {        if (!ALLOWED_FIELDS.contains(field))            throw new IllegalArgumentException("Invalid sort field: " + field);    }    public static SortRequest asc(String field)  {            return new SortRequest(field, Direction.ASC); }    public static SortRequest desc(String field) {            return new SortRequest(field, Direction.DESC);     }}

Вызывающий код использует доменный язык. Никакие SQL aliases не проникают в слой приложения:

// одно полеPageQuery.of(0, 10, List.of(SortRequest.asc("name")));// несколько полейPageQuery.of(0, 10, List.of(    SortRequest.asc("name"),    SortRequest.desc("id")));

Контракт репозитория чтения возвращает read model напрямую (CQRS подход), репозиторий намеренно возвращает проекцию, а не агрегат:

public interface OwnerReadRepository {    PageResult<OwnerView> findAllFlat(PageQuery request);}

Инфраструктурный адаптер

На границе инфраструктуры PageQuery конвертируется в SQL параметры. SQL aliases живут здесь и нигде больше. Слой приложения никогда не видит ни Pageable, ни SQL aliases, ни сырой ResultSet:

private static final Map<String, String> FIELD_MAP = Map.of(    "id",   "o.id",    "name", "o.name");private static final String SELECT_PAGE = """    SELECT o.id   AS owner_id,           o.name AS owner_name    FROM owners o    ORDER BY %s    LIMIT :limit OFFSET :offset    """;@Repositorypublic class JdbcOwnerReadRepository implements OwnerReadRepository {    private String buildOrderBy(PageQuery request) {        if (request.sort().isEmpty()) return "o.id ASC";        return request.sort().stream()                .map(s -> FIELD_MAP.get(s.field()) + " " + s.direction().name())                .collect(Collectors.joining(", "));    }    @Override    public PageResult<OwnerView> findAllFlat(PageQuery request) {        int offset = request.page() * request.size();        // orderBy собирается только из полей FIELD_MAP + enum direction — SQL инъекция невозможна        String orderBy = buildOrderBy(request);        List<OwnerView> content = jdbc.sql(SELECT_PAGE.formatted(orderBy))                .param("limit",  request.size())                .param("offset", offset)                .query(OwnerProjection.class)                .stream()                .map(ViewMapper::toView)                .toList();        long total = jdbc.sql(COUNT_ALL).query(Long.class).single();        return new PageResult<>(content, request.page(), request.size(), total);    }}

OwnerProjection и ViewMapper package-private. Они никогда не выходят за пределы инфраструктурного пакета. В большинстве случаев смена persistence технологии затрагивает только адаптер. Слой приложения остаётся неизменным.

Тестируемость

Application service тестируется без Spring контекста:

@Testvoid returns_paginated_owners() {    var request = PageQuery.of(0, 10);    var expected = new PageResult<>(List.of(ownerView), 0, 10, 1L);    when(repository.findAllFlat(request)).thenReturn(expected);    var result = service.getOwners(request);    assertThat(result.content().get(0).name()).isEqualTo("jack1");    verify(repository).findAllFlat(request);}

Нет Spring контекста, нет Pageable, нет Spring Data зависимостей в тесте.

Компромисс

Этот подход добавляет абстракции. В простом CRUD это может быть излишним. И это валидный выбор.

В системе где архитектурные границы важны — явная модель пагинации сохраняет независимость слоя приложения от конкретного фреймворка. Стоит отметить: в разных bounded context’ах может быть своя семантика пагинации: cursor-based, keyset. PageQuery не стоит делать общим на всю систему в таком случае.

И да, PageQuery выглядит похоже на Pageable. Это намеренно. Цель не в том, чтобы изобрести новую модель пагинации. Цель в том, чтобы контракт приложения принадлежал приложению, а не Spring Data.

Рабочие примеры на Гитхабе

Плоская пагинация — PageQuery, PageResult, чистый SQL

Пагинация с многополевой сортировкой и whitelist защитой

Пагинация по объектному графу (owner → pets → visits)

ссылка на оригинал статьи https://habr.com/ru/articles/1044352/