Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

Питання на співбесіді: GraphQL, gRPC і вебхуки

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

14 питань

Три поширені стилі API розв'язують різні задачі.

REST - ресурси за URL і стандартні методи HTTP:

GET /api/orders/42
GET /api/orders/42/items
  • сильні сторони: простота, HTTP-кешування (CDN, ETag), зрозумілі коди відповіді, будь-який клієнт (навіть curl);
  • слабкі: клієнт отримує фіксовану форму відповіді - або зайві поля (overfetching), або кілька запитів, щоб зібрати екран (underfetching).

GraphQL - один ендпойнт, клієнт сам описує, які поля потрібні:

query {
  order(id: 42) { number total items { name qty } customer { name } }
}
  • сильні сторони: один запит на екран, сувора схема з типами, зручно для кількох клієнтів з різними потребами (веб, мобільний застосунок);
  • слабкі: складніше кешування (зазвичай POST на один URL), ризик дорогих запитів, N+1 на сервері, складніші авторизація й обмеження частоти.

gRPC - виклик віддалених процедур поверх HTTP/2 з бінарним форматом Protocol Buffers і згенерованими клієнтами:

  • сильні сторони: швидкість і компактність, суворий контракт у .proto, двобічний стримінг;
  • слабкі: браузер не викликає gRPC напряму (потрібен gRPC-Web чи шлюз), бінарні повідомлення важче налагоджувати.

Як обирати:

Ситуація Стиль
публічне API, інтеграції партнерів, вебхуки REST
багато клієнтів з різними екранами, складні зв'язані дані GraphQL
внутрішні сервіси між собою, високе навантаження, стримінг gRPC
прості дії на кшталт «надіслати лист» RPC поверх HTTP (POST /api/send-invoice)

Практичне зауваження: для типового Laravel-застосунку з одним фронтендом REST (чи Inertia без окремого API) майже завжди простіший. GraphQL і gRPC окупаються, коли їхні сильні сторони справді потрібні, - а не «бо модно».

Докладніше в документації: Вступ до GraphQL

Схема - контракт API: які типи є, які поля в них і що можна запитати. Пишеться мовою SDL:

type User {
  id: ID!
  name: String!
  email: String
  posts: [Post!]!
}

type Post {
  id: ID!
  title: String!
  author: User!
}

type Query {
  user(id: ID!): User
  posts(first: Int = 10): [Post!]!
}

type Mutation {
  createPost(title: String!, body: String!): Post!
}

! - поле не може бути null. [Post!]! - список, який сам не null і не містить null.

Запит (query) - читання. Клієнт вибирає лише потрібні поля, включно з вкладеними:

query {
  user(id: 7) {
    name
    posts { title }
  }
}

Відповідь повторює форму запиту: { "data": { "user": { "name": "Оля", "posts": [...] } } }.

Мутація (mutation) - зміна даних. Синтаксис як у запиту, але виконується послідовно, а результат - змінений об'єкт:

mutation {
  createPost(title: "Привіт", body: "...") { id title }
}

Підписка (subscription) - потік подій у реальному часі, зазвичай через WebSocket.

Резолвер - функція на сервері, що повертає значення поля. Для кожного поля в запиті сервер викликає його резолвер. Простим полям (name) достатньо значення з батьківського об'єкта, а для зв'язків (posts) резолвер іде в базу.

Відмінності від REST, які варто пам'ятати:

  • одна адреса (/graphql) і зазвичай метод POST;
  • помилки не через коди HTTP: відповідь часто має статус 200, а помилки лежать у масиві errors поряд із частковими даними в data;
  • інтроспекція: схему можна запитати через сам API - на цьому побудовані автодоповнення в GraphiQL і генератори типів для клієнтів.

У Laravel найпоширеніший пакет - Lighthouse: схема описується в .graphql-файлі, а директиви (@all, @find, @paginate, @create) прив'язують поля до моделей Eloquent без ручних резолверів.

