В Clean Architecture зависимости направлены внутрь — слой приложения не должен знать об инфраструктуре. Но стоит добавить пагинацию, и Pageable из Spring Data оказывается прямо в контракте use case. Деталь фреймворка проникает в ядро приложения, нарушая направление зависимостей.
Ниже — как это исправить.
Контракт 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 зависимостей в тесте.
Компромисс
Этот подход добавляет абстракции. В классической n-слойной архитектуре с зависимостями сверху вниз он избыточен — Pageable в application layer там не нарушает направления зависимостей. Приём работает именно в контексте Ports & Adapters / Clean Architecture, где слой приложения должен оставаться изолированным ядром. Без этой изоляции независимые unit-тесты без Spring-контекста становятся невозможны.
В системе где архитектурные границы важны — явная модель пагинации сохраняет независимость слоя приложения от конкретного фреймворка. Стоит отметить: в разных bounded context’ах может быть своя семантика пагинации: cursor-based, keyset. PageQuery не стоит делать общим на всю систему в таком случае.
И да, PageQuery выглядит похоже на Pageable. Это намеренно. Цель не в том, чтобы изобрести новую модель пагинации. Цель в том, чтобы контракт приложения принадлежал приложению, а не Spring Data.
Рабочие примеры на Гитхабе
Плоская пагинация - PageQuery, PageResult, чистый SQL
Snaret
Я бы порекомендовал для затравки статьи описать проблему которую вы решили применением данного решения. Пока ценность только в отказе от спринга.
aleksandy
Ценность, очевидно, в том, что пропадает гвоздями прибитая зависимость на спринг дату. И при необходимости его можно спокойно выкинуть, не меняя контракт внешнего взаимодействия. В целом подход правильный и его нужно придерживаться, если приложение не голый CRUD.
boeing1337
Ради строчки в импорте? Какой такой необходимости ? Будет необходимость выкинуть напишешь новую дтошку тогда когда оно действительно надо будет в не сейчас на это время тратить и архитектуру усложнять ради следования каким то правилам из книжки.
DmitriiRussuLink Автор
Если зависимость на Spring Data считается несущественной, то по этой логике можно и JpaRepository напрямую в контроллер передавать.
Но тогда теряется изоляция слоёв, а тестирование бизнес-логики без Spring-контекста становится заметно сложнее.
Помните тот веселый советский мультик? Там мальчик постоянно повторял - а, и так сойдет! В общем в конце он очень раскаялся.
DmitriiRussuLink Автор
Спасибо за замечание! Упустил некоторые моменты как само собой разумеющиеся. Правки внёс.
От Spring отказываться никто не призывает — это отличный контейнер и де-факто стандарт. Но минимизировать его влияние на внутреннее устройство системы — это уже другой вопрос.