Один экран в приложении, а на бэкенде несколько REST-вызовов, куча эндпоинтов и ответы, где 90% полей не используются. Теряем в скорости, усложняется фронтенд и приходится версионировать контракт, когда меняется формат данных.

GraphQL предлагает другой подход: один API-эндпоинт и запрос, в котором клиент сам указывает, какие поля ему нужны. Это снижает overfetching, уменьшает количество сетевых затрат и упрощает договоренности между фронтом и бэком за счет схемы как явного контракта и живой документации.

В новом переводе от команды Spring АйО разберем, где GraphQL реально помогает: как уйти от разрастания эндпоинтов, как держать контракт синхронизированным и что делать с типичными проблемами производительности и наблюдаемости, когда данные собираются из разных источников.

Почему GraphQL?

Сначала давайте обсудим слона в комнате: не стоит учить что-то только ради строки в резюме. Избегайте Resume Driven Development! Однако GraphQL решает несколько очень конкретных болевых точек, с которыми вы могли сталкиваться при построении традиционных REST API:

  • Больше никакой избыточной выборки. REST-эндпоинты часто возвращают огромный объём данных, когда вам нужны всего пара полей. GraphQL позволяет клиенту запрашивать ровно то, что ему нужно.

  • Меньше сетевых походов. Вместо обращения к четырём разным эндпоинтам, чтобы заполнить один экран интерфейса, один GraphQL-запрос может получить данные сразу из нескольких ресурсов.

  • Устранение разрастания эндпоинтов. Попрощайтесь с /users/{id}, /users/{id}/posts и сложными стратегиями версионирования (/v1, /v2). GraphQL даёт один-единственный эндпоинт, который обрабатывает все запросы к данным.

  • Встроенная документация. Поскольку GraphQL опирается на строго типизированную схему, вы получаете встроенную «живую» документацию. Инструменты вроде обозревателя GraphiQL позволяют интроспектировать и тестировать API прямо из коробки.

Проект: GraphQL Books

На протяжении статьи мы создаём приложение, которое управляет библиотекой книг, авторов и отзывов. Чтобы быстро начать работу без ручной установки внешних баз данных, мы используем Docker Compose — он поднимает базу PostgreSQL и экземпляр Zipkin для трассировки.

Вот compose.yaml, который используется для локальной разработки:

services:
  postgres:
    image: 'postgres:latest'
    environment:
      - 'POSTGRES_DB=books'
      - 'POSTGRES_PASSWORD=password'
      - 'POSTGRES_USER=admin'
    ports:
      - '5432:5432'
  zipkin:
    image: 'openzipkin/zipkin:latest'
    ports:
      - '9411:9411'

Модуль Docker Compose в Spring Boot автоматически запускает эти контейнеры, когда вы стартуете приложение. Никакой ручной настройки не требуется.

1. Разработка по схеме (Schema-First)

В отличие от REST API, где контракт часто остаётся «на потом», GraphQL продвигает подход schema-first. Мы заранее определяем контракт API с помощью Schema Definition Language (SDL). Это гарантирует, что фронтенд- и бэкенд-команды согласованы ещё до начала реализации.

Вот часть схемы из проекта:

type Query {
  books: [Book!]!
  book(id: Int!): Book!
  authors: [Author!]!
  search(text: String) : [SearchItem!]!
}

type Mutation {
  addBook(bookInput: BookInput): Book!
}

type Book {
  id: ID!
  title: String!
  author: Author!
}

type Author {
  id: ID!
  name: String!
  books: [Book!]!
}

input BookInput {
  title: String!
  authorId: Int!
}

union SearchItem = Author | Book

Ключевые моменты: типы операций Query и Mutation определяют, что именно могут запрашивать клиенты; input-типы используются для передачи структурированных аргументов; а union-тип — для union типов :)

Spring for GraphQL также выводит на старте отличный отчёт Schema Mapping Inspection Report. Он проверяет соответствие вашего Java-кода схеме, убеждаясь, что нет неразмеченных полей или отсутствующих fetcher’ов данных. Если что-то рассинхронизировано, вы увидите это прямо в выводе консоли ещё до того, как первый запрос попадёт на сервер.

2. Data Fetchers (контроллеры)

Чтобы связать GraphQL-схему с Java-кодом, мы создаём контроллеры. С помощью аннотаций вроде @QueryMapping и @MutationMapping мы сопоставляем бэкенд-логику операциям, определённым в схеме. Вот BookController:

