Контракт 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
ссылка на оригинал статьи https://habr.com/ru/articles/1044352/