Poin Penting dalam Mendesain Kontrak Data JSON API
Intinya: Enam hal sangat sulit diubah setelah rilis: envelope respons, format error, bentuk pagination, representasi ID dan waktu, semantik null versus tidak ada, serta evolusi versi.

Intinya: kunci envelope respons, format error, representasi ID dan waktu, serta semantik null-versus-tidak-ada di dalam kontrak sebelum berdebat soal detail pagination dan penamaan; selalu bungkus top level dengan envelope data agar pagination dan tracing ID bisa ditambahkan nanti tanpa memutus pemanggil yang sudah ada.
Tidak ada jawaban standar dalam desain API, tetapi ada keputusan "sulit ditarik kembali setelah dipilih" yang sebaiknya dipikirkan matang sebelum Anda menulis baris kode pertama.
1. Membungkus respons atau tidak
Tiga gaya:
// Gaya A: respons polos
{ "id": 1, "name": "张三" }
// Gaya B: envelope data
{ "data": { "id": 1, "name": "张三" } }
// Gaya C: data + metadata
{ "data": { "id": 1, "name": "张三" }, "meta": { "requestId": "a1b2" } }
| Gaya | Kelebihan | Kekurangan |
|---|---|---|
| Respons polos | Paling sederhana, satu lapisan lebih sedikit untuk frontend | Tidak bisa menempelkan metadata; array di top level membawa risiko JSON hijacking dan evolusi |
| Envelope data | Mudah menambahkan meta / errors / links |
Satu level nesting tambahan, frontend harus menulis res.data.data |
| Data + metadata | Observabilitas terbaik | Butuh kesepakatan ketat soal field mana yang masuk meta |
Rekomendasi: gunakan envelope data secara seragam untuk daftar maupun detail, dan jangan pernah menjadikan top level sebagai array. Dengan begitu, menambahkan pagination atau tracing ID nanti tidak pernah memutus kontrak.
2. Format error: tiru RFC 9457
Menciptakan struktur error sendiri adalah sumber biaya kolaborasi yang besar. RFC 9457 (yang menggantikan RFC 7807) mendefinisikan application/problem+json:
{
"type": "https://bbdrug.com/problems/insufficient-balance",
"title": "余额不足",
"status": 422,
"detail": "账户余额 12.00 元,本次扣款需要 30.00 元",
"instance": "/accounts/A1001/payments/p-889",
"balance": "12.00",
"required": "30.00"
}
| Field | Arti | Wajib |
|---|---|---|
type |
URI stabil untuk tipe masalah | Disarankan |
title |
Ringkasan singkat yang mudah dibaca manusia | Disarankan |
status |
Kode status HTTP | Disarankan |
detail |
Penjelasan spesifik tentang apa yang terjadi kali ini | Opsional |
instance |
Instance resource yang bermasalah | Opsional |
| Field ekstensi | Field bisnis sembarang | Opsional |
Field ekstensi (seperti balance / required di atas) diizinkan spesifikasi, dan frontend bisa memakainya untuk pesan yang presisi alih-alih mem-parsing teks detail.
3. Pagination: cursor mengalahkan offset
// Pagination offset
{ "data": [], "page": { "offset": 40, "limit": 20, "total": 137 } }
// Pagination cursor
{ "data": [], "page": { "nextCursor": "eyJpZCI6MTAwfQ", "hasMore": true } }
| Dimensi | Pagination offset | Pagination cursor |
|---|---|---|
| Kesulitan implementasi | Rendah (LIMIT/OFFSET) |
Menengah (butuh key urutan yang stabil) |
| Performa pada halaman dalam | Buruk (semakin besar offset, semakin lambat) | Baik |
| Ketika data berubah | Duplikat atau item hilang | Stabil |
| Lompat ke halaman | Bisa | Tidak bisa |
| Butuh total count | Butuh count tambahan | Tidak perlu |
Rekomendasi: gunakan pagination offset untuk tabel admin (mereka butuh lompat halaman dan total), dan pagination cursor untuk feed tak berujung yang menghadap pengguna.
4. ID dan waktu
4.1 Selalu gunakan string untuk ID
Alasannya sudah dibahas di artikel tentang tipe: di sisi JavaScript, nilai di atas 2^53 diam-diam kehilangan presisi.
Bahkan jika ID saat ini adalah integer auto-increment kecil, memakai string menyisakan ruang untuk beralih ke snowflake ID nanti.
{ "id": "1000000000000000001" }
4.2 Gunakan string RFC 3339 untuk waktu
{
"createdAt": "2026-10-30T10:41:45+08:00",
"updatedAt": "2026-10-30T02:41:45Z"
}
- Jangan gunakan number timestamp milidetik: sulit dibaca, dan satuannya (detik/milidetik) sering keliru;
- Tentukan zona waktu secara eksplisit: dengan
Zatau dengan offset; jangan pernah memakai "waktu lokal tanpa zona waktu"; - Beri nama field secara seragam
xxxAtagar sekilas jelas bahwa itu sebuah titik waktu.
5. null dan tidak ada
Anda harus memilih satu dan menuliskannya ke dokumentasi:
| Pendekatan | Menyatakan "tidak ada nilai" | Menyatakan "jangan ubah" |
|---|---|---|
| Pendekatan A | Nilai field adalah null |
Hilangkan field di request |
| Pendekatan B | Field tidak ada | Hilangkan field di request |
Masalah Pendekatan A adalah "dihilangkan" dan "dikirim sebagai null" punya arti berbeda dalam PATCH, yang mudah disalahpahami;
masalah Pendekatan B adalah JSON.stringify di frontend membuang field yang nilainya undefined, yang kebetulan cocok dengan semantiknya.
Rekomendasi praktis:
- Gunakan Pendekatan A untuk response body (
nulljelas berarti tidak ada nilai, dan frontend tidak perlu membedakanundefined); - Gunakan Pendekatan B untuk request body (terutama
PATCH), dikombinasikan dengan Merge Patch atau JSON Patch agar semantiknya eksplisit.
6. Penamaan dan struktur field
| Keputusan | Pilihan umum | Catatan |
|---|---|---|
| Gaya penamaan | camelCase (ekosistem JS) / snake_case (Python, basis data) |
Seragam di seluruh proyek, jangan dicampur |
| Nilai enum | Huruf kecil dengan garis bawah, "pending_review" |
Jangan pakai number; sulit dibaca |
| Field boolean | isActive / enabled |
Jangan pakai status: 0/1 |
| Field array | Kata benda jamak, items |
Kembalikan [] untuk array kosong, jangan pernah null |
| Kedalaman nesting | Tidak lebih dari 3 level | Lebih dalam dari itu, pecah atau tawarkan parameter expand |
Mengembalikan [] alih-alih null untuk array kosong sangat penting: data.items.map(...) di frontend tidak akan crash karena sebuah null.
7. Strategi evolusi
| Perubahan | Memutus kompatibilitas | Cara menangani |
|---|---|---|
| Menambah field opsional | Tidak | Kirim langsung |
| Menambah field wajib | Ya | Beri nilai default, atau rilis lewat versi |
| Menghapus field | Ya | Tandai deprecated dulu, amati pemanggil, lalu hapus |
| Mengubah tipe field | Ya | Tambah nama field baru dan dual-write selama transisi |
| Mengubah himpunan nilai enum | Bergantung | Klien harus punya fallback untuk nilai tak dikenal |
| Mengubah semantik field | Ya | Perlakukan sebagai field baru |
Tiga aturan disiplin:
- Klien harus toleran terhadap field tak dikenal (jangan mengurung diri dengan
additionalProperties: false); - Klien harus punya cabang fallback untuk nilai enum tak dikenal;
- Pertahankan field deprecated setidaknya satu versi mayor, dan tandai dengan
Deprecationdi header respons atau dokumentasi.
8. Contoh lengkap
{
"data": [
{
"id": "1001",
"name": "JSON 权威指南",
"price": "89.50",
"tags": ["json", "spec"],
"publishedAt": "2026-09-01T00:00:00+08:00",
"author": null
}
],
"page": { "nextCursor": null, "hasMore": false },
"meta": { "requestId": "req-7f3a", "tookMs": 12 }
}
Respons ini mencerminkan semua konvensi di atas: envelope data, ID berupa string, jumlah uang sebagai string, timestamp dengan zona waktu,
null bermakna tidak ada, array kosong, pagination cursor, dan meta untuk informasi observabilitas.
9. Checklist
- [ ] Top level bukan array; gunakan envelope
datasecara seragam; - [ ] Respons error mengikuti RFC 9457 dan menyertakan
typeyang stabil; - [ ] Bentuk pagination sesuai skenario bisnis;
- [ ] ID berupa string, dan waktu memakai RFC 3339;
- [ ] Semantik
nulldan tidak ada dituliskan di dokumentasi; - [ ] Gaya penamaan seragam di seluruh proyek, dan enum memakai string huruf kecil;
- [ ] Klien punya fallback untuk field tak dikenal dan nilai enum tak dikenal;
- [ ] Perubahan yang memutus kompatibilitas melewati versioning atau transisi dual-write.
FAQ
Bagaimana cara mendesain JSON API yang bisa berevolusi?
Bungkus daftar dan detail secara seragam dalam envelope data dan jangan pernah menjadikan top level sebagai array, agar pagination dan tracing ID bisa ditambahkan tanpa memutus kontrak. Gunakan ID string dan timestamp RFC 3339, dan tuliskan semantik null-versus-tidak-ada di dokumentasi. Jaga field baru tetap opsional dan mandiri secara semantik, dan beri klien fallback untuk field serta nilai enum tak dikenal.
Mengapa tidak menciptakan format error kustom?
Menciptakan struktur error sendiri adalah sumber biaya kolaborasi yang besar. Tiru application/problem+json RFC 9457, identifikasi masalah dengan type yang stabil, dan izinkan field ekstensi seperti balance dan required, agar frontend bisa membangun pesan presisi tanpa mem-parsing teks detail.
Apakah setiap endpoint harus memakai pagination cursor?
Tidak. Tabel admin butuh lompat halaman dan total, sehingga pagination offset lebih cocok; feed tak berujung yang menghadap pengguna sebaiknya memakai pagination cursor, yang tetap stabil di halaman dalam dan menghindari duplikat atau celah ketika data berubah. Pilih berdasarkan apakah skenario itu butuh lompat halaman dan total.