Наш catalog-service разросся до 30 REST-эндпоинтов. Фронтенд-команда постоянно открывала тикеты: «добавьте поле discountPercentage в ответ GET /products», «уберите fullDescription из списка, нам нужно только в деталях», «мне нужны данные пользователя вместе с продуктом, а сейчас это два запроса».
Каждое изменение — новый endpoint или версия существующего. GET /api/v3/products, потом GET /api/v3/products?fields=id,name,price — мы изобретали GraphQL кривыми руками. Решили: хватит, переводим на нормальный GraphQL с помощью graphql-kotlin от Expedia.
Когда REST начинает болеть
Вот реальные признаки, что вам нужен GraphQL. Если вы узнали свою ситуацию хотя бы по двум пунктам — стоит задуматься:
Over-fetching: фронтенд получает 40 полей, а использует 8. Остальное — лишний трафик и парсинг. У нас JSON-ответ продукта весил 12KB, а фронтенду нужно было 3KB.
Under-fetching: для отрисовки одной страницы фронтенд делает 5–7 запросов. Страница товара — запрос продукта, запрос отзывов, запрос рекомендаций, запрос наличия, запрос цен.
BFF-слой раздувается: вы создаёте Backend-For-Frontend, который аггрегирует REST-вызовы, и он становится таким же сложным, как бэкенд.
Versioning-кошмар: v1, v2, v3 — и все живы, потому что мобильное приложение на v1 до сих пор в проде.
Старт с graphql-kotlin
graphql-kotlin использует подход schema-first с кодогенерацией. Мы выбрали schema-first — он даёт больше контроля и понятен фронтенд-разработчикам.
// build.gradle.kts
dependencies {
implementation("com.expediagroup:graphql-kotlin-server:7.1.1")
implementation("com.expediagroup:graphql-kotlin-spring-server:7.1.1")
}Определяем схему:
type Query {
product(id: ID!): Product
products(filter: ProductFilter, pagination: Pagination): ProductConnection!
user(id: ID!): User
}
type Product {
id: ID!
name: String!
price: Float!
description: String
discountPercentage: Float
reviews: [Review!]!
inventory: Inventory!
category: Category!
}
type Review {
id: ID!
author: User!
rating: Int!
text: String!
createdAt: String!
}
input ProductFilter {
categoryId: ID
minPrice: Float
maxPrice: Float
inStock: Boolean
}Теперь пишем резолверы:
@Component
class ProductQuery(
private val productRepository: ProductRepository
) : Query {
@GraphQLDescription("Получить товар по ID")
fun product(@GraphQLId id: String): Product? {
return productRepository.findById(id)
}
fun products(
filter: ProductFilter?,
pagination: Pagination?
): Connection {
val result = productRepository.findAll(
categoryId = filter?.categoryId,
minPrice = filter?.minPrice,
maxPrice = filter?.maxPrice,
inStock = filter?.inStock,
limit = pagination?.first ?: 20,
cursor = pagination?.after
)
return result.toConnection()
}
}
@Component
class ProductResolver(
private val reviewRepository: ReviewRepository,
private val inventoryClient: InventoryClient,
private val dataLoader: DataLoaderRegistry
) {
// Каждое поле — отдельный резолвер
fun reviews(product: Product): List {
return dataLoader.getDataLoader(ReviewDataLoader::class)
.loadMany(listOf(product.id))
.get()
}
fun inventory(product: Product): Inventory {
return inventoryClient.getInventory(product.id)
}
fun discountPercentage(product: Product): Float? {
return productRepository.findDiscount(product.id)
}
} Проблема N+1 и DataLoader
Первое, что мы получили после перехода — N+1 проблема. Запросили 20 продуктов с отзывами, и GraphQL резолвил отзывы для каждого продукта отдельным SQL-запросом. 20 продуктов = 20 запросов в БД для отзывов. Плюс 1 запрос для продуктов.
Решение — DataLoader. Он батчит запросы и выполняет их одним SQL:
@Component
class ReviewDataLoader(
private val reviewRepository: ReviewRepository
) : KotlinDataLoader> {
override val dataLoaderName = "ReviewDataLoader"
override fun getDataLoader(): DataLoader> {
return DataLoaderFactory.newDataLoader { keys ->
CompletableFuture.supplyAsync {
val reviews = reviewRepository.findByProductIds(keys)
keys.map { productId ->
reviews.filter { it.productId == productId }
}
}
}
}
}
// SQL-запрос вместо N штук:
// SELECT * FROM reviews WHERE product_id IN (?, ?, ?, ...) DataLoader автоматически батчит все вызовы load() в рамках одного запроса и выполняет один запрос. После внедрения количество SQL-запросов упало с 21 до 2.
Проблемы, о которых никто не предупреждает
Кэширование. С REST всё просто: Cache-Control: max-age=300 и браузер/CDN делает работу. С GraphQL у вас один endpoint, и кэшировать весь ответ бессмысленно — каждый клиент может запросить разные поля. Мы используем кэширование на уровне DataLoader и нормализацию кэша на фронтенде через Apollo Client.
Versioning. В GraphQL нет версий в классическом смысле. Вы добавляете новые поля без breaking changes, а старые депрекейтите через директиву @deprecated(reason: "..."). Звучит отлично, но на практике фронтенд может зависеть от поля, которое вы хотите удалить. Поэтому нужно строгое управление lifecycle полей.
Security. Каждый запрос может быть любым — включая глубоко вложенные и рекурсивные. Без ограничения глубины можно положить сервис одним запросом. Мы ограничили глубину до 6 уровней и добавили лимит на количество резолвируемых полей — максимум 200.
Итог: когда переходить
Мы довольны переходом. Один микросервис перевели за 3 недели. Фронтенд сократил количество запросов со страницы товара с 7 до 1. Трафик снизился на 40% для этого сервиса. Но мы не переводим всё на GraphQL — REST отлично подходит для простых CRUD-сервисов и внутренних API. Если у вас 5 эндпоинтов и фронтенд не жалуется — не трогайте. Если 20+ эндпоинтов, over/under-fetching, BFF раздувается — GraphQL окупит себя.
Пока нет комментариев
Будьте первым, кто оставит комментарий к этой статье