Контракт 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
""";
@Repository
public 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 контекста:
@Test
void 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
Автор: DmitriiRussuLink
