Resolver adalah fungsi di server yang mengisi nilai satu field. Schema cuma bilang field apa saja yang ada; resolver yang mencari isinya, misalnya dari database. Bayangin pesanan di toko buku dikerjakan berantai: petugas pertama mengambil bukunya dari rak, lalu menyerahkannya ke petugas berikutnya yang mencari data penulisnya, lalu ke petugas yang mencari buku-buku lain penulis itu. Tiap petugas mulai dari barang yang diserahkan petugas sebelumnya. Itu rantai resolver.
🧪 Tebakan cepat: Resolver
Book.authordihapus dari server Toko Buku. Query{ book(id: 1) { title author { name } } }menjawab apa? Jawabannya ada di bagian "Resolver default: membaca property".
Empat parameter: parent, args, context, info
Ini sebagian resolver Toko Buku, persis yang dijalankan Lab di halaman ini:
const resolvers = {
Query: {
book: (_, args) => books.find((b) => String(b.id) === args.id) ?? fail("Book not found"),
},
Book: {
status: (book) => statusOf(book),
author: (book) => authors.find((a) => a.id === book.authorId),
},
Author: {
books: (author) => books.filter((b) => b.authorId === author.id),
},
};
- Resolver dikelompokkan per type, lalu per field:
Book.authoradalah resolver fieldauthordi typeBook. - Tiap resolver dipanggil dengan empat parameter, selalu dalam urutan ini:
(parent, args, context, info). Yang gak dipakai boleh gak ditulis, atau diberi nama_.
| Parameter | Isinya | Contoh |
|---|---|---|
parent |
hasil resolver satu tingkat di atasnya | di Book.author: baris buku { id: 1, title: "Laskar Pelangi", authorId: 1, price: 89000, stock: 12 } |
args |
argument field ini, sudah dicek type-nya | di Query.book: { id: "1" }, string karena type-nya ID |
context |
objek bersama untuk satu request | { user, db } |
info |
keterangan field yang sedang diisi | info.fieldName = "author", info.path = book, lalu author |
Di field teratas (Query.book), parent adalah root value, biasanya undefined. Makanya ditulis _.
Rantai resolver: book → author → books
{
book(id: 1) {
title
author {
name
books {
title
}
}
}
}
Mesin GraphQL berjalan dari atas ke bawah. Hasil tiap resolver jadi parent untuk field di dalamnya:
| # | Field | parent yang diterima |
Resolver | Hasilnya |
|---|---|---|---|---|
| 1 | Query.book |
undefined, args { id: "1" } |
ditulis sendiri | baris buku 1 |
| 2 | Book.title |
baris buku 1 | default | "Laskar Pelangi" |
| 3 | Book.author |
baris buku 1 | ditulis sendiri | { id: 1, name: "Andrea Hirata" } |
| 4 | Author.name |
penulis 1 | default | "Andrea Hirata" |
| 5 | Author.books |
penulis 1 | ditulis sendiri | list berisi buku 1 |
| 6 | Book.title |
baris buku 1 | default | "Laskar Pelangi" |
Enam pemanggilan: tiga resolver yang ditulis sendiri, tiga resolver default. Response-nya { "book": { "title": "Laskar Pelangi", "author": { "name": "Andrea Hirata", "books": [{ "title": "Laskar Pelangi" }] } } }.
Resolver default: membaca property
Field tanpa resolver tetap terisi, karena graphql-js memakai resolver default: ia membaca property bernama sama dari parent (parent.title), dan memanggilnya kalau property itu fungsi. Itu sebabnya title, price, stock, dan name gak butuh resolver.
Book.author berbeda. Baris buku menyimpan authorId, bukan author. Kalau resolver-nya dihapus, resolver default membaca book.author, hasilnya undefined, dan author bertype Author! gak boleh kosong:
{
"errors": [
{
"message": "Cannot return null for non-nullable field Book.author.",
"locations": [{ "line": 4, "column": 5 }],
"path": ["book", "author"]
}
],
"data": { "book": null }
}
null dari author naik ke tempat terdekat yang boleh null, yaitu book. Jadi judulnya pun hilang. Itu jawaban tebakannya. Kalau resolver melempar error di field non-null, null-nya naik dengan cara yang sama, cuma pesannya pesan error itu. Aturannya: tulis resolver untuk field yang nilainya gak ada apa adanya di parent (relasi seperti author, nilai hitungan seperti status), selebihnya biarkan default.
Resolver async
Resolver sungguhan biasanya membaca database, jadi ia async dan mengembalikan Promise. graphql-js menunggu Promise itu sebelum mengisi field di bawahnya:
const resolvers = {
Query: {
books: async (_, args, context) => context.db.books.findMany(),
},
Book: {
author: async (book, _, context) => context.db.authors.findById(book.authorId),
},
};
Ada satu efek yang perlu kamu tahu. Untuk { books { title author { name } } }, mesin gak menunggu satu buku selesai sebelum mulai buku berikutnya. Dengan resolver async, urutan pemanggilannya jadi begini: Query.books, lalu Book.title dan Book.author untuk buku 1, 2, dan 3, baru Author.name tiga kali setelah ketiga penulis datang. Book.author dipanggil tiga kali, sekali per buku. Kalau tiap pemanggilan itu satu query database, itu masalah N+1, dibahas di pelajaran "N+1 Problem & DataLoader". Field teratas mutation tetap dijalankan berurutan walaupun resolver-nya async.
context: dibuat baru untuk tiap request
context dibuat server sekali per request, lalu diberikan ke semua resolver request itu. Di Apollo Server:
const { url } = await startStandaloneServer(server, {
context: async ({ req }) => ({
user: await userFromToken(req.headers.authorization),
db,
}),
});
- Isinya yang dibutuhkan banyak resolver: user yang sedang login, koneksi database, cache untuk satu request.
- Dibuat baru tiap request karena tiap request bisa dari user yang berbeda. Data user jangan disimpan di variabel global, nanti terbawa ke request orang lain.
- Cara mengisi
context.userdari token dibahas di pelajaran "Authentication & Authorization".
Logika bisnis di luar resolver
Resolver sebaiknya tipis: baca args dan context, panggil fungsi bisnis, kembalikan hasilnya. Aturan seperti "stok gak boleh negatif" ditaruh di fungsi bisnis, misalnya updateStock: (_, { id, stock }, { services }) => services.stock.update(id, stock). Dengan begitu aturan yang sama dipakai juga oleh REST API, job terjadwal, dan test, tanpa ditulis ulang.
⚠️ Jebakan yang sering ketemu
- Field relasi tanpa resolver.
authordibaca daribook.authoryang gak ada, jadinull, laluCannot return null for non-nullable field Book.author. - Lupa
return. Resolver dengan kurung kurawal tanpareturnmengembalikanundefined, yang dianggapnull. - Salah urutan parameter. Argument ada di parameter kedua:
(_, args), bukan(args). Di field teratas, parameter pertama itu root value. - Menyimpan data request di variabel global. User, izin, dan cache per request taruh di
context.
TL;DR: Resolver adalah fungsi yang mengambil data untuk tiap field di schema, dikelompokkan per type dan field (Book.author). Parameternya selalu (parent, args, context, info): hasil resolver di atasnya, argument field itu, objek bersama satu request, dan keterangan field. Resolver berantai: Query.book mengembalikan baris buku, yang jadi parent untuk Book.author, yang hasilnya jadi parent untuk Author.books. Field tanpa resolver memakai resolver default yang membaca parent[namaField]; field relasi seperti author butuh resolver sendiri, dan tanpa itu null di field non-null naik ke tempat terdekat yang boleh null. Resolver boleh async; saudara-saudaranya dimulai tanpa saling menunggu. context dibuat baru untuk tiap request, dan logika bisnis sebaiknya di luar resolver.