Загрузка
Все статьи

GraphQL vs REST в Kotlin-микросервисе — когда имеет смысл переходить

У нас был REST API с 30 endpoint'ами. Фронтенд постоянно просил добавить поля, убрать лишнее, объединить запросы. Перевели один микросервис на GraphQL (graphql-kotlin) и получили: фронтенд запрашивает только нужные данные, один запрос вместо 5-7, меньше нагрузки на сеть. Но появились свои проблемы: N+1 в резолверах, сложность кэширования, versioning.

Наш 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 окупит себя.

Поделиться статьёй

Комментарии

0
0 / 2000

Пока нет комментариев

Будьте первым, кто оставит комментарий к этой статье

Читайте также

Micronaut vs Spring Boot — лёгкий фреймворк для Kotlin-микросервисов

Сравниваю Micronaut и Spring Boot для Kotlin-микросервисов: стартовое время, потребление памяти, холодный старт и реальный опыт миграции одного из сервисов.

12 июня 2026 11 мин

Kotlin: sealed interfaces и when-выражения — паттерн-матчинг как в Rust

Как sealed interfaces из Kotlin 1.5+ вместе с when-выражениями дают мощный паттерн-матчинг, похожий на Rust enum+match, и почему мы перешли на ADT для доменных событий.

11 июня 2026 9 мин

Kotlinx Serialization + Protobuf — быстрая сериализация для микросервисов

Разбираюсь с накладными расходами JSON-сериализации при высоких нагрузках и показываю, как интегрировать Protobuf с kotlinx-serialization в микросервисной архитектуре на Kotlin.

10 июня 2026 10 мин