Skip to content
jsonforge.app
Kembali ke blog
Panduan9 menit baca

Desain Respons JSON REST API: Praktik Terbaik dan Anti-Pattern

Byte-byte aktual yang dikembalikan sebuah REST API sama pentingnya dengan route dan kode status — setiap klien yang memanggil API Anda ditulis menghadap bentuk JSON-nya, dan membentuk ulang JSON itu nanti adalah breaking change betapapun sepelenya terasa secara internal. Panduan ini membahas keputusan desain respons yang murah untuk benar sejak awal dan mahal untuk diperbaiki setelah klien ada.

Pilih satu bentuk envelope dan jangan pernah menyimpang

Envelope adalah pembungkus level atas yang konsisten untuk setiap respons, apa pun endpoint-nya: sekumpulan key yang dikenal seperti `data`, `error`, dan `meta` yang bisa ditangani secara generik oleh lapisan HTTP klien alih-alih menulis parsing khusus per endpoint. Tanpanya, setiap endpoint yang mengembalikan bentuk sedikit berbeda memaksa setiap klien menulis parser yang sedikit berbeda.

Envelope yang konsisten: respons sukses dan error berbagi bentuk level atas yang sama.
json
// Success
{
  "data": { "id": 42, "name": "Ada Lovelace" },
  "meta": { "requestId": "a1b2c3" }
}

// Failure
{
  "data": null,
  "error": { "code": "NOT_FOUND", "message": "User 42 does not exist" },
  "meta": { "requestId": "a1b2c4" }
}

Nama key yang dipilih kurang penting dibanding konsistensinya: apa pun pilihan Anda, setiap endpoint mengembalikannya, `data` selalu menjadi tempat payload berada, dan klien bisa menulis satu fungsi `unwrap(response)` untuk seluruh API alih-alih satu per resource.

Jangan pernah mengembalikan array (atau skalar) telanjang di level atas

`GET /users` yang mengembalikan array JSON telanjang — `[{...}, {...}]` — tampak wajar, tetapi mengunci Anda dari kemungkinan menambahkan metadata level respons tanpa breaking change. Begitu Anda butuh total count, cursor halaman berikutnya, atau pesan peringatan di samping daftar, Anda harus mengubah tipe level atas dari array ke object — ini merusak setiap klien yang melakukan `response.map(...)` atau `response.length` langsung pada body yang diurai.

Bungkus koleksi dalam field bernama sejak hari pertama — `{ "users": [...] }` atau `{ "data": [...] }` — bahkan saat Anda yakin tak akan pernah butuh metadata ekstra. Menambahkan key saudara pada object adalah perubahan backward-compatible; mengubah array menjadi object bukan, untuk klien mana pun, dalam bahasa apa pun.

Null vs. dihilangkan: keduanya bukan sinyal yang sama

Field yang hadir dengan nilai `null` dan field yang ditinggalkan keluar dari respons sama-sama tampak seperti 'tanpa nilai' sekilas, tetapi keduanya berarti hal berbeda dan klien perlu bisa membedakannya. `null` berkata: field ini konsep nyata yang dikenal untuk resource ini, dan nilai saat ini secara eksplisit kosong (pengguna tanpa `middleName`). Dihilangkan berkata: field ini tidak berlaku, tidak dimuat, atau tidak diminta (permintaan sparse fieldset yang hanya meminta `id` dan `name`).

Distingsi ini krusial khususnya dalam semantik PATCH: di bawah JSON Merge Patch (RFC 7396), mengirim field sebagai `null` berarti hapus field itu dari target, sedangkan sekadar tidak menyertakan field dalam body patch berarti biarkan tidak berubah. Mencampuradukkan keduanya — atau tidak konsisten soal field mana yang sah bernilai `null` — menghasilkan API tempat klien tak bisa aman membedakan 'kosongkan nilai ini' dari 'saya tidak mengirim pembaruan untuk ini'.

Pagination: cursor vs. offset, dan bagaimana bentuk JSON-nya

Pagination offset/limit (`?offset=40&limit=20`, atau `?page=3&pageSize=20`) paling sederhana untuk diimplementasikan dan dipahami, tetapi punya masalah kebenaran yang nyata: jika baris disisipkan atau dihapus di antara permintaan halaman, offset bergeser di bawah kaki klien, menyebabkan baris terlewat atau terduplikasi. Ia juga mahal di SQL pada offset dalam, karena database tetap harus memindai dan membuang semua baris yang dilewati.

Pagination berbasis cursor menyerahkan klien token opaque yang menunjuk 'baris setelah terakhir yang Anda lihat' alih-alih posisi numerik, sehingga tetap stabil meski data yang mendasarinya berubah, dan tetap murah di kedalaman mana pun. Trade-off-nya, klien tidak bisa melompat ke nomor halaman arbitrer — hanya maju (dan, jika didukung, mundur) dari sebuah cursor.

Respons pagination berbasis cursor — tanpa matematika offset, hanya token halaman-berikutnya yang opaque.
json
{
  "data": [ { "id": 101 }, { "id": 102 } ],
  "meta": {
    "nextCursor": "eyJpZCI6MTAyfQ==",
    "hasMore": true
  }
}