@Controller
public class BookController {

    private final BookRepository bookRepository;
    private final AuthorRepository authorRepository;

    public BookController(BookRepository bookRepository, AuthorRepository authorRepository) {
        this.bookRepository = bookRepository;
        this.authorRepository = authorRepository;
    }

    @QueryMapping
    public List<Book> books() {
        return bookRepository.findAll();
    }

    @QueryMapping
    public Optional<Book> book(@Argument Long id) {
        return bookRepository.findById(id);
    }

    @MutationMapping
    public Book addBook(@Argument BookInput bookInput) {
        var author = authorRepository.findById(bookInput.authorId());

        var book = new Book();
        book.setTitle(bookInput.title());
        book.setAuthor(author.orElseThrow());

        return bookRepository.save(book);
    }
}

Аннотация @QueryMapping — это сокращение для @SchemaMapping(typeName = "Query"). Spring автоматически сопоставляет имя метода с соответствующим полем в схеме. Аннотация @Argument привязывает входящие аргументы GraphQL к параметрам метода.

Для мутации мы используем input-тип, чтобы сгруппировать поля, необходимые для создания книги. На стороне Java это простой record:

public record BookInput(String title, Long authorId) {}

Records идеально подходят для GraphQL input-типов: они неизменяемые, лаконичные, а Spring for GraphQL может автоматически связывать входящие аргументы с конструктором record’а.

С включённым интерфейсом GraphiQL вы можете интерактивно тестировать эти операции:

# найти все книги вместе с их авторами
query {
  books {
    id
    title
    author {
      name
    }
  }
}

# найти конкретную книгу, используя переменные
query findBookById($id: Int!) {
  book(id: $id) {
    id
    title
    author {
      id
      name
    }
  }
}

# добавить новую книгу
mutation {
  addBook(bookInput: {title: "new book", authorId: 1}) {
    id
    title
  }
}

3. Решение проблемы N+1 с пакетной загрузкой

Производительность критически важна. Если мы запрашиваем список авторов, а затем отдельно получаем книги для каждого автора, мы сталкиваемся с классической проблемой N+1 запросов:

@SchemaMapping
public List<Book> books(Author author) throws InterruptedException {
  log.info("Retrieving books for author " + author.getName());
  return bookRepository.findByAuthor(author);
}

Если у вас 6 авторов, этот метод выполняет 6 отдельных запросов. Вы можете наблюдать, как SQL-операторы накапливаются в консоли, с spring.jpa.show-sql=true. Исправление — аннотация @BatchMapping, которая группирует все эти обращения в один вызов к базе данных:

@BatchMapping
public List<List<Book>> books(List<Author> authors) {
    log.info("Batch loading books for {} authors", authors.size());

    List<Long> authorIds = authors.stream()
        .map(Author::getId)
        .toList();

    List<Book> allBooks = bookRepository.findByAuthorIdIn(authorIds);

    Map<Long, List<Book>> booksByAuthorId = allBooks.stream()
        .collect(Collectors.groupingBy(book -> book.getAuthor().getId()));

    return authors.stream()
        .map(author -> booksByAuthorId.getOrDefault(author.getId(), Collections.emptyList()))
        .toList();
}

Сигнатура метода говорит сама за себя. Spring передаёт полный список объектов Author, для которых нужно разрешить поле books, а вы возвращаете List<List>, где порядок соответствует входному списку. Один запрос — и готово.

Чтобы сделать это ещё более масштабируемым, можно включить виртуальные потоки (Virtual Threads), чтобы выборка данных выполнялась параллельно, а не последовательно в рамках одного потока Tomcat. Это одна строка в конфигурации:

spring:
  threads:
    virtual:
      enabled: true

4. Продвинутый поиск с Unions

Что если пользователь ищет по ключевому слову, а вы хотите возвращать либо Book, либо Author? Объединения GraphQL (Unions) позволяют это сделать. В схеме мы определяем:

union SearchItem = Author | Book

А затем реализуем SearchController, который возвращает полиморфные результаты:

@Controller
public class SearchController {

    private static final Logger log = LoggerFactory.getLogger(SearchController.class);

    private final BookRepository bookRepository;
    private final AuthorRepository authorRepository;

    public SearchController(BookRepository bookRepository, AuthorRepository authorRepository) {
        this.bookRepository = bookRepository;
        this.authorRepository = authorRepository;
    }

