GraphQL #
GraphQL sering menjadi pilihan yang terlalu cepat diambil atau terlalu cepat ditolak. Sebagian tim mengadopsinya karena terdengar modern, tanpa ada kebutuhan nyata yang membenarkan kompleksitas tambahannya. Sebagian lain menolaknya karena “REST sudah cukup”, padahal ada masalah nyata yang GraphQL selesaikan dengan jauh lebih elegan. Untuk memutuskan dengan tepat, kamu perlu memahami dari mana GraphQL berasal, masalah apa yang ia dirancang untuk dipecahkan, bagaimana cara kerjanya, dan apa biaya yang datang bersamanya. Artikel ini membahas semua itu — dari schema design sampai DataLoader, dari query complexity limiting sampai kapan justru kamu harus tetap pakai REST.
Mengapa GraphQL Ada #
Facebook membangun GraphQL pada 2012 untuk mengatasi masalah nyata di aplikasi mobile mereka. News feed Facebook membutuhkan data dari puluhan sumber berbeda — post, foto, video, komentar, reaksi, informasi teman — dan setiap komponen UI membutuhkan subset data yang berbeda.
Dengan REST, ada dua pilihan yang sama buruknya:
Pilihan 1: Satu endpoint per kebutuhan UI
GET /newsfeed/posts
GET /newsfeed/photos
GET /newsfeed/friends-activity
→ Banyak request, banyak round trip, lambat di mobile
→ Setiap perubahan UI butuh endpoint baru dari backend
Pilihan 2: Satu endpoint besar yang mengembalikan semua data
GET /newsfeed
→ Over-fetching: mobile menerima data yang tidak semua dipakai
→ Boros bandwidth di jaringan mobile yang terbatas
→ Response lambat
GraphQL adalah solusi untuk dilema ini: satu endpoint, tapi client yang menentukan persis data apa yang dibutuhkan. Facebook menggunakannya secara internal selama tiga tahun sebelum akhirnya open-source di 2015.
flowchart LR
subgraph REST["REST — Server menentukan data"]
RC["Client"]
RE1["GET /users/1\n→ seluruh profil user"]
RE2["GET /users/1/posts\n→ seluruh posts"]
RE3["GET /users/1/followers\n→ seluruh followers"]
RC -->|requests| RE1
RC -->|requests| RE2
RC -->|requests| RE3
end
subgraph GQL["GraphQL — Client menentukan data"]
GC["Client"]
GE["POST /graphql\n→ hanya field yang diminta"]
GC -->|request| GE
endCara Kerja GraphQL #
Schema — Kontrak yang Mendefinisikan Segalanya #
Di GraphQL, schema adalah sumber kebenaran tunggal. Ia mendefinisikan semua type yang tersedia, relasi antar type, dan operasi yang bisa dilakukan. Schema ditulis dalam SDL (Schema Definition Language).
# Definisi type
type User {
id: ID!
name: String!
email: String!
createdAt: String!
posts: [Post!]!
followers: [User!]!
}
type Post {
id: ID!
title: String!
content: String!
author: User!
publishedAt: String
tags: [String!]!
}
# Root types — pintu masuk semua operasi
type Query {
user(id: ID!): User
users(limit: Int, offset: Int): [User!]!
post(id: ID!): Post
}
type Mutation {
createPost(input: CreatePostInput!): Post!
updatePost(id: ID!, input: UpdatePostInput!): Post!
deletePost(id: ID!): Boolean!
}
type Subscription {
postCreated: Post!
commentAdded(postId: ID!): Comment!
}
Tanda ! berarti non-nullable — field tersebut dijamin tidak null jika request berhasil.
Tiga Operasi GraphQL #
flowchart TD
subgraph Ops["Tiga Operasi GraphQL"]
Q["Query\nMembaca data\n(seperti GET di REST)"]
M["Mutation\nMengubah data\n(seperti POST/PUT/DELETE di REST)"]
S["Subscription\nReal-time updates\n(seperti WebSocket)"]
end
Q -->|"query { user(id: 1) { name } }"| Qex["Response:\n{ user: { name: 'Budi' } }"]
M -->|"mutation { createPost(input: {...}) { id } }"| Mex["Response:\n{ createPost: { id: '123' } }"]
S -->|"subscription { postCreated { id title } }"| Sex["Push notifications\nsaat ada post baru"]Query — Client Menentukan Shape Data #
# Client meminta persis data yang dibutuhkan
query GetUserProfile {
user(id: "123") {
name
email
posts {
title
publishedAt
}
followers {
name
}
}
}
# Response — hanya data yang diminta, tidak lebih
{
"data": {
"user": {
"name": "Budi Santoso",
"email": "[email protected]",
"posts": [
{ "title": "Belajar GraphQL", "publishedAt": "2024-01-15" }
],
"followers": [
{ "name": "Ani" },
{ "name": "Candra" }
]
}
}
}
Mutation — Mengubah Data #
# Mutation dengan input type
mutation CreateNewPost($input: CreatePostInput!) {
createPost(input: $input) {
id
title
author {
name
}
}
}
# Variables
{
"input": {
"title": "Panduan GraphQL",
"content": "...",
"tags": ["graphql", "api"]
}
}
Variables — Memisahkan Query dari Data #
# ANTI-PATTERN: Hardcode nilai langsung di query (tidak aman, tidak reusable)
query {
user(id: "123") { name }
}
# BENAR: Gunakan variables
query GetUser($userId: ID!) {
user(id: $userId) { name }
}
# Variables dikirim terpisah: { "userId": "123" }
# Lebih aman (mencegah injection), lebih reusable
Masalah N+1 dan Solusi DataLoader #
Ini adalah masalah paling kritis yang harus dipahami sebelum mengimplementasikan GraphQL di production. N+1 query problem adalah kondisi di mana satu GraphQL query menghasilkan N+1 database query.
sequenceDiagram
participant C as Client
participant R as GraphQL Resolver
participant DB as Database
C->>R: query { posts { title author { name } } }
Note over R,DB: Tanpa DataLoader — N+1 problem
R->>DB: SELECT * FROM posts LIMIT 10
DB-->>R: 10 posts
loop Untuk setiap post
R->>DB: SELECT * FROM users WHERE id = ?
DB-->>R: 1 user
end
Note over R,DB: Total: 1 + 10 = 11 database queries!
Note over R,DB: Dengan DataLoader — 2 queries saja
R->>DB: SELECT * FROM posts LIMIT 10
DB-->>R: 10 posts
R->>DB: SELECT * FROM users WHERE id IN (1,2,3,...,10)
DB-->>R: 10 users sekaligusMengapa N+1 Terjadi #
Setiap resolver di GraphQL beroperasi secara independen. Resolver author pada type Post tidak tahu bahwa ia akan dipanggil 10 kali untuk 10 post yang berbeda — ia hanya tahu “saya perlu fetch user dengan id ini”.
// ANTI-PATTERN: Resolver yang naif
const resolvers = {
Post: {
author: async (post) => {
// Ini dipanggil SEKALI PER POST → N+1 problem
return await db.users.findById(post.authorId);
}
}
}
// BENAR: Dengan DataLoader
const userLoader = new DataLoader(async (userIds) => {
// Dipanggil SEKALI dengan semua ID sekaligus
const users = await db.users.findByIds(userIds);
return userIds.map(id => users.find(u => u.id === id));
});
const resolvers = {
Post: {
author: (post) => userLoader.load(post.authorId)
// DataLoader mengumpulkan semua ID dalam satu tick,
// lalu melakukan satu batch query
}
}
DataLoader melakukan dua hal: batching (mengumpulkan request individual dalam satu batch query) dan caching (result yang sama dalam satu request tidak di-fetch dua kali).
Schema Design yang Baik #
Schema yang baik mencerminkan domain bisnis, bukan struktur database. Ini adalah perbedaan paling penting antara schema yang mudah berkembang dan schema yang menjadi technical debt.
Domain-Driven, Bukan Database-Driven #
# ANTI-PATTERN: Schema yang mencerminkan database
type User {
user_id: Int! # database column name
created_timestamp: Int # Unix timestamp dari DB
is_deleted: Boolean # soft delete flag dari DB
role_id: Int # foreign key dari DB
}
# BENAR: Schema yang mencerminkan domain
type User {
id: ID!
createdAt: String!
status: UserStatus! # enum yang bermakna
role: UserRole! # type yang rich, bukan ID
}
enum UserStatus {
ACTIVE
SUSPENDED
DEACTIVATED
}
enum UserRole {
ADMIN
EDITOR
VIEWER
}
Input Type untuk Mutation #
# ANTI-PATTERN: Banyak argumen individual
mutation {
createUser(name: String!, email: String!, role: UserRole!, teamId: ID!)
}
# BENAR: Gunakan input type — lebih mudah di-evolve
input CreateUserInput {
name: String!
email: String!
role: UserRole!
teamId: ID!
}
mutation {
createUser(input: CreateUserInput!): User!
}
# Menambahkan field baru ke CreateUserInput tidak breaking change
Pagination dengan Cursor-based #
# Cursor-based pagination (direkomendasikan untuk GraphQL)
type UserConnection {
edges: [UserEdge!]!
pageInfo: PageInfo!
}
type UserEdge {
node: User!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type Query {
users(first: Int, after: String, last: Int, before: String): UserConnection!
}
# Query:
query {
users(first: 10, after: "cursor123") {
edges {
node { id name }
cursor
}
pageInfo {
hasNextPage
endCursor
}
}
}
Pola ini dikenal sebagai Relay Cursor Connection Spec dan menjadi standar de-facto untuk pagination di GraphQL.
Query Complexity dan Depth Limiting #
Tanpa pembatasan, client bisa mengirim query yang sangat mahal ke server:
# Query yang bisa membuat server kolaps
query MaliciousQuery {
users {
followers {
followers {
followers {
followers {
posts {
comments {
author {
followers { name }
}
}
}
}
}
}
}
}
}
Ada dua strategi utama untuk mencegah ini:
Depth Limiting #
Batasi kedalaman nesting query:
Depth limit: 5
Query di atas memiliki depth 8 → ditolak sebelum dieksekusi
Implementasi (contoh dengan graphql-depth-limit di Node.js):
import depthLimit from 'graphql-depth-limit'
const server = new ApolloServer({
validationRules: [depthLimit(5)]
})
Complexity Scoring #
Setiap field diberi "cost":
Scalar field: 1 point
Object field: 1 point
List field: 10 points (karena bisa return banyak item)
Query di atas:
users (list): 10
followers (list) × users: bisa sangat besar
Maximum complexity: misalnya 1000 points
Query yang melebihi batas → ditolak dengan error yang jelas:
"Query complexity 4320 exceeds maximum allowed complexity of 1000"
Query complexity limiting wajib diimplementasikan sebelum GraphQL API diekspos ke publik atau bahkan ke consumer internal yang tidak terkontrol. Tanpa ini, satu query yang tidak sengaja atau sengaja dibuat mahal bisa membuat seluruh service tidak responsif. Ini bukan optimasi — ini adalah keamanan dasar.
Caching di GraphQL #
Caching adalah salah satu tantangan terbesar di GraphQL karena semua request menggunakan POST ke satu endpoint — HTTP cache tradisional tidak bisa dimanfaatkan secara langsung.
Strategi Caching yang Tersedia #
1. Persisted Queries
Client mengirim hash dari query, bukan query string penuh
→ Bisa dieksekusi via GET dengan hash sebagai parameter
→ GET request bisa di-cache oleh CDN dan HTTP cache
GET /graphql?operationName=GetUser&extensions={"persistedQuery":{"sha256Hash":"abc123"}}
2. Response Caching di Server
Cache hasil resolver per field atau per query
→ Redis atau in-memory cache di level resolver
→ Perlu invalidation strategy yang tepat
@cacheControl(maxAge: 300) # cache 5 menit
type Product {
id: ID!
name: String!
price: Float! @cacheControl(maxAge: 30) # lebih sering berubah
}
3. Client-side Caching
Apollo Client dan Relay punya normalized cache yang canggih
→ Menyimpan data per entitas, bukan per query
→ Update satu entitas otomatis memperbarui semua query yang menggunakannya
4. DataLoader Caching
Sudah dibahas sebelumnya — per-request cache untuk menghindari
fetch data yang sama dua kali dalam satu request
Versioning dan Evolusi Schema #
Salah satu keunggulan besar GraphQL adalah kemampuannya untuk berevolusi tanpa versioning eksplisit — tapi ini butuh disiplin.
# Menambahkan field baru → AMAN, tidak breaking
type User {
id: ID!
name: String!
email: String!
phoneNumber: String # field baru, nullable → client lama tidak terpengaruh
}
# Mengubah nama field → BREAKING (gunakan deprecation)
type User {
id: ID!
name: String!
username: String @deprecated(reason: "Use 'name' instead. Will be removed 2025-01-01")
# Tetap ada untuk backward compatibility, tapi diberi tanda deprecated
}
# Menghapus field → HANYA setelah deprecation period cukup
# Step 1: tandai deprecated + komunikasikan ke consumer
# Step 2: monitor apakah masih digunakan (melalui field usage metrics)
# Step 3: hapus setelah tidak ada yang menggunakan
Timeline deprecation yang sehat:
Bulan 1: Tambahkan field baru, deprecate field lama
Bulan 2–4: Monitor usage field lama melalui observability
Bulan 5: Jika usage sudah nol → hapus field
Jika masih ada usage → komunikasikan lagi ke consumer
Monitoring dan Observability #
GraphQL membutuhkan monitoring yang berbeda dari REST. Di REST, kamu bisa monitor per endpoint (/users, /orders). Di GraphQL, semua request masuk ke satu endpoint — yang perlu dimonitor adalah per operasi.
Metric yang perlu dipantau:
Per operasi (bukan per endpoint):
→ Latency per query/mutation (P50, P95, P99)
→ Error rate per operasi
→ Frequency setiap operasi dipanggil
Per field/resolver:
→ Resolver latency per field
→ Field usage — field mana yang tidak pernah diminta?
(kandidat untuk dihapus dari schema)
System level:
→ Query complexity distribution
→ Depth distribution
→ DataLoader batch size
Tools yang umum digunakan:
→ Apollo Studio (tracking operation, field usage)
→ GraphQL Yoga dengan custom plugins
→ Prometheus + Grafana untuk sistem yang sudah ada
Field usage metrics sangat berharga untuk schema evolution — kamu bisa tahu dengan pasti apakah sebuah field masih digunakan sebelum menghapusnya. Apollo Studio menyediakan fitur ini secara built-in. Tanpa visibility ini, schema tumbuh tanpa bisa dipangkas.
Hybrid Architecture — GraphQL dan REST Bersama #
GraphQL tidak harus menggantikan REST sepenuhnya. Hybrid approach sering lebih pragmatis dan lebih sehat.
flowchart TD
WebClient["Web Client (React)"]
MobileClient["Mobile Client (Flutter)"]
GQL["GraphQL API\n(BFF Layer)"]
UserSvc["User Service\n(REST/gRPC)"]
OrderSvc["Order Service\n(REST/gRPC)"]
ProductSvc["Product Service\n(REST/gRPC)"]
PaymentSvc["Payment Service\n(REST/gRPC)"]
WebClient -->|"GraphQL Query"| GQL
MobileClient -->|"GraphQL Query"| GQL
GQL -->|"Internal REST/gRPC"| UserSvc
GQL -->|"Internal REST/gRPC"| OrderSvc
GQL -->|"Internal REST/gRPC"| ProductSvc
GQL -->|"Internal REST/gRPC"| PaymentSvcPola ini dikenal sebagai BFF (Backend for Frontend) — GraphQL berperan sebagai aggregation layer yang menghadap ke client, sementara service-service di belakangnya tetap menggunakan REST atau gRPC untuk komunikasi internal.
Keuntungannya: client mendapat fleksibilitas GraphQL, sementara service internal tetap sederhana dan bisa di-cache dengan HTTP cache tradisional.
Anti-Pattern GraphQL yang Harus Dihindari #
Schema yang Mencerminkan Database #
# ✗ Schema yang bocorkan detail database:
type OrderItem {
order_item_id: Int! # database primary key
order_fk: Int! # foreign key
product_fk: Int! # foreign key
qty: Int! # abbreviasi
unit_price_cents: Int! # implementation detail (cents)
}
# ✓ Schema yang domain-friendly:
type OrderItem {
id: ID!
order: Order! # relasi langsung, bukan FK
product: Product! # relasi langsung, bukan FK
quantity: Int! # nama lengkap
unitPrice: Money! # type yang rich
}
type Money {
amount: Float!
currency: String!
}
God Query — Satu Query untuk Segalanya #
# ✗ God query yang mengambil semua data sekaligus:
query GetEverything {
currentUser {
profile { ... }
orders { ... }
notifications { ... }
recommendations { ... }
recentSearches { ... }
savedItems { ... }
}
}
→ Lambat karena semua data di-fetch sekaligus meski tidak semua ditampilkan
# ✓ Query yang spesifik per halaman atau komponen:
# Di halaman profil:
query GetUserProfile { currentUser { profile { ... } } }
# Di halaman orders:
query GetUserOrders { currentUser { orders { ... } } }
# Di sidebar notifikasi:
query GetNotifications { currentUser { notifications { ... } } }
Tidak Ada Error Handling yang Proper #
# ✗ GraphQL selalu return 200 OK, bahkan untuk error
# Tanpa penanganan yang tepat, error mudah terlewat:
{
"data": {
"user": null ← null tanpa penjelasan mengapa
}
}
# ✓ Gunakan error handling yang eksplisit:
{
"data": {
"user": null
},
"errors": [
{
"message": "User tidak ditemukan",
"extensions": {
"code": "USER_NOT_FOUND",
"path": ["user"]
}
}
]
}
# Atau gunakan union type untuk error yang bisa diprediksi:
union UserResult = User | UserNotFoundError | UnauthorizedError
type Query {
user(id: ID!): UserResult!
}
GraphQL sebagai ORM over HTTP #
# ✗ GraphQL yang terlalu dekat dengan database:
mutation {
updateUser(
where: { id: { eq: "123" } }
set: { name: "Budi", email: "[email protected]" }
)
}
→ Ini adalah database query, bukan API
→ Business logic, validasi, dan authorization dilewatkan
# ✓ Mutation yang mencerminkan operasi domain:
mutation {
updateUserProfile(input: {
userId: "123"
name: "Budi"
email: "[email protected]"
})
}
→ Resolver menerapkan business rules dan validasi
Kapan Menggunakan GraphQL vs REST #
Gunakan GraphQL jika ada kebutuhan nyata untuk:
✓ Multiple client berbeda (web, mobile, widget) dengan kebutuhan data berbeda
✓ UI yang sangat dinamis dan data-hungry
✓ Over-fetching sudah menjadi masalah yang terukur (bukan asumsi)
✓ Tim frontend butuh kecepatan iterasi yang tidak tergantung backend
✓ Aggregating data dari beberapa service (BFF pattern)
Tetap gunakan REST jika:
✓ API sederhana dengan endpoint yang jelas dan stabil
✓ Public API yang butuh cacheability HTTP penuh
✓ Tim belum familiar dengan GraphQL dan tidak ada bandwidth untuk belajar
✓ Use case utama adalah CRUD sederhana tanpa variasi query yang signifikan
✓ Butuh file upload yang sederhana (REST lebih natural untuk ini)
Tanda bahwa GraphQL mungkin over-engineering:
✗ Hanya ada satu jenis client (misalnya hanya web)
✗ Data structure di setiap halaman tidak terlalu berbeda
✗ Tim kecil dengan velocity yang sudah baik menggunakan REST
✗ Tidak ada masalah over-fetching yang nyata dan terukur
Checklist GraphQL yang Sehat #
SCHEMA DESIGN:
□ Schema mencerminkan domain bisnis, bukan database schema
□ Nama field camelCase dan konsisten
□ Input type digunakan untuk semua mutation
□ Enum digunakan untuk nilai yang terbatas (bukan string bebas)
□ Nullable vs non-nullable dipertimbangkan dengan hati-hati
PERFORMA DAN KEAMANAN:
□ DataLoader diimplementasikan untuk semua relasi yang bisa N+1
□ Query depth limiting diaktifkan
□ Query complexity scoring diimplementasikan
□ Rate limiting diterapkan (per IP atau per token)
□ Introspection dinonaktifkan di production (kecuali ada alasan)
CACHING:
□ Persisted queries diimplementasikan untuk production
□ Resolver-level caching untuk data yang jarang berubah
□ Cache invalidation strategy sudah dipikirkan
EVOLUSI SCHEMA:
□ Deprecation digunakan sebelum menghapus field
□ Field usage dipantau sebelum menghapus
□ Nullable field untuk field baru yang opsional
□ Input type memudahkan penambahan parameter baru
MONITORING:
□ Latency per operasi dipantau (bukan hanya per endpoint)
□ Resolver latency dipantau untuk menemukan bottleneck
□ Error rate per operasi dipantau
□ Field usage metrics tersedia untuk schema governance
ERROR HANDLING:
□ Error extensions berisi code yang machine-readable
□ Union type digunakan untuk expected error (bukan hanya null)
□ Partial success didokumentasikan dengan jelas
Ringkasan #
- GraphQL lahir untuk memecahkan over-fetching dan under-fetching — bukan untuk menggantikan REST. Gunakan jika ada masalah nyata yang membenarkan kompleksitas tambahannya.
- Schema adalah kontrak — desain untuk domain, bukan database — nama field mencerminkan bisnis, relasi eksplisit (bukan FK), enum untuk nilai terbatas, input type untuk mutation.
- N+1 query problem adalah ancaman terbesar di production — DataLoader wajib diimplementasikan untuk setiap relasi sebelum ke production. Tanpanya, satu query sederhana bisa menghasilkan ratusan database query.
- Query complexity limiting bukan optimasi, tapi keamanan dasar — depth limit dan complexity scoring harus ada sebelum API diekspos. Satu query yang tidak terkontrol bisa membuat service kolaps.
- Caching di GraphQL butuh strategi berbeda — persisted queries untuk CDN caching, resolver caching dengan Redis, dan client-side normalized caching dengan Apollo/Relay.
- Schema berevolusi melalui deprecation, bukan versioning — tambahkan field baru, tandai field lama sebagai deprecated, monitor usage, baru hapus setelah tidak ada yang menggunakan.
- Monitoring per operasi, bukan per endpoint — satu
/graphqlendpoint menyembunyikan ratusan operasi berbeda. Gunakan tooling yang bisa breakdown per operasi.- Hybrid GraphQL + REST/gRPC adalah pattern yang sering paling sehat — GraphQL sebagai BFF layer menghadap client, service internal tetap menggunakan REST atau gRPC.
- GraphQL bukan ORM over HTTP — resolver harus menerapkan business logic, validasi, dan authorization. Schema yang terlalu dekat dengan database melanggar abstraksi.
- Introspection dinonaktifkan di production — schema yang bisa di-query adalah peta bagi penyerang yang ingin menemukan field sensitif atau merencanakan query kompleks untuk DoS.