Обидві проблеми, з яких починається ця розмова, походять з того, що ресурс і екран мають різну форму. Over-fetching - коли GET /orders?page=1 віддає по двадцять полів на замовлення, включно з адресою доставки та історією статусів, а списку в мобільному потрібні три. Under-fetching зворотний: щоб намалювати картку замовлення, клієнт послідовно тягне замовлення, клієнта, позиції, товари по позиціях і статус оплати, і кожен наступний запит чекає попереднього. Ціна в них теж різна. Зайві поля коштують трафіку й серіалізації, і на LTE це помітно, але один запит лишається одним RTT. Зайві раунд-трипи коштують латентності, яка множиться на пінг, і саме вони частіше ламають відчуття швидкості. Тому починати треба з того, яка з двох проблем у вас реальна: лікуються вони різними засобами, а GraphQL продають як відповідь на обидві одразу.
У REST на обидві є прості інструменти, про які часто забувають. Проти зайвих полів працюють sparse fieldsets: ?fields[order]=id,total,status у стилі JSON:API або хоч би власний ?fields=, який фільтрує вихід ресурсу. Проти раунд-трипів - контрольований ?include=customer,lines з whitelist дозволених зв'язок і eager loading під ним, або compound document, який віддає зв'язані сутності поруч у included. Найдешевший і найчастіше правильний варіант - окремий ендпоінт під екран: GET /dashboard/summary замість семи запитів. Він порушує уявлення, що URL мусить відповідати таблиці, зате має одну перевірку доступу, один план запитів і повністю кешується. Це і є BFF у найпростішій формі, без окремого сервісу.
Кешування - головне, що ви ставите на карту, обираючи стиль. У REST з GET-адресами кеш працює на чотирьох рівнях безкоштовно: браузер, shared-проксі або CDN за Cache-Control: public, s-maxage=..., умовні запити через ETag/If-None-Match з поверненням 304 без тіла (RFC 9110), плюс stale-while-revalidate, коли дані можна віддавати трохи протухлими. У Laravel це middleware cache.headers:public;max_age=120;etag, який сам порівняє ETag і поверне 304; у Symfony - атрибут #[Cache] і вбудований reverse proxy. Щойно API переїжджає на єдиний POST /graphql або на RPC-ендпоінт, усі чотири рівні вимикаються: проксі не знає, що лежить у тілі, і кешувати не має права. Кеш нікуди не зникає, він переїжджає в застосунок і стає вашою роботою: Redis по сутностях, batch-лоадери в межах запиту, persisted queries з GET, щоб повернути кешовані URL. Для приватних відповідей різниця менша, бо Vary: Authorization і так вбиває shared-кеш, а для публічного read-heavy трафіку вона вирішальна.
Контракт у трьох стилях евольвує по-різному, і це впливає на щоденну роботу сильніше, ніж синтаксис. REST живе на additive-змінах: нові поля додаються, видалення й звуження типів чекають нової major-версії. GraphQL має одну схему без версій: поля додаються вільно, а застарілі позначаються @deprecated(reason: ...) і прибираються тоді, коли телеметрія показує нуль звернень до поля, для чого сервер мусить логувати, які поля зачіпала кожна операція. Protobuf прив'язує сумісність до номерів полів: номер не можна перевикористати, видалене поле заносять у reserved, у proto3 усі поля необов'язкові, тому додавання безпечне за конструкцією. Спільне в усіх трьох: видалення поля ламає клієнтів, і нове значення в enum ламає навіть коректно написаного клієнта, якщо той розгалужується по ньому exhaustive-ом. І в усіх трьох перевірку варто робити машинно, diff схеми в CI (graphql-inspector для SDL, oasdiff для OpenAPI, buf breaking для proto), а не оком на ревʼю.
Коли GraphQL зайвий: клієнт один і він ваш, релізиться разом з бекендом, сутностей десяток, трафік публічний і добре кешується, команда маленька. У цій конфігурації ви платите схемою, лімітами складності й глибини, авторизацією, розкиданою по резолверах, втраченим HTTP-кешем і моніторингом, де всі запити називаються POST /graphql, а взамін отримуєте гнучкість, якою нікому користуватися. GraphQL починає окупатися, коли клієнтів кілька і вони різні (iOS, Android, веб, партнерський дашборд), вони релізяться незалежно й версії лишаються на пристроях роками, а кожна нова фіча інакше вимагала б нового ендпоінта. RPC стоїть на іншій осі: JSON-RPC або gRPC беруть для внутрішньої комунікації між сервісами, де потрібні строгий контракт, генерація клієнтів, низька латентність і стрімінг, а браузерна прозорість та кеш нікого не цікавлять. У PHP тут є практичне обмеження: офіційний gRPC дає тільки клієнта, серверна частина живе на RoadRunner. І цілком нормальна відповідь на співбесіді - «у нас REST назовні, gRPC між сервісами, і один GraphQL-ендпоінт під мобільний застосунок»: стилі вибираються під межу, а не під увесь проєкт.
// GET /api/orders/42?include=customer,lines&fields[order]=id,total,status
// Кеш: ETag + 304 на If-None-Match, private - бо відповідь залежить від користувача
Route::get('api/orders/{order}', ShowOrder::class)
->middleware(['auth:sanctum', 'cache.headers:private;max_age=60;etag']);
final class ShowOrder
{
/** Whitelist зв'язок: без нього клієнт сам собі влаштує N+1 через ?include */
private const ALLOWED_INCLUDES = ['customer', 'lines', 'lines.product'];
public function __invoke(Request $request, int $orderId): JsonResponse
{
$includes = array_values(array_intersect(
array_filter(explode(',', (string) $request->query('include', ''))),
self::ALLOWED_INCLUDES,
));
// Під-fetching лікуємо одним запитом з eager loading, а не серією раундтрипів
$order = Order::query()->with($includes)->findOrFail($orderId);
$data = (new OrderResource($order))->resolve($request);
// Sparse fieldset: клієнт бере лише потрібні поля, решта не летить по мережі.
// Роботи базі це не зменшує - економія тільки на payload
$fields = array_filter(explode(',', (string) $request->query('fields.order', '')));
return response()
->json($fields === [] ? $data : Arr::only($data, $fields))
// Last-Modified дає клієнту другий механізм умовних запитів (If-Modified-Since)
->setLastModified($order->updated_at);
}
}
Не відповідайте назвою стилю. Спитайте, хто клієнт і скільки клієнтів, як часто вони релізяться і який трафік переважає. Далі назвіть ціну кожного варіанта одним рядком: REST - зайві поля й раунд-трипи в обмін на HTTP-кеш і читабельні логи; GraphQL - гнучкість в обмін на кеш, per-field авторизацію і ліміти складності; RPC - швидкість і строгий контракт в обмін на непрозорість для браузера й проксі. Окремо скажіть, що для одного власного SPA дешевше зробити ендпоінт під екран, ніж піднімати схему.