    @QueryMapping
    public List<Object> search(@Argument String text) {
        log.debug("Searching for '{}'", text);

        List<Object> results = new ArrayList<>();

        results.addAll(authorRepository.findAllByNameContainsIgnoreCase(text));
        results.addAll(bookRepository.findAllByTitleContainsIgnoreCase(text));

        return results;
    }
}

Тип возвращаемого значения — List, потому что результаты могут быть экземплярами Author или Book. Spring for GraphQL автоматически выполняет разрешение типа, поскольку оба класса находятся в том же пакете, что и типы схемы, которые они представляют. Клиенты затем могут использовать inline fragments, чтобы запрашивать поля, специфичные для конкретного типа:

query {
  search(text: "Spring") {
    ... on Book {
      title
    }
    ... on Author {
      name
    }
  }
}

5. Query By Example и @GraphQlRepository

Реализация гибкого поиска быстро усложняется. Представьте систему отзывов, где пользователи могут захотеть фильтровать по оценке, по имени рецензента, по статусу верификации — или по любой комбинации этих параметров. Традиционно вы бы в итоге писали отдельный метод репозитория для каждой возможной комбинации.

Spring for GraphQL решает эту задачу с помощью @GraphQlRepository и Query by Example:

@GraphQlRepository
public interface ReviewRepository extends JpaRepository<Review, Long>, QueryByExampleExecutor<Review> {
}

Это и есть весь репозиторий. Аннотация @GraphQlRepository автоматически создаёт data fetcher’ы для запросов в вашей схеме, которые соответствуют этому типу. В сочетании с QueryByExampleExecutor клиенты могут динамически фильтровать данные без того, чтобы вы писали какой-либо дополнительный код контроллеров.

На стороне схемы мы определяем input-тип ReviewFilter:

input ReviewFilter {
  rating: Int
  verified: Boolean
  reviewerName: String
}

И соответствующий Java record:

public record ReviewFilter(
  Integer rating,
  Boolean verified,
  String reviewerName
) {}

Теперь клиенты могут отправлять такие запросы — и фильтрация будет работать «из коробки»:

# найти только верифицированные отзывы
{
  reviews(filter: { verified: true }) {
    reviewerName
    rating
    comment
  }
}

# найти отзывы конкретного рецензента
{
  reviews(filter: { reviewerName: "Sarah Chen" }) {
    book {
      title
    }
    rating
    comment
  }
}

Поля в фильтре все допускают null, поэтому клиенты могут включать или опускать любую комбинацию, которая им нужна.

6. Репозитории Spring Data AOT

Spring Boot 4 вводит Spring Data AOT (Ahead-of-Time) компиляцию. Добавив цель process-aot в Maven-плагин, мы переносим обработку запросов репозиториев из runtime в build time. Вместо того чтобы на каждом старте разбирать имена методов производных запросов, AOT-процессор заранее генерирует SQL-выражения и реализации репозиториев во время сборки.

В проекте это уже настроено в pom.xml:

<plugin>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-maven-plugin</artifactId>
    <executions>
        <execution>
            <id>process-aot</id>
            <goals>
                <goal>process-aot</goal>
            </goals>
        </execution>
    </executions>
</plugin>

Это даёт более быстрый старт (команда Spring упоминает улучшение на 50–70%), обнаружение ошибок на этапе сборки — например, опечаток в именах методов вроде findByNamme, — меньший расход памяти и возможность просматривать сгенерированные SQL-реализации в target/. Репозитории в этом проекте, такие как BookRepository и AuthorRepository, используют производные методы запросов, которые выигрывают от AOT-обработки:

public interface BookRepository extends JpaRepository<Book, Long> {

    @Override
    @EntityGraph(attributePaths = "author")
    List<Book> findAll();

    List<Book> findAllByTitleContainsIgnoreCase(String title);

    List<Book> findByAuthorIdIn(List<Long> authorIds);
}

7. Интеграция клиентского приложения

Мы не ограничиваемся сервером. Мы также рассматриваем, как потреблять GraphQL API из Java-приложения. ClientApp — это отдельное приложение Spring Boot (с WebApplicationType.NONE), которое демонстрирует нативный HttpSyncGraphQlClient от Spring:

@Import(RestClientAutoConfiguration.class)
public class ClientApp implements ApplicationRunner {

    private static final Logger log = LoggerFactory.getLogger(ClientApp.class);
    private final HttpSyncGraphQlClient client;

