Один экран в приложении, а на бэкенде несколько 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:
@Controllerpublic 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 запросов:
@SchemaMappingpublic 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, которая группирует все эти обращения в один вызов к базе данных:
@BatchMappingpublic 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, который возвращает полиморфные результаты:
@Controllerpublic 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:
@GraphQlRepositorypublic 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@Transactionalclass 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 и всего, что с ним связано.
ссылка на оригинал статьи https://habr.com/ru/articles/1026130/