Desain REST API yang Baik
Setelah memahami cara kerja protokol HTTP, langkah berikutnya adalah mendesain API yang memanfaatkannya dengan baik. Artikel ini membahas REST (Representational State Transfer) — sebuah gaya arsitektur untuk membangun web service yang konsisten, mudah diprediksi, dan nyaman dipakai oleh frontend maupun aplikasi mobile. Fokusnya bukan lagi pada tabel method dan status code (sudah dibahas di artikel HTTP), melainkan pada keputusan desain: bagaimana menamai resource, kapan memakai method tertentu, dan bagaimana menyepakati kontrak respons yang seragam.
Apa Itu REST & RESTful API?
REST diperkenalkan oleh Roy Fielding pada tahun 2000 dalam disertasinya sebagai gaya arsitektur untuk sistem terdistribusi berbasis web. API yang mengikuti prinsip REST disebut RESTful API. Intinya, REST memanfaatkan fasilitas yang sudah disediakan HTTP (method, URI, status code, header) untuk memodelkan akses data.
REST dibangun di atas sejumlah constraint (batasan) arsitektur. Berikut ringkasannya, termasuk yang sudah Anda kenal dari artikel HTTP:
| Constraint | Makna Singkat | Contoh Penerapan |
|---|---|---|
| Client-Server | Klien dan server terpisah, masing-masing bisa berkembang sendiri | Frontend diganti tanpa menyentuh backend |
| Stateless | Setiap request berdiri sendiri; server tidak menyimpan sesi klien | Token dikirim di tiap request, bukan disimpan di server |
| Cacheable | Response menyatakan apakah bisa di-cache | Header Cache-Control pada daftar data yang jarang berubah |
| Uniform Interface | Antarmuka yang seragam dan konsisten | Semua resource diakses dengan pola URL + method yang sama |
| Layered System | Server bisa terdiri atas lapisan (API, service, database) | Klien tidak perlu tahu struktur internal server |
Uniform interface adalah inti REST: pengguna API seharusnya bisa menebak pola pemakaian hanya dari satu endpoint yang sudah dikenal — tanpa membaca dokumentasi untuk tiap endpoint.
Arsitektur berlapis di sini mengingatkan pada layered architecture yang umum dipakai aplikasi backend: controller → service → repository. Setiap lapisan punya tanggung jawab sendiri, sehingga API Anda tetap rapi saat kompleksitas bertambah.
Resource & Naming: Memodelkan Data sebagai Kata Benda
Pertanyaan pertama dalam desain API adalah: apa yang ingin kita akses? Jawabannya adalah resource — entitas data seperti users, orders, atau products. Dalam REST, resource direpresentasikan sebagai kata benda jamak di URL, bukan kata kerja.
Aturan Penamaan Resource
- Gunakan kata benda jamak untuk koleksi:
/users,/orders,/products. - Gunakan
{id}untuk item tunggal:/users/{id},/orders/{id}. - Gunakan relasi sebagai sub-resource:
/users/{id}/ordersartinya "order milik user{id}". - Hindari kata kerja di URL — kata kerja adalah tugas HTTP method.
| URL Benar | URL Salah | Alasan |
|---|---|---|
GET /users | GET /getUsers | /getUsers memakai kata kerja |
POST /orders | POST /createOrder | Kata kerja create sudah diwakili POST |
GET /users/{id}/orders | GET /getOrdersByUser?id=1 | Hierarki resource lebih jelas sebagai path |
DELETE /users/{id} | GET /deleteUser?id=1 | Aksi hapus memakai DELETE, bukan GET |
GET /products/{id} | GET /products/id/1 | {id} adalah variabel path, bukan folder |
Query Parameters untuk Filter, Sorting, dan Paging
Parameter tambahan yang sifatnya opsional — filter, urutan, dan halaman — diletakkan di query string:
GET /orders?status=PAID&sort=-date&page=2
| Query param | Makna | Contoh nilai |
|---|---|---|
status | Filter berdasarkan kolom | PAID, PENDING |
sort | Urutan; awali - untuk descending | -date (terbaru dulu) |
page | Nomor halaman (untuk pagination) | 2 |
Garis pemisah yang perlu diingat: identitas resource masuk ke path (
/users/{id}), sedangkan kriteria pencarian masuk ke query (?role=ADMIN). Memindahkan filter ke path membuat URL tidak fleksibel dan sulit diprediksi.
HTTP Method pada Resource
Artikel Anatomi Protokol HTTP sudah membahas makna tiap method. Di sini kita melihat sisi desainnya: bagaimana method dipetakan ke aksi CRUD pada resource.
Matriks Method × Aksi
| Method | Aksi pada Resource | Contoh | Idempoten |
|---|---|---|---|
GET | Membaca (list/detail) | GET /orders atau GET /orders/{id} | Ya |
POST | Membuat resource baru | POST /orders | Tidak |
PUT | Mengganti resource seutuhnya | PUT /orders/{id} | Ya |
PATCH | Mengubah sebagian field | PATCH /orders/{id} | Tidak |
DELETE | Menghapus resource | DELETE /orders/{id} | Ya |
Aksi yang "Tidak Natural" sebagai CRUD
Tidak semua aksi bisnis masuk kerangka CRUD. Contoh: cancel order bukan membuat, mengganti, maupun menghapus data — ia mengubah status order. Ada dua gaya umum:
Gaya RPC dengan sub-path aksi:
POST /orders/{id}/cancel
Gaya Google API Design Guide — aksi sebagai sub-resource:
POST /orders/{id}:cancel
Keduanya sah. Kuncinya adalah konsisten memakai POST untuk aksi semacam ini, karena aksi umumnya tidak idempoten dan sering punya efek samping. Hindari GET /orders/{id}/cancel — itu melanggar kaidah bahwa GET tidak boleh mengubah state.
@PostMapping("/orders/{id}/cancel")
public OrderResponse cancelOrder(@PathVariable Long id) {
Order order = orderService.cancel(id);
return OrderResponse.from(order);
}
Status Code yang Tepat
Memilih status code yang akurat membuat klien bisa mengambil keputusan tanpa membaca isi body. Dari artikel HTTP kita tahu tiga kelompok utama — di sini pemakaiannya diangkat ke level desain:
| Kelompok | Kapan Digunakan | Contoh Konkret |
|---|---|---|
2xx | Operasi sukses | 200 list ditemukan; 201 resource berhasil dibuat; 204 hapus tanpa isi |
4xx | Kesalahan di sisi klien | 400 body tidak valid; 401 belum login; 403 tidak berhak; 404 resource tak ada |
5xx | Kesalahan di sisi server | 500 bug tak terduga; 503 server overload |
Idempotency (membahas yang sudah disinggung di artikel HTTP) juga berperan penting di sini: klien berhak menelpon ulang GET, PUT, atau DELETE saat koneksi putus tanpa takut menciptakan data ganda. Untuk POST yang rawan duplikasi, beri idempotency-key di header sebagai kontrak opsional.
Prinsip praktis: jika klien bisa memperbaiki kesalahan lalu mengulang, jawablah dengan
4xx. Jika kesalahan ada di kode Anda, jujurlah dengan5xx— dan catat lognya. Menjawab200untuk error adalah sumber kebingungan terbesar bagi konsumen API.
Envelope Respons: Membungkus atau Tidak?
Keputusan desain berikutnya adalah bentuk body respons. Ada dua kubu:
Kubu 1 — return object langsung (tanpa bungkus):
{ "id": 42, "email": "hendro@mail.com", "role": "ADMIN" }
Kubu 2 — envelope ({ "success", "message", "data" }):
{
"success": true,
"message": "User berhasil dibuat",
"data": { "id": 42, "email": "hendro@mail.com", "role": "ADMIN" }
}
| Aspek | Return Langsung | Envelope |
|---|---|---|
| Kesederhanaan | Struktur sejelas datanya | Ada lapisan ekstra yang harus di-unwrap klien |
| Metadata | Tidak ada tempat untuk info tambahan | Ada slot message, success, timestamp |
| Error handling | Pakai status code + body error terpisah | Semua respons satu bentuk, mudah di-parse |
| Developer experience | Lebih ringkas saat 200 | Konsisten namun berisik untuk operasi yang sering |
Kapan envelope berguna? Saat klien butuh informasi tambahan di luar data (seperti totalElements untuk pagination), atau saat tim Anda ingin satu format tunggal untuk sukses dan gagal. Sebaliknya, banyak API modern (misal JSON:API) memilih langsung mengembalikan data dan menyerahkan penanganan error ke status code. Tidak ada jawaban universal — yang penting konsisten.
Error Contract yang Seragam
Apa pun pilihan envelope Anda, format error harus seragam di seluruh API. Klien yang baik memprogram berdasarkan pola, bukan teks. Bayangkan dua format error berbeda dalam satu API:
{ "error": "user not found" }
{ "message": "VALIDATION_FAILED", "details": { "email": "must be valid" } }
Sulit di-parse dan rentan salah program. Solusinya, definisikan satu kontrak ApiError:
public record ApiError(
String code, // kode mesin, bisa diprogram: USER_NOT_FOUND
String message, // pesan untuk manusia, bahasa lokal
Instant timestamp, // kapan error terjadi (ISO 8601 UTC)
Map<String, String> fieldErrors // detail per field, opsional
) {}
Contoh response error-nya:
{
"code": "VALIDATION_FAILED",
"message": "Permintaan tidak valid",
"timestamp": "2026-08-05T09:30:00Z",
"fieldErrors": { "email": "format email tidak valid", "quantity": "harus minimal 1" }
}
Handler Terpusat dengan @RestControllerAdvice
Agar semua error melewati format yang sama, terpusatkan mapping exception → ApiError di satu tempat:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ApiError> handleNotFound(ResourceNotFoundException ex) {
ApiError body = new ApiError(
"RESOURCE_NOT_FOUND",
ex.getMessage(),
Instant.now(),
Map.of());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiError> handleValidation(MethodArgumentNotValidException ex) {
Map<String, String> fieldErrors = ex.getBindingResult().getFieldErrors().stream()
.collect(Collectors.toMap(
e -> e.getField(),
e -> e.getDefaultMessage() == null ? "invalid" : e.getDefaultMessage(),
(a, b) -> a));
ApiError body = new ApiError(
"VALIDATION_FAILED",
"Permintaan tidak valid",
Instant.now(),
fieldErrors);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiError> handleUnexpected(Exception ex) {
// log ex terlebih dahulu, jangan bocorkan detail internal ke klien
ApiError body = new ApiError(
"INTERNAL_ERROR",
"Terjadi kesalahan di server",
Instant.now(),
Map.of());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(body);
}
}
Kunci dari
code: itu adalah machine-readable identifier yang bisa dipercaya oleh frontend untuk branching (misalcode == "TOKEN_EXPIRED"→ redirect ke halaman login), sedangkanmessagehanya untuk ditampilkan kepada manusia dan bisa berubah kapan saja.
Pagination: Jangan Mengirim Semua Data Sekaligus
Endpoint list seperti GET /orders berpotensi mengembalikan jutaan baris. Tanpa pagination, respons menjadi lambat dan boros memori. Ada dua pendekatan utama:
| Aspek | Offset (page/size atau offset/limit) | Cursor (berbasis token) |
|---|---|---|
| Cara kerja | Lewati N baris pertama | Lanjut dari penanda (cursor) data terakhir |
| Implementasi | Paling sederhana (LIMIT x OFFSET y) | Butuh kolom unik terurut (misal id atau created_at) |
| Kinerja | Lambat di halaman dalam (OFFSET besar) | Stabil berapa pun halamannya |
| Data berubah di tengah | Halaman bisa bergeser/duplikat | Konsisten terhadap data baru |
| Contoh query | ?page=2&size=20 | ?limit=20&cursor=abc123 |
Untuk API dengan data relatif kecil, offset pagination lebih dari cukup:
GET /orders?status=PAID&page=1&size=10
Respons-nya menyertakan metadata halaman, misalnya:
{
"data": [ { "id": 1, "total": 250000, "status": "PAID" } ],
"pagination": {
"page": 1,
"size": 10,
"totalElements": 137,
"totalPages": 14
}
}
totalElements dan totalPages memungkinkan frontend menggambar kontrol pagination (halaman 1 … 14). Untuk feed real-time yang terus bertambah (misal notifikasi), cursor pagination lebih cocok karena data baru tidak menggeser hasil yang sudah tampil.
Aturan praktis: gunakan
page/sizesaat Anda butuh lompat langsung ke halaman tertentu (misal tabel admin), dan gunakan cursor saat alur baca-berlanjut seperti infinite scroll.
Versioning API
API berubah seiring waktu: field baru ditambahkan, kontrak error berubah, nama resource direname. Agar perubahan ini tidak memecahkan aplikasi klien yang sudah terlanjur rilis, API perlu versioning. Ada beberapa pendekatan:
| Pendekatan | Contoh | Kelebihan | Kekurangan |
|---|---|---|---|
| Path versioning | GET /api/v1/orders | Paling jelas, mudah di-cache & di-debug | Versi "menempel" di URL selamanya |
| Header versioning | Accept: application/vnd.api+json;version=1 | URL tetap bersih | Klien harus memikirkan header dengan benar |
| Query versioning | GET /orders?api-version=1 | Sederhana | Mudah terlewat, sulit di-cache |
Pendekatan paling umum (dan direkomendasikan banyak tim, termasuk Google API Design Guide) adalah path versioning di bawah prefix /api/v{n}:
GET /api/v1/orders
GET /api/v2/orders
Kebijakannya: versi lama dipertahankan beberapa waktu dengan masa deprekasi, klien diberi tenggat migrasi, lalu versi lama dihapus. Tanpa versioning, Anda akan ragu mengubah apa pun — dan API yang tidak pernah berubah tidak akan pernah menjadi lebih baik.
Dokumentasi & Kontrak: OpenAPI sebagai Sumber Kebenaran
API yang bagus tidak berarti apa-apa jika tidak bisa ditemukan orang lain. OpenAPI (dikenal juga sebagai Swagger) adalah standar untuk mendeskripsikan API dalam format YAML/JSON: endpoint, parameter, skema data, hingga status code. Dari dokumen ini Anda bisa otomatis menghasilkan dokumentasi interaktif (Swagger UI) dan bahkan client SDK.
Link referensi: OpenAPI Specification dan JSON:API sebagai standar kontrak data JSON.
Selain kontrak, ada praktik kecil yang membuat API terasa profesional:
- Konsistensi nama field: pilih satu konvensi. JSON umumnya memakai
camelCase(totalPrice, bukantotal_price), sementara database boleh tetapsnake_case. - Timezone dan format waktu: selalu gunakan UTC dan format ISO 8601 (
2026-08-05T09:30:00Z), biarkan klien yang menyesuaikan ke zona waktu lokal. - Nama yang deskriptif:
sort=-createdAt, bukanorder=1. - Jangan bocorkan detail internal: kesalahan server cukup menyebut
INTERNAL_ERROR; simpan stack trace di log server.
Studi Kasus: Mendesain API Order E-Commerce
Mari terapkan semua prinsip di atas untuk membangun API order dari nol. Entitasnya: Order (header transaksi) dan OrderItem (detail per produk). Skenario yang didukung: lihat daftar order, lihat detail, buat order baru, batalkan order, dan ambil riwayat order milik seorang user.
| Endpoint | Method | Fungsi | Status Sukses | Error umum |
|---|---|---|---|---|
/api/v1/orders?status=&sort=&page= | GET | List order (dengan pagination) | 200 | 400 filter tidak dikenal |
/api/v1/orders/{id} | GET | Detail order + items | 200 | 404 order tidak ada |
/api/v1/orders | POST | Buat order baru | 201 | 400 validasi gagal, 409 stok kurang |
/api/v1/orders/{id}/cancel | POST | Batalkan order | 200 | 409 status bukan PENDING |
/api/v1/users/{userId}/orders | GET | Riwayat order milik user | 200 | 404 user tidak ada |
Contoh kontrak untuk POST /api/v1/orders:
{
"userId": 7,
"items": [ { "productId": 12, "quantity": 2 } ],
"shippingAddress": "Jl. Merdeka No. 1"
}
Response 201 Created:
{
"success": true,
"message": "Order berhasil dibuat",
"data": {
"id": 501,
"status": "PENDING",
"totalPrice": 500000,
"createdAt": "2026-08-05T09:30:00Z"
}
}
Perhatikan beberapa keputusan desainnya:
- Nama resource memakai kata benda jamak dan hierarki yang jelas (
/users/{userId}/orders). - Aksi non-CRUD (
cancel) diperlakukan sebagai aksiPOSTdengan sub-path. - Envelope dipakai konsisten sehingga
success,message, dandataselalu ada di semua respons sukses. - Error selalu lewat
ApiErrordariGlobalExceptionHandler, dengancodeyang bisa diprogram (misalINVALID_ORDER_STATEuntuk kasus409). - Pagination menyertakan metadata
totalElements/totalPagesdi respons list. - Versi dipegang di path (
/api/v1/), dan semua timestamp memakai ISO 8601 UTC.
Ringkasan
Mendesain REST API yang baik bukan soal menghafal tabel method dan status code — melainkan tentang konsistensi keputusan: resource yang dinamai sebagai kata benda jamak, HTTP method yang dipakai sesuai maknanya, respons yang bentuknya seragam, error dengan code yang bisa diprogram, pagination yang disepakati, versi yang dikelola, dan kontrak yang didokumentasikan lewat OpenAPI. Tidak ada satu jawaban benar untuk semua kasus — kunci sebenarnya adalah membuat satu set keputusan dan mematuhinya, agar developer lain (dan diri Anda sendiri enam bulan kemudian) bisa memakai API tanpa menebak-nebak.
Lanjut membaca
- Artikel selanjutnya: Dasar Database Relasional & Bahasa SQL untuk memahami bagaimana resource yang Anda buka lewat API disimpan di sisi server.
- Referensi resmi: restfulapi.net — panduan REST langkah demi langkah; MDN Glossary: REST — definisi ringkas; JSON:API — standar kontrak data; RFC 9110 — spesifikasi HTTP itu sendiri; serta Google API Design Guide untuk praktik desain dari sisi industri.