    public ClientApp(RestClient.Builder builder) {
        RestClient restClient = builder.baseUrl("http://localhost:8080/graphql").build();
        this.client = HttpSyncGraphQlClient.builder(restClient).build();
    }

    public static void main(String[] args) {
        new SpringApplicationBuilder(ClientApp.class).web(WebApplicationType.NONE).run(args);
    }

    @Override
    public void run(ApplicationArguments args) throws Exception {
        var document = """
                query findBookById($id: Int!) {
                    book(id: $id) {
                        id
                        title
                        author {
                            id
                            name
                        }
                    }
                }
                """;
        var book = client.document(document)
                .variable("id", 1L)
                .retrieveSync("book")
                .toEntity(Book.class);
        log.info("Book: {}", book);
    }
}

Клиент построен поверх RestClient, поэтому реактивные зависимости не требуются. Вы описываете GraphQL-запрос строкой, привязываете переменные типобезопасными методами и десериализуете ответ напрямую в ваш entity-класс. Вызов .toEntity(Book.class) автоматически обрабатывает вложенное поле author.

8. Наблюдаемость

Поскольку GraphQL API может «распараллеливать» выборку данных между базами, кэшами и внешними сервисами, наблюдаемость становится крайне важной. В этот проект добавлены зависимости Micrometer tracing bridge и Zipkin reporter, а в конфигурации приложения вероятность сэмплирования выставлена на 100%:

management:
  tracing:
    sampling:
      probability: 1.0

Spring for GraphQL имеет встроенную инструментацию на базе Micrometer Observation API. Это означает, что каждый GraphQL-запрос и каждый нетривиальный data fetcher автоматически трассируется. Вы можете открыть Grafana по адресу http://localhost:3000 и увидеть, как именно GraphQL-запрос раскладывается по вашим data fetcher’ам, какие из них медленные и где находятся узкие места.

9. Тестирование

Проект также включает полный набор тестов с использованием GraphQlTester из Spring for GraphQL. Вот пример, который тестирует мутацию добавления новой книги:

@SpringBootTest
@Transactional
class BookControllerTests {

    private final GraphQlTester graphQlTester;

    @Autowired
    BookControllerTests(ExecutionGraphQlService graphQlService) {
        this.graphQlTester = ExecutionGraphQlServiceTester.builder(graphQlService).build();
    }

    @Test
    void shouldAddNewBook() {
        var document = """
        mutation($input: BookInput!) {
            addBook(bookInput: $input) {
                id
                title
                author {
                    id
                    name
                }
            }
        }
        """;
        Map<String, Object> input = Map.of(
                "title", "New Book",
                "authorId", 1
        );

        graphQlTester.document(document)
                .variable("input", input)
                .execute()
                .path("addBook")
                .entity(Book.class)
                .satisfies(book -> {
                    assertThat(book.getTitle()).isEqualTo("New Book");
                    assertThat(book.getAuthor()).isNotNull();
                });
    }
}

ExecutionGraphQlServiceTester выполняет запросы к полноценному GraphQL-движку без запуска HTTP-сервера, что делает тесты быстрыми и сосредоточенными. В проекте во время тестов используются Testcontainers для PostgreSQL, поэтому ваши тестовые данные изолированы и воспроизводимы.

Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм — Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано.

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


  1. hddn
    21.04.2026 13:25

    Кто уходит то? Полтора маргинала?

    Если и уходят - то в JSON RPC. А из graphql видел как назад возвращались к rest.


  1. mosinnik
    21.04.2026 13:25

    Что мешало просто перевести и заголовок "GraphQL for Java Developers", а не выдумывать кликбейт?


  1. MyraJKee
    21.04.2026 13:25

    Гладко было на бумаге, да забыли про овраги.

    Технологии уже больше 11 лет? Но что-то широкого распространения не получила


  1. MrCheater
    21.04.2026 13:25

    Как будто бы хабр 10 летней давности открыл :-)

    Согласен с комментаторами, что с rest уходят только в grpc


    1. hddn
      21.04.2026 13:25

      не только

      помимо grpc есть другие rpc


  1. lexasub
    21.04.2026 13:25

    graphql любят особенно фронты когда бэк для фронта делают


  1. cssru
    21.04.2026 13:25

    Всему своя технология. Хотите избежать избыточности? Идите в graphql. Нужен жёсткий контракт? Идите в rest.