Докладніше в документації: Схеми й типи GraphQL

Вебхук - HTTP-запит, який ваш сервіс надсилає на URL клієнта, коли щось сталося: замовлення оплачене, вакансію опубліковано. Клієнт не опитує API, а отримує подію сам.

Що надсилати:

{
  "id": "evt_01J9Z3K8",
  "type": "order.paid",
  "created_at": "2026-10-04T10:15:00Z",
  "api_version": "2026-09-01",
  "data": {
    "object": { "id": 42, "status": "paid", "total": "1250.00", "currency": "UAH" }
  }
}
  • id події - унікальний: одержувач за ним відкидає дублікати;
  • type - назва події у форматі ресурс.дія; клієнт підписується лише на потрібні;
  • час події - щоб одержувач міг розібратися з порядком;
  • версія формату - щоб змінювати структуру, не ламаючи наявних інтеграцій;
  • дані: або повний знімок об'єкта («товстий» вебхук), або лише ідентифікатор («тонкий», одержувач сам запитує актуальний стан через API).

Обов'язкові складники надійного вебхука:

  • підпис запиту (HMAC з секретом одержувача) і мітка часу - щоб одержувач перевірив, що запит від вас і не повторений;
  • HTTPS для URL одержувача;
  • доставка «щонайменше один раз»: при помилці чи тайм-ауті - повторні спроби з наростаючою затримкою. Одержувач має бути готовим до дублікатів;
  • швидка відповідь: одержувач підтверджує прийом кодом 2xx одразу, а обробляє асинхронно. Документуйте тайм-аут (кілька секунд);
  • журнал доставок у кабінеті: які події пішли, з яким кодом відповіді, кнопка «надіслати ще раз».

Відправлення в Laravel - лише через чергу. HTTP-запит до чужого сервера в обробнику запиту користувача - затримки й падіння, якщо одержувач повільний чи недоступний. Готове рішення - пакет spatie/laravel-webhook-server: черга, підпис, повтори з затримкою, події про невдалі доставки.

Документація для одержувачів: перелік типів подій із прикладами, як перевіряти підпис, політика повторів, тестові події з кабінету.

Докладніше в документації: Вебхуки Stripe

REST працює за схемою «запит - відповідь»: клієнт питає, сервер відповідає. Коли клієнту потрібно дізнаватися про зміни одразу, без постійного опитування, використовують постійне з'єднання.

Варіанти:

  • опитування (polling) - запит раз на N секунд. Найпростіше, але створює багато порожніх запитів і затримку до N секунд;
  • Server-Sent Events (SSE) - однобічний потік від сервера до клієнта через звичайне HTTP-з'єднання. Автоматичне перепідключення вбудоване в браузер. Добре для сповіщень, прогресу, стрічок, відповідей LLM;
  • WebSocket - двобічний канал. Потрібен, коли й клієнт часто надсилає дані: чат, спільне редагування, присутність користувачів онлайн.

Laravel Reverb - власний WebSocket-сервер Laravel, сумісний із протоколом Pusher. Застосунок транслює події, а клієнти підписуються через Laravel Echo:

php artisan install:broadcasting --reverb
php artisan reverb:start
class OrderShipped implements ShouldBroadcast
{
    public function __construct(public Order $order) {}

    public function broadcastOn(): array
    {
        return [new PrivateChannel("orders.{$this->order->user_id}")];
    }
}
Echo.private(`orders.${userId}`).listen('OrderShipped', (event) => {
  updateStatus(event.order);
});

Приватні канали авторизуються в routes/channels.php - Reverb пускає лише тих, кому дозволено слухати канал.

Що враховувати в продакшені:

  • Reverb - окремий довгоживучий процес (Supervisor, контейнер), за зворотним проксі з підтримкою WebSocket;
  • ліміт відкритих файлів ОС - кожне з'єднання займає дескриптор;
  • горизонтальне масштабування - кілька серверів Reverb обмінюються повідомленнями через Redis (REVERB_SCALING_ENABLED);
  • події через чергу: трансляція з ShouldBroadcast іде через чергу, тож без воркера повідомлення не надходять.

