В 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

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

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

Комментарии (5)


  1. Snaret
    07.06.2026 01:33

    Я бы порекомендовал для затравки статьи описать проблему которую вы решили применением данного решения. Пока ценность только в отказе от спринга.


    1. aleksandy
      07.06.2026 01:33

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


      1. boeing1337
        07.06.2026 01:33

        Ради строчки в импорте? Какой такой необходимости ? Будет необходимость выкинуть напишешь новую дтошку тогда когда оно действительно надо будет в не сейчас на это время тратить и архитектуру усложнять ради следования каким то правилам из книжки.


        1. DmitriiRussuLink Автор
          07.06.2026 01:33

          Если зависимость на Spring Data считается несущественной, то по этой логике можно и JpaRepository напрямую в контроллер передавать.

          Но тогда теряется изоляция слоёв, а тестирование бизнес-логики без Spring-контекста становится заметно сложнее.

          Помните тот веселый советский мультик? Там мальчик постоянно повторял - а, и так сойдет! В общем в конце он очень раскаялся.


    1. DmitriiRussuLink Автор
      07.06.2026 01:33

      Спасибо за замечание! Упустил некоторые моменты как само собой разумеющиеся. Правки внёс.

      От Spring отказываться никто не призывает — это отличный контейнер и де-факто стандарт. Но минимизировать его влияние на внутреннее устройство системы — это уже другой вопрос.