Buku punya penulis, buku punya review. Data yang saling terhubung seperti ini disebut relasi, dan API harus memutuskan cara mengirimnya: penulisnya ditaruh di dalam buku, atau client mengambilnya sendiri dari alamat lain? Bayangin pesan makanan lewat telepon. Cara pertama: restoran cuma menyebut nomor menu, dan kamu harus menelepon lagi untuk tiap nomor supaya tahu namanya. Cara kedua: mereka langsung membacakan semuanya, lengkap dengan bahan dan cara masaknya, padahal kamu cuma butuh nama. Cara ketiga: kamu bilang di awal "sebutkan namanya juga ya", dan cuma dapat yang kamu minta.
🧪 Tebakan cepat: halaman daftar Toko Buku menampilkan 8 buku, masing-masing dengan nama penulisnya. API cuma mengirim
author_id, jadi aplikasi memanggilGET /authors/:iduntuk tiap buku. Berapa request totalnya: 2, 5, atau 9? Tebak dulu, jawabannya ada di bagian "N+1 di level API".
Tiga cara mengirim relasi
Data yang dipakai: 8 buku dari pelajaran sebelumnya, 4 penulis, dan 6 review. Tiap review milik satu buku (book_id).
| Cara | GET /books/1 menjawab |
Penulisnya |
|---|---|---|
| Link (id saja) | {"data": {"id": 1, "title": "Laskar Pelangi", "author_id": 1, ...}} |
client ambil sendiri di GET /authors/1 |
| Embed (disematkan) | {"data": {..., "author": {"id": 1, "name": "Andrea Hirata"}}} |
selalu ikut |
| Include opsional | sama dengan embed, tapi cuma kalau diminta lewat ?include=author |
ikut kalau diminta |
Nested resource: data milik induknya
Review selalu milik satu buku, jadi alamatnya ditulis di bawah alamat bukunya: GET /books/1/reviews artinya "review milik buku 1". Ini disebut nested resource. Contoh lain: komentar milik post 3 ada di GET /api/posts/3/comments.
app.get("/books/:id/reviews", (req, res) => {
const bookId = Number(req.params.id);
if (!books.some((b) => b.id === bookId)) {
return res.status(404).json({ error: "Book not found" });
}
const data = reviews.filter((r) => r.book_id === bookId);
res.json({ data, total: data.length });
});
- Buku yang gak ada dijawab
404, bukan daftar kosong: alamat/books/99/reviewsmenunjuk buku yang gak ada. reviews.filter(...)mengambil review yangbook_id-nya cocok. Buku 1 punya 2 review.
Cukup satu tingkat: koleksi/id/koleksi. GET /api/posts/3/comments/7/likes/12/users itu nesting terlalu dalam: client harus tahu post, komentar, dan like-nya sekaligus, padahal like 12 sudah punya id sendiri. Pecah jadi endpoint terpisah, misalnya GET /api/likes/12/users.
Embed: penulis di dalam buku
app.get("/books/:id", (req, res) => {
const book = books.find((b) => b.id === Number(req.params.id));
if (!book) return res.status(404).json({ error: "Book not found" });
const author = authors.find((a) => a.id === book.author_id);
res.json({
data: {
id: book.id,
title: book.title,
price: book.price,
author: { id: author.id, name: author.name },
},
});
});
authors.find(...)mencari penulisnya lewatbook.author_id.- Field-nya dipilih satu per satu, termasuk di dalam
author. Kalau tabel penulis punyaemail, field itu gak ikut terkirim. GET /books/1dijawab dengan"author": {"id": 1, "name": "Andrea Hirata"}di dalam bukunya. Client gak perlu request kedua.
Include opsional dengan ?include=
Embed yang selalu ikut juga ada harganya. Halaman daftar butuh nama penulis, tapi gak butuh review. Kalau server selalu menyematkan penulis dan review, body daftar 8 buku jadi 1.528 byte, padahal dengan penulis saja cukup 932 byte. Di server yang memakai database, tiap relasi yang disematkan juga berarti query tambahan. Jadi alasan relasi di-include secara opsional adalah performa: client cuma membayar yang dia minta.
const INCLUDABLE = ["author", "reviews"];
app.get("/books", (req, res) => {
const include = req.query.include ? req.query.include.split(",") : [];
if (include.some((name) => !INCLUDABLE.includes(name))) {
return res.status(400).json({ error: "include must be one of: author, reviews" });
}
const data = books.map((book) => {
const item = { ...book };
if (include.includes("author")) item.author = authors.find((a) => a.id === book.author_id);
if (include.includes("reviews")) item.reviews = reviews.filter((r) => r.book_id === book.id);
return item;
});
res.json({ data });
});
"author,reviews".split(",")jadi["author", "reviews"]. Tanpa?include=, daftarnya kosong dan gak ada yang disematkan.- Nama relasi dicek dengan allowlist, seperti
sortdi pelajaran Filter & Sort.?include=passworddijawab400.
N+1 di level API
Kalau API cuma memberi author_id, halaman daftar menulis loop seperti ini:
const { data: books } = await (await fetch("/books")).json();
for (const book of books) {
const { data: author } = await (await fetch(`/authors/${book.author_id}`)).json();
book.author = author;
}
Satu request untuk daftar, lalu satu request lagi untuk tiap buku: 1 + 8 = 9 request. Itu jawaban tebakannya. Ini masalah N+1 yang sama dengan di pelajaran Query Database, cuma sekarang terjadi antara client dan API. Kalau satu bolak-balik di jaringan HP butuh 100 milidetik, loop di atas menunggu sekitar 900 milidetik. Mengingat penulis yang sudah diambil memangkasnya jadi 1 + 4 = 5, karena cuma ada 4 penulis. Dengan GET /books?include=author, cukup 1 request.
| Cara, halaman daftar 8 buku | Request | Body yang diterima |
|---|---|---|
| Link, loop per buku | 9 | 924 byte |
| Embed penulis dan review | 1 | 1.528 byte |
?include=author |
1 | 932 byte |
Di sisi server, include=author juga jangan dikerjakan dengan query per buku. Ambil semua penulisnya dalam satu query (WHERE id IN (...) atau JOIN), seperti di pelajaran N+1 Problem & Eager Loading di track Database.
📌 Di Laravel: API Resource
Di Laravel (pelajaran API Resource di track Laravel), bentuk JSON-nya ditulis di sebuah Resource. $this->kelas membaca relasi kelas milik model:
class SiswaResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'nama' => $this->nama,
'kelas' => new KelasResource($this->whenLoaded('kelas')),
];
}
}
new KelasResource($this->kelas)selalu menyematkan kelasnya, dan memicu query per siswa kalau relasinya belum dimuat.$this->whenLoaded('kelas')cuma menyematkan kelas kalau controller sudah memuatnya denganSiswa::with('kelas')->get(). Kalau belum, keykelasgak muncul. Itu include opsional versi Laravel.
⚠️ Jebakan yang sering ketemu
- Nesting terlalu dalam. Lebih dari
koleksi/id/koleksibikin URL panjang dan kaku. Child yang punya id sendiri dapat alamat sendiri. - Selalu menyematkan semuanya. Review ikut di halaman daftar yang gak butuh: body hampir dua kali lipat.
- Include tanpa allowlist. Nama relasi dari client dicek dulu, yang gak dikenal dijawab
400. - Menyematkan seluruh baris.
author: authorRowbisa ikut membawaemailataupassword_hash. Pilih field-nya. - Request di dalam loop. Client yang meminta penulis satu per satu adalah N+1. Sediakan
?include=.
TL;DR: Relasi bisa dikirim tiga cara: link (id saja, client mengambil sendiri), embed (selalu disematkan), atau include opsional (?include=author,reviews, disematkan kalau diminta). Data yang selalu milik induknya pakai nested resource satu tingkat, misalnya /books/1/reviews atau /api/posts/3/comments; /posts/3/comments/7/likes/12/users itu nesting terlalu dalam. Embed memilih field-nya sendiri (author: { id, name }). Include dibuat opsional karena performa: tiap relasi menambah byte dan query, jadi client cuma membayar yang dia minta. Link tanpa include memaksa client ke N+1 di level API: 8 buku = 9 request, dengan include cukup 1. Di Laravel, $this->kelas membaca relasinya, dan $this->whenLoaded('kelas') menyematkannya hanya kalau sudah dimuat dengan with('kelas').