SSE в Laravel - response()->eventStream() у звичайному маршруті: без окремого сервера, але кожен відкритий потік тримає процес PHP.

Докладніше в документації: Laravel Reverb

API-шлюз - окремий сервіс-«вхідні двері» перед вашими API. Клієнти звертаються лише до нього, а він маршрутизує запити до внутрішніх сервісів.

клієнти → API-шлюз → сервіс замовлень
                   → сервіс користувачів
                   → сервіс платежів

Що зазвичай робить шлюз:

  • маршрутизація: /api/orders/* - до одного сервісу, /api/users/* - до іншого;
  • автентифікація: перевірка токенів чи API-ключів один раз на вході, далі - довірений заголовок з ідентифікатором користувача;
  • обмеження частоти і квоти для клієнтів і тарифів;
  • TLS-термінація, CORS, стиснення;
  • кешування відповідей;
  • трансформація: перетворення форматів, об'єднання відповідей кількох сервісів в одну;
  • спостережуваність: централізовані журнали, метрики, трасування запитів;
  • версіонування і поступове перемикання трафіку між версіями сервісу.

Приклади: Kong, Tyk, AWS API Gateway, Azure API Management, Cloudflare (частково), Traefik і Nginx як простіші варіанти.

Коли шлюз доречний:

  • кілька сервісів, які мають виглядати як одне API;
  • публічне API з ключами, тарифами, квотами й порталом для розробників;
  • потрібно централізовано застосовувати політики, а не дублювати їх у кожному сервісі.

Коли він зайвий: один моноліт на Laravel. Маршрутизація, автентифікація (Sanctum, Passport), обмеження частоти (throttle) уже є у фреймворку, а зовнішній шлюз лише додасть ще одну ланку, яка може впасти.

Ризики шлюзу:

  • єдина точка відмови і вузьке місце - потребує резервування;
  • «розумний шлюз»: бізнес-логіка, що поступово переїжджає в конфігурацію шлюзу, стає некерованою. Шлюз має займатися наскрізними задачами, а не правилами предметної області;
  • додаткова затримка на кожен запит.

Споріднений патерн - BFF (backend for frontend): окремий шлюз під кожен тип клієнта, що збирає дані саме під його екрани.

Докладніше в документації: Патерн API Gateway

GraphQL викликає резолвер для кожного поля кожного об'єкта. Запит

{
  posts(first: 50) {
    title
    author { name }
  }
}

виконає один запит за постами, а потім резолвер author - 50 разів, по одному на пост. Класичне N+1, тільки його не видно в коді: кожен резолвер окремо виглядає невинно.

DataLoader - патерн (і бібліотека з такою назвою від авторів GraphQL), що збирає звернення в пакет:

  1. резолвери не запитують базу одразу, а кажуть завантажувачу «мені потрібен автор 7», «мені потрібен автор 12»;
  2. завантажувач накопичує ключі протягом поточного «такту» виконання;
  3. потім робить один запит за всіма ключами (WHERE id IN (7, 12, ...)) і роздає результати резолверам;
  4. в межах запиту кешує вже завантажені значення - той самий автор не завантажиться двічі.

Головна вимога до функції пакетного завантаження: повернути результати в тому самому порядку, що й ключі, і з відповідною кількістю елементів (null для відсутніх).

У Laravel з Lighthouse це зроблено за вас для зв'язків:

type Post {
  title: String!
  author: User! @belongsTo
  comments: [Comment!]! @hasMany
}

Директиви зв'язків Lighthouse пакетують запити до бази - зв'язок для 50 постів завантажиться одним запитом. Для даних, що не є зв'язками Eloquent (зовнішній сервіс, обчислення), Lighthouse дає змогу писати власні пакетні завантажувачі.

Типові пастки:

  • власний резолвер з ->find() усередині поля обходить пакетування й повертає N+1;
  • вкладені списки з пагінацією в кожному елементі (posts { comments(first: 5) }) складні для пакетування - «перші 5 коментарів кожного поста» не виражається простим IN, потрібні віконні функції чи окремі стратегії;
  • кеш завантажувача - на запит, не глобальний: інакше користувачі побачать чужі дані.

Як помітити: лічильник SQL-запитів на один GraphQL-запит (Telescope, Debugbar, Pulse) - тест, що перевіряє: кількість запитів не росте разом із кількістю елементів у списку.

Докладніше в документації: DataLoader

Lighthouse - пакет, у якому GraphQL API описується схемою-першою (schema-first): ви пишете файл graphql/schema.graphql, а директиви пов'язують поля з моделями Eloquent.

composer require nuwave/lighthouse
php artisan vendor:publish --tag=lighthouse-schema
type Query {
  posts: [Post!]! @paginate(defaultCount: 20) @orderBy(column: "created_at", direction: DESC)
  post(id: ID! @eq): Post @find
  me: User @auth
}

type Mutation {
  createPost(input: CreatePostInput! @spread): Post!
    @guard
    @canModel(ability: "create")
    @create
}

input CreatePostInput {
  title: String! @rules(apply: ["required", "max:255"])
  body: String!
}

type Post {
  id: ID!
  title: String!
  author: User! @belongsTo
}

Що тут відбувається без жодного PHP-коду:

  • @paginate - пагінація з типами для сторінок;
  • @find, @all - вибірка моделей; @eq - умова where;
  • @belongsTo, @hasMany - зв'язки з пакетним завантаженням (без N+1);
  • @guard - автентифікація (Sanctum чи інший guard), @canModel (і решта сімейства @can*: @canFind, @canQuery...) - політики Laravel. Стара універсальна @can у Lighthouse 6 позначена застарілою;
  • @rules - звичайні правила валідації Laravel;
  • @create, @update, @delete - мутації над моделями.

Власна логіка - резолвер-клас:

final class PublishPost
{
    public function __invoke(null $_, array $args): Post
    {
        $post = Post::findOrFail($args['id']);
        Gate::authorize('publish', $post);
        $post->publish();

        return $post;
    }
}
publishPost(id: ID!): Post! @field(resolver: "App\\GraphQL\\Mutations\\PublishPost")

Що налаштувати одразу:

  • безпеку в config/lighthouse.php: max_query_depth, max_query_complexity, вимкнення інтроспекції в продакшені - за замовчуванням обмеження вимкнені;
  • кешування схеми в продакшені (php artisan lighthouse:cache);
  • авторизацію на кожному полі, що віддає чутливі дані, а не лише на запитах верхнього рівня;
  • тести: трейт MakesGraphQLRequests з методом graphQL() і перевіркою відповіді.

Чим відрізняється від REST-контролерів: схема - єдине джерело правди і документація одночасно; клієнти (Apollo, urql) генерують з неї типи TypeScript.

Докладніше в документації: Lighthouse: встановлення

Одержувач вебхука може бути недоступним, повільним чи повертати помилку - доставка мусить це пережити.

Архітектура відправлення:

  1. подія фіксується в базі в тій самій транзакції, що й зміна даних (запис «вебхук до відправки»). Інакше при падінні після коміту подія загубиться - патерн outbox;
  2. джоба в черзі надсилає запит;
  3. результат кожної спроби записується в журнал доставок.
final class DeliverWebhook implements ShouldQueue
{
    public $tries = 8;

    public function __construct(public WebhookDelivery $delivery) {}

    public function backoff(): array
    {
        return [10, 60, 300, 1800, 3600, 7200, 21600];   // секунди: до кількох годин
    }

    public function handle(): void
    {
        $response = Http::timeout(5)
            ->withHeaders($this->delivery->signatureHeaders())
            ->post($this->delivery->endpoint->url, $this->delivery->payload);

        $this->delivery->recordAttempt($response->status());

        if ($response->failed()) {
            throw new WebhookDeliveryFailed($response->status());
        }
    }
}

Правила повторів:

  • наростаюча затримка (експоненційна, з випадковим розкидом) - щоб не «добити» сервер одержувача, який відновлюється, і щоб тисячі вебхуків не повторювалися синхронно;
  • повторювати мережеві помилки, тайм-аути, 5xx і 429 (з урахуванням Retry-After); 4xx на кшталт 400, 404, 410 - зазвичай ні: повтор не допоможе;
  • загальний горизонт - години чи дні (Stripe повторює до трьох днів), після чого доставка позначається невдалою.

Журнал доставок у кабінеті клієнта: подія, URL, час, код відповіді, фрагмент тіла відповіді, кількість спроб, кнопка «надіслати повторно». Найкорисніша функція для підтримки - більшість звернень «вебхук не прийшов» вирішуються переглядом журналу.

Захист від «мертвих» одержувачів: якщо ендпойнт падає днями, його автоматично вимикають і повідомляють власника листом - інакше черга забивається безнадійними спробами.

Ізоляція: вебхуки - в окремій черзі з власними воркерами, щоб повільні одержувачі не затримували решту фонових задач застосунку.

Готові інструменти: spatie/laravel-webhook-server (черга, підпис, повтори, події WebhookCallFailedEvent), або зовнішні сервіси доставки вебхуків, якщо обсяги великі.

Важливо для одержувачів: доставка «щонайменше один раз» означає дублікати - документуйте, що обробка має бути ідемпотентною за id події.

Докладніше в документації: Stripe: автоматичні повтори вебхуків

Backend for Frontend - окремий серверний шар під конкретний клієнт: веб-застосунок, мобільний застосунок, адмінка. Кожен BFF збирає дані з внутрішніх сервісів у формі, зручній саме своєму інтерфейсу.

веб-застосунок    → BFF для вебу     ↘
мобільний застосунок → BFF для мобільних → сервіси замовлень, користувачів, каталогу
партнерське API   → публічне API     ↗

Яку проблему розв'язує. Одне загальне API для всіх клієнтів поступово обростає компромісами:

  • мобільному застосунку на повільній мережі потрібні компактні відповіді, а вебу - більше даних за раз;
  • екран збирається з п'яти запитів до різних сервісів - на мобільній мережі це повільно;
  • зміна, потрібна одному клієнту, ламає іншого;
  • команда фронтенду чекає на команду API заради кожного нового поля.

Що робить BFF:

  • агрегує: один запит від клієнта - кілька паралельних запитів до сервісів - одна відповідь під екран;
  • адаптує формат: лише потрібні поля, зручна структура, локалізовані підписи;
  • тримає автентифікацію браузера: BFF працює з сесійними cookie (HttpOnly), а токени до внутрішніх сервісів не потрапляють у браузер. Це рекомендований підхід для SPA з OAuth;
  • належить команді фронтенду - вона змінює його у власному темпі.

Ви, можливо, вже маєте BFF:

  • Next.js з серверними компонентами й Route Handlers - фактично BFF для React-застосунку;
  • Laravel з Inertia - контролери готують props саме для сторінок Vue/React;
  • GraphQL частково вирішує ту саму проблему іншим способом - клієнт сам вибирає поля.

Коли BFF виправданий:

  • кілька клієнтів з суттєво різними потребами;
  • мікросервісна архітектура, де клієнтові інакше довелося б знати про багато сервісів;
  • окремі команди фронтенду, яким потрібна незалежність.

Коли зайвий: один клієнт і моноліт - сам моноліт уже є «бекендом для фронтенду».

Ризики: дублювання логіки між кількома BFF (спільне виносять у сервіси), і бізнес-правила, що непомітно переїжджають у BFF. BFF має лише збирати й адаптувати дані, а не вирішувати, наприклад, чи можна оформити замовлення.

Докладніше в документації: Backends For Frontends

gRPC - фреймворк віддалених викликів процедур: клієнт викликає метод на сервері так, ніби це локальна функція. Контракт описується у файлі .proto мовою Protocol Buffers:

syntax = "proto3";

package billing.v1;

service InvoiceService {
  rpc GetInvoice (GetInvoiceRequest) returns (Invoice);
  rpc StreamPayments (StreamPaymentsRequest) returns (stream Payment);
}

message GetInvoiceRequest {
  int64 id = 1;
}

message Invoice {
  int64 id = 1;
  string number = 2;
  int64 total_cents = 3;
  repeated LineItem items = 4;
}

З .proto генеруються клієнти й серверні заготовки для десятків мов - контракт однаковий для Go, Java, PHP, Node.js.

Чому його обирають для внутрішнього зв'язку сервісів:

  • компактність і швидкість: Protocol Buffers - бінарний формат, повідомлення в рази менші за JSON і швидше розбираються. Імена полів не передаються - лише номери;
  • HTTP/2: одне з'єднання для багатьох паралельних викликів, стиснення заголовків;
  • стримінг: від сервера, від клієнта і двобічний - потоки подій, великі вивантаження без пагінації;
  • суворий контракт: типи перевіряються при генерації коду, а не в рантаймі;
  • дедлайни й скасування вбудовані в протокол: клієнт задає, скільки готовий чекати, і скасування поширюється ланцюжком викликів.

Обмеження:

  • браузер не говорить gRPC напряму - потрібен gRPC-Web з проксі (Envoy) чи шлюз, що перетворює на REST/JSON;
  • налагодження: бінарні повідомлення не прочитаєш у DevTools - потрібні grpcurl, Postman з підтримкою gRPC;
  • PHP-FPM погано підходить для gRPC-сервера (довгі з'єднання, HTTP/2) - сервери пишуть на Go, Java, Node.js, а з PHP частіше виступають клієнтом (розширення grpc) або використовують RoadRunner;
  • інфраструктура: балансувальники мають розуміти HTTP/2 і довгоживучі з'єднання.

Відповіді й помилки: у gRPC власні коди стану (NOT_FOUND, UNAVAILABLE, DEADLINE_EXCEEDED, PERMISSION_DENIED) замість HTTP-кодів.

Типова картина: публічне API - REST/JSON, внутрішні виклики між сервісами - gRPC, асинхронні події - черги чи брокери повідомлень.

Докладніше в документації: Вступ до gRPC

GraphQL дає клієнту змогу самому будувати запит - а отже й побудувати дуже дорогий. Один HTTP-запит може змусити сервер виконати мільйони операцій.

Типові атаки й проблеми:

# глибина: циклічні зв'язки дають експоненційне зростання
{ user(id: 1) { friends { friends { friends { friends { name } } } } } }

# ширина: величезні списки
{ posts(first: 100000) { comments(first: 1000) { author { name } } } }

# псевдоніми: одне поле, викликане тисячу разів в одному запиті
{ a1: login(email: "...", password: "1") a2: login(email: "...", password: "2") ... }

Останній приклад обходить обмеження частоти на рівні HTTP: один запит - тисяча спроб входу.

Захист - кілька рівнів:

  • обмеження глибини запиту (наприклад, 8-10 рівнів);
  • аналіз складності: кожному полю призначається «вартість» (списки - помножена на first), запит понад ліміт відхиляється до виконання;
  • обов'язкова пагінація з максимумом - жодних списків без first чи з first: 100000;
  • обмеження частоти за складністю, а не за кількістю HTTP-запитів: бюджет «очок» на клієнта за хвилину (так працює GitHub GraphQL API);
  • обмеження псевдонімів і пакетних запитів (batching кількох операцій в одному HTTP-запиті);
  • тайм-аути виконання запиту.

Інтроспекція в продакшені. Вона показує всю схему, включно з внутрішніми полями й мутаціями. Для публічних API зі схемою як документацією це нормально; для API лише свого фронтенду - вимкнути.

Збережені (persisted) запити / довірені документи: клієнт надсилає не текст запиту, а його хеш з переліку, відомого серверу під час збирання. Сервер виконує лише заздалегідь відомі запити - довільні атаки неможливі взагалі. Найсильніший захист для API, яке використовує лише ваш фронтенд, плюс бонус - запити можна робити через GET і кешувати на CDN.

Авторизація на рівні полів. Перевірка лише на верхньому запиті недостатня: доступ до order не означає доступу до order.customer.paymentMethods. Кожен резолвер чутливих даних має перевіряти права.

Повідомлення про помилки: у продакшені не віддавати стек і внутрішні деталі в errors - це витік інформації про реалізацію.

У Lighthouse обмеження задаються в config/lighthouse.php (max_query_depth, max_query_complexity, disable_introspection), а вартість полів - директивою @complexity. За замовчуванням ліміти вимкнені - їх треба ввімкнути свідомо.

Докладніше в документації: Безпека GraphQL

Чому REST кешується легко: кожен ресурс має свою адресу, читання - через GET, і вся інфраструктура HTTP (браузер, CDN, проксі) розуміє Cache-Control і ETag без жодної участі застосунку.

Чому з GraphQL складніше:

  • одна адреса /graphql для всього;
  • запити через POST - HTTP-кеші їх не кешують;
  • кожен клієнт формує свій запит - навіть однакові дані запитуються різними наборами полів, і ключ кешу «URL» не працює;
  • одна відповідь змішує дані з різним терміном актуальності (назва товару - години, залишок на складі - секунди).

Рішення на різних рівнях:

1. Нормалізований кеш на клієнті (Apollo Client, urql з Graphcache, Relay). Кеш зберігає об'єкти за __typename + id, а не відповіді цілком. Мутація, що повертає змінений об'єкт, автоматично оновлює його в усіх екранах. Тому корисно мати глобально унікальні ідентифікатори і завжди запитувати id.

2. Збережені запити + GET. Клієнт надсилає хеш заздалегідь відомого запиту і змінні в рядку запиту:

GET /graphql?extensions={"persistedQuery":{"sha256Hash":"ab12..."}}&variables={"id":42}

Тепер відповідь має стабільну адресу, і її можна кешувати на CDN - як REST.

3. Підказки кешування в схемі. Сервер обчислює Cache-Control для відповіді з найкоротшого терміну серед полів (директиви на кшталт @cacheControl(maxAge: 60)), а персональні поля позначає приватними. Відповідь з даними користувача не повинна потрапити в спільний кеш.

4. Кеш на сервері:

  • на рівні резолверів чи DataLoader - кешування окремих сутностей у Redis;
  • кеш цілих відповідей за нормалізованим текстом запиту + змінними + користувачем - простий, але інвалідація складна.

Інвалідація - найважче місце. Зміна одного товару торкається безлічі різних запитів, що його містять. Тому популярний підхід - теги: відповідь позначається тегами сутностей (Product:42), і зміна сутності скидає всі відповіді з цим тегом (так працюють CDN з purge за ключами).

Практичний висновок: якщо важливий кеш на CDN для публічних даних (каталог, статті), - або REST для цих частин, або збережені запити через GET. Для персональних даних основну роботу робить нормалізований кеш клієнта.

Докладніше в документації: Кешування в GraphQL

У Protocol Buffers у повідомленні передаються не назви полів, а їхні номери. Звідси головні правила еволюції схеми: важливо не те, як поле називається, а який у нього номер і тип.

Безпечні зміни:

  • додати нове поле з новим номером. Старі клієнти його проігнорують, нові - отримають значення за замовчуванням, якщо старий сервер його не надіслав;
  • перейменувати поле - номер той самий, на «дроті» нічого не змінилося (але згенерований код зміниться - це зміна API для коду, не для формату);
  • видалити поле, якщо його номер і назву зарезервувати.

Небезпечні зміни:

  • змінити номер поля - для формату це видалення старого поля і поява нового;
  • повторно використати номер видаленого поля з іншим змістом - старі клієнти розберуть нові дані як старе поле, з тихим пошкодженням даних;
  • змінити тип на несумісний (string → int64). Деякі пари сумісні на рівні формату (int32/int64/bool), але з можливим обрізанням значень - покладатися на це не варто;
  • перетворити одиничне поле на repeated чи навпаки - з неочевидними наслідками для різних мов.

Резервування видалених полів:

message Invoice {
  reserved 3, 7;
  reserved "discount", "legacy_status";

  int64 id = 1;
  string number = 2;
  int64 total_cents = 4;
}

Компілятор не дасть використати зарезервовані номери й назви повторно.

Інші практики:

  • значення за замовчуванням у proto3 - 0, "", false неможливо відрізнити від «не задано». Якщо різниця важлива - optional (явна присутність) чи обгорткові типи;
  • перелічення: перше значення має бути 0 і означати «невідомо» (STATUS_UNSPECIFIED = 0), бо саме його отримає старий клієнт для нових значень;
  • версія в назві пакета (billing.v1, billing.v2) - для справді несумісних змін створюється новий пакет і обидва обслуговуються паралельно;
  • автоматична перевірка в CI: інструмент buf breaking порівнює схему з попередньою версією й знаходить ламаючі зміни до злиття.

Порядок розгортання: спершу оновлюються ті, хто читає нове поле (сервери, що його приймають), потім ті, хто починає його надсилати. Видалення - у зворотному порядку: спершу всі припиняють використовувати поле, потім воно резервується.

Докладніше в документації: Protocol Buffers: оновлення типу повідомлення

Подієві API - вебхуки, повідомлення в брокері (Kafka, RabbitMQ), канали WebSocket - мають ті самі проблеми, що й REST: контракт, документація, сумісність. Але є й власні - порядок і дублікати.

Документація - AsyncAPI. Те, чим OpenAPI є для REST, AsyncAPI є для подій: специфікація каналів, повідомлень і їхніх схем:

asyncapi: 3.0.0
info:
  title: Orders events
  version: 1.4.0
channels:
  orderPaid:
    address: orders.paid
    messages:
      orderPaid:
        payload:
          type: object
          required: [id, orderId, paidAt]
          properties:
            id: { type: string }
            orderId: { type: integer }
            paidAt: { type: string, format: date-time }

З неї генеруються документація, типи для споживачів і перевірки повідомлень.

Версіонування подій:

  • додавання полів - безпечне, якщо споживачі ігнорують невідомі поля (це треба вимагати в документації);
  • ламаючі зміни - нова назва чи версія події (order.paid.v2) і паралельна публікація обох версій на перехідний період;
  • версія у вебхуках - часто прив'язується до облікового запису одержувача (як api_version у Stripe): одержувач сам обирає, коли перейти на новий формат.

Порядок подій не гарантований. Повтори, паралельні воркери й мережа змішують порядок: order.shipped може прийти раніше за order.paid. Стратегії для одержувача:

  • мітка часу чи номер версії об'єкта в події - застосовувати лише якщо подія новіша за вже відомий стан;
  • «тонкі» події: подія лише повідомляє «замовлення 42 змінилося», а одержувач запитує актуальний стан через API - порядок перестає мати значення;
  • впорядкування за ключем у брокері (партиції Kafka за order_id) - порядок гарантується в межах однієї сутності, а не глобально.

Дублікати - наслідок доставки «щонайменше один раз»: одержувач зберігає оброблені id подій і пропускає повтори.

Документуйте явно: гарантії доставки (щонайменше один раз), відсутність гарантії порядку, тайм-аути й політику повторів, як перевіряти підпис. Більшість помилок інтеграцій - від неявних припущень одержувача про порядок і унікальність.

Докладніше в документації: AsyncAPI: основні поняття