Jadikan pagination berbasis cursor sebagai bawaan untuk apa pun yang didukung data yang sering berubah (feed, log, event stream) atau tabel besar; pagination berbasis offset tak masalah untuk koleksi kecil yang sebagian besar statis di mana lompat-ke-halaman-N benar-benar berguna (tabel admin dengan pemilih halaman).

Object error dan konvensi penamaan

Respons error seharusnya membawa lebih dari kode status HTTP dan sebuah string: `code` yang stabil dan dapat dibaca mesin yang bisa aman di-branch oleh klien (`"VALIDATION_ERROR"`, bukan kalimat), `message` yang dapat dibaca manusia untuk log dan pengembang alih-alih untuk diparse, dan untuk kegagalan validasi, array `details` yang menyebutkan persis field mana yang gagal dan mengapa.

Bentuk error yang benar-benar bisa ditindaklanjuti klien.
json
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Request failed validation",
    "details": [
      { "field": "email", "message": "must be a valid email address" },
      { "field": "age", "message": "must be at least 0" }
    ]
  }
}

Untuk penamaan key, camelCase dan snake_case sama-sama pilihan yang baik — yang benar-benar menyakitkan adalah mencampur keduanya antar-endpoint dalam API yang sama, yang memaksa setiap klien menulis logika pemetaan field yang tidak konsisten tergantung endpoint yang dipanggil. Konsumen yang didominasi JavaScript/TypeScript cenderung memilih camelCase karena cocok dengan destructuring native; ekosistem yang didominasi Python dan Ruby sering memilih snake_case. Pilih satu untuk seluruh API, dan terapkan secara konsisten juga pada boolean (`isActive`/`hasAccess`, bukan campuran `active` dan `has_access`).

Anti-pattern yang umum

JSON yang di-stringify di dalam field JSON — `"metadata": "{\"plan\":\"pro\"}"` alih-alih `"metadata": { "plan": "pro" }` — memaksa setiap klien memparse dua kali dan membuang type safety tanpa manfaat. Ini hampir selalu tanda bahwa server menyerialisasi kolom blob database apa adanya alih-alih mendekodenya sebelum mengirim respons.

Format tanggal yang tidak konsisten — sebagian field sebagai timestamp Unix, yang lain sebagai `"08/15/2026"`, yang lain lagi sebagai ISO 8601 penuh — memaksa klien menulis parsing tanggal per field alih-alih satu handler tanggal bersama. Standarkan pada ISO 8601 dalam UTC dengan offset eksplisit (`"2026-08-15T09:30:00Z"`) di semua tempat.

Kebocoran field database internal — mengembalikan artefak ORM seperti `password_hash`, foreign key internal milik layanan lain, atau field pembukuan ORM (`__v`, `_id`, `created_by_worker_id`) apa adanya dalam respons API. Selalu petakan model database Anda ke response DTO yang eksplisit alih-alih menyerialisasi modelnya langsung; itu satu-satunya cara andal menjamin perubahan schema internal tidak diam-diam menjadi perubahan API publik.

FAQ

Apakah respons error sebaiknya memakai envelope yang sama dengan respons sukses?
Ya. Jika respons sukses berbentuk `{ "data": ..., "meta": ... }`, respons error sebaiknya berbentuk level atas yang sama dengan `data: null` dan object `error` terisi, alih-alih struktur yang sepenuhnya berbeda. Itu memungkinkan kode penanganan respons klien memeriksa satu hal — kehadiran `error` — alih-alih bercabang berdasarkan bentuk respons per kode status.
camelCase atau snake_case — apakah pilihannya benar-benar berpengaruh?
Tidak banyak jika sendirian, tetapi konsistensi berpengaruh besar. Pilih satu konvensi untuk seluruh API berdasarkan ekosistem konsumen utama Anda, dokumentasikan, dan terapkan di mana-mana — termasuk prefix boolean dan key object bersarang. Biayanya bukan pada konvensinya, melainkan pada API yang endpoint-nya memakai konvensi berbeda-beda sehingga setiap klien butuh logika pemetaan spesifik-endpoint.
Apakah API baru sebaiknya memakai pagination cursor atau offset?
Default-kan pagination berbasis cursor kecuali Anda secara khusus perlu mengizinkan pengguna melompat ke nomor halaman arbitrer pada data kecil yang sebagian besar statis. Pagination cursor tetap benar saat baris ditambah atau dihapus di tengah pagination dan tetap murah di kedalaman apa pun; pagination offset memburuk dalam kebenaran maupun performa seiring dataset tumbuh atau berubah.
Mengapa tidak langsung menyerialisasi model database sebagai respons API saja?
Karena schema database dan kontrak API publik Anda berubah karena alasan dan lini masa yang berbeda. Menyerialisasi modelnya langsung berarti setiap migrasi, kolom baru, atau upgrade ORM berisiko mengubah — atau membocorkan — bentuk respons publik Anda secara diam-diam. Response DTO yang eksplisit memisahkan keduanya, dengan biaya satu langkah pemetaan ekstra per endpoint.

Coba alat ini

Artikel terkait