Tampilan
Kenapa penting
API adalah kontrak: begitu aplikasi mobile, frontend, atau sistem partner memakainya, mengubah URL atau arti status code berarti merusak klien yang tidak bisa kamu deploy ulang. Desain yang konsisten membuat API mudah ditebak, aman di-retry, dan murah dirawat. Di tim backend, kamu akan diminta mereview desain endpoint, memilih status code saat validasi gagal, atau menjelaskan kenapa pembayaran tertagih dua kali — semuanya keputusan desain REST, bukan sekadar anotasi Spring. Bab ini belum menulis controller (itu bab berikutnya); fokusnya apa yang dirancang.
Konsep inti
Resource dan URL
REST memodelkan API sebagai kumpulan resource (pesanan, produk, pelanggan) yang dialamatkan lewat URL. Aturan praktis yang dipakai sebagian besar tim:
- Pakai kata benda jamak, bukan kata kerja:
/orders, bukan/getOrdersatau/createOrder. - Item tunggal lewat ID:
/orders/42. - Hierarki untuk relasi kepemilikan yang kuat:
/orders/42/items. Batasi kedalaman ±2 tingkat; lebih dari itu biasanya tanda resource anak sebaiknya punya URL sendiri (/items/9001). - Huruf kecil dan kebab-case:
/payment-methods. - Filter, sort, dan pagination lewat query parameter:
/orders?status=PAID&sort=-createdAt. - Aksi yang bukan CRUD murni dimodelkan sebagai sub-resource atau aksi dengan
POST:POST /orders/42/cancellation.
Method HTTP dan CRUD
| Method | Operasi | Contoh | Aman | Idempotent |
|---|---|---|---|---|
GET | baca | GET /orders/42 | ya | ya |
POST | buat / aksi | POST /orders | tidak | tidak |
PUT | ganti seluruh resource | PUT /products/7 | tidak | ya |
PATCH | ubah sebagian | PATCH /products/7 | tidak | tidak dijamin |
DELETE | hapus | DELETE /orders/42 | tidak | ya |
Aman (safe) berarti tidak mengubah state server — karena itu GET boleh di-cache dan di-prefetch, dan tidak boleh dipakai untuk menghapus atau membayar. Idempotent berarti mengirim request yang sama berkali-kali menghasilkan kondisi akhir yang sama seperti sekali.
Status code yang tepat
| Kode | Kapan |
|---|---|
200 | sukses dengan body (GET, PUT, PATCH) |
201 | resource baru dibuat; sertakan header Location ke URL resource baru |
202 | diterima untuk diproses asinkron, belum selesai |
204 | sukses tanpa body (umumnya DELETE) |
400 | request rusak: JSON tidak valid, tipe parameter salah, field wajib kosong |
401 | belum terautentikasi (token tidak ada/tidak valid) |
403 | terautentikasi tetapi tidak berhak |
404 | resource tidak ditemukan |
409 | konflik dengan state saat ini: SKU duplikat, pesanan sudah dibayar, versi usang |
422 | format benar tetapi melanggar aturan bisnis (mis. jumlah melebihi stok) |
500 | bug/kegagalan tak terduga di server — jangan dipakai untuk kesalahan klien |
Respons pembuatan resource yang baik:
text
HTTP/1.1 201 Created
Location: /orders/42
Content-Type: application/json
{"id":42,"status":"NEW","total":185000}Garis antara 400 dan 422 tidak selalu tajam, dan tim berbeda memilih berbeda. Yang penting konsisten dan terdokumentasi. Pola umum: 400 untuk validasi sintaks/field (Spring sendiri mengembalikan 400 untuk Bean Validation yang gagal), 422 untuk aturan bisnis yang ditolak, dan 409 untuk benturan dengan state resource.
Idempotensi: PUT, PATCH, POST
PUT /products/7mengirim representasi lengkap; mengulanginya menghasilkan produk yang sama. Field yang tidak dikirim dianggap dikosongkan — itulah bedanya denganPATCH.PATCH /products/7mengubah sebagian. Format JSON Merge Patch ({"price": 90000}) praktis idempotent, tetapi operasi seperti "tambah stok 5" tidak — mengulanginya menambah lagi.POST /ordersmembuat resource baru setiap kali dipanggil. Retry setelah timeout bisa membuat pesanan atau penagihan ganda.
Untuk operasi POST yang berbahaya bila terulang — pembayaran, transfer — klien mengirim header Idempotency-Key berisi nilai unik (biasanya UUID) per niat transaksi. Server menyimpan hasil per kunci; request ulang dengan kunci yang sama mengembalikan hasil pertama tanpa mengeksekusi ulang. Header ini dipopulerkan Stripe dan sedang distandarkan lewat draf IETF. Simulasi logikanya dengan Java murni:
java
import java.util.HashMap;
import java.util.Map;
public class IdempotentPayments {
record ChargeRequest(String orderId, long amount) {}
record ChargeResult(String paymentId, String orderId, long amount, String status) {}
record StoredResponse(ChargeRequest request, ChargeResult result) {}
private final Map<String, StoredResponse> byIdempotencyKey = new HashMap<>();
private int sequence = 0;
private int gatewayCalls = 0;
ChargeResult charge(String idempotencyKey, ChargeRequest request) {
StoredResponse stored = byIdempotencyKey.get(idempotencyKey);
if (stored != null) {
if (!stored.request().equals(request)) {
// Kunci sama tapi isi berbeda: tolak (di HTTP biasanya 422 atau 409)
throw new IllegalStateException("Idempotency-Key dipakai untuk request lain: " + idempotencyKey);
}
return stored.result(); // ulangi jawaban lama, jangan tagih lagi
}
gatewayCalls++; // di sini uang benar-benar ditarik
ChargeResult result = new ChargeResult("PAY-" + (++sequence), request.orderId(), request.amount(), "SUCCEEDED");
byIdempotencyKey.put(idempotencyKey, new StoredResponse(request, result));
return result;
}
public static void main(String[] args) {
IdempotentPayments service = new IdempotentPayments();
ChargeRequest request = new ChargeRequest("ORD-7", 150_000);
System.out.println(service.charge("k-3f9a", request));
System.out.println(service.charge("k-3f9a", request)); // klien retry karena timeout
System.out.println("Penagihan ke gateway: " + service.gatewayCalls);
try {
service.charge("k-3f9a", new ChargeRequest("ORD-7", 999_000));
} catch (IllegalStateException e) {
System.out.println(e.getMessage());
}
}
}Output:
text
ChargeResult[paymentId=PAY-1, orderId=ORD-7, amount=150000, status=SUCCEEDED]
ChargeResult[paymentId=PAY-1, orderId=ORD-7, amount=150000, status=SUCCEEDED]
Penagihan ke gateway: 1
Idempotency-Key dipakai untuk request lain: k-3f9aDi production, penyimpanannya berupa tabel database atau Redis dengan masa kedaluwarsa, dan harus aman terhadap dua request bersamaan dengan kunci sama (misalnya lewat unique constraint) — topik transaksi di M08.
Pagination, filter, dan sort
Jangan pernah mengembalikan seluruh tabel. Dua pendekatan utama:
| Aspek | Offset (?page=2&size=20) | Cursor (?limit=20&after=aWQ6MTAy) |
|---|---|---|
| Lompat halaman | bisa langsung ke halaman N | hanya maju (dan mundur bila didukung) |
| Total halaman | mudah ditampilkan | biasanya tidak |
| Performa | melambat di offset besar | stabil karena memakai index |
| Data berubah | item bisa dobel/terlewat | stabil |
| Cocok untuk | panel admin, tabel kecil | feed, riwayat transaksi, API publik |
Cursor sebaiknya opaque — dikodekan, bukan ID mentah — agar kamu bebas mengubah isinya tanpa merusak klien:
java
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
public class CursorDemo {
record Order(long id, String status) {}
record Page(List<Order> items, String nextCursor) {}
static String encode(long lastId) {
return Base64.getUrlEncoder().withoutPadding()
.encodeToString(("id:" + lastId).getBytes(StandardCharsets.UTF_8));
}
static long decode(String cursor) {
String raw = new String(Base64.getUrlDecoder().decode(cursor), StandardCharsets.UTF_8);
return Long.parseLong(raw.substring("id:".length()));
}
// Data diurutkan berdasarkan id; cursor = id terakhir yang sudah dikirim
static Page findOrders(List<Order> all, String after, int limit) {
long lastId = after == null ? 0 : decode(after);
List<Order> items = new ArrayList<>();
for (Order order : all) {
if (order.id() > lastId && items.size() < limit) {
items.add(order);
}
}
boolean hasMore = !items.isEmpty() && items.getLast().id() < all.getLast().id();
return new Page(items, hasMore ? encode(items.getLast().id()) : null);
}
public static void main(String[] args) {
List<Order> all = List.of(new Order(101, "PAID"), new Order(102, "NEW"),
new Order(105, "SHIPPED"), new Order(108, "PAID"), new Order(110, "NEW"));
Page first = findOrders(all, null, 2);
System.out.println(first);
Page second = findOrders(all, first.nextCursor(), 2);
System.out.println(second);
Page third = findOrders(all, second.nextCursor(), 2);
System.out.println(third);
}
}Output:
text
Page[items=[Order[id=101, status=PAID], Order[id=102, status=NEW]], nextCursor=aWQ6MTAy]
Page[items=[Order[id=105, status=SHIPPED], Order[id=108, status=PAID]], nextCursor=aWQ6MTA4]
Page[items=[Order[id=110, status=NEW]], nextCursor=null]Di database, versi ini menjadi WHERE id > ? ORDER BY id LIMIT ? — dibahas di bab paginasi M08. Untuk filter dan sort, pakai query parameter yang eksplisit (?status=PAID&createdFrom=2026-10-01&sort=-createdAt), batasi size maksimum di server (misalnya 100), dan izinkan sort hanya pada kolom yang ber-index.
Versioning
Perubahan yang merusak klien (menghapus field, mengganti arti field) butuh versi baru. Opsi umum:
| Strategi | Contoh | Catatan |
|---|---|---|
| Path URL | /api/v1/orders | paling mudah terlihat dan di-cache; paling umum |
| Header | API-Version: 1.1 | URL tetap bersih; perlu disiplin klien |
| Media type | Accept: application/json;version=1.1 | jarang, tapi presisi |
Menambah field baru yang opsional bukan breaking change — klien yang baik mengabaikan field yang tidak dikenal.
Spring Framework 7 (dipakai Spring Boot 4) menyediakan dukungan API versioning bawaan. Strategi resolusinya dikonfigurasi sekali, lalu setiap mapping memakai atribut version:
yaml
# application.yml
spring:
mvc:
apiversion:
default: 1.0
use:
header: API-Versionjava
// Potongan — controller dibahas di bab berikutnya
@RestController
@RequestMapping("/orders")
public class OrderController {
@GetMapping(path = "/{id}", version = "1.0")
public OrderV1 getV1(@PathVariable long id) { /* ... */ }
@GetMapping(path = "/{id}", version = "1.1+") // 1.1 dan versi didukung di atasnya
public OrderV2 getV2(@PathVariable long id) { /* ... */ }
}Versi yang tidak didukung ditolak dengan 400. Konfigurasi yang sama bisa ditulis di Java lewat WebMvcConfigurer.configureApiVersioning(...), misalnya configurer.useRequestHeader("API-Version").
Format error: RFC 9457 Problem Details
Error yang konsisten memudahkan klien. Standar RFC 9457 (pengganti RFC 7807) mendefinisikan body application/problem+json:
json
{
"type": "https://api.toko.example/problems/insufficient-stock",
"title": "Stok tidak cukup",
"status": 422,
"detail": "Stok SKU KOPI-250 tersisa 3, diminta 5",
"instance": "/orders",
"sku": "KOPI-250",
"available": 3
}type, title, status, detail, dan instance adalah field standar; field tambahan (sku, available) boleh ditambahkan. Spring punya class ProblemDetail untuk ini — dipakai di bab error handling. Jangan pernah mengirim stack trace ke klien.
HATEOAS sekilas
HATEOAS menyertakan link aksi berikutnya di respons, misalnya "_links": {"cancel": {"href": "/orders/42/cancellation"}}, sehingga klien tidak perlu merangkai URL sendiri. Spring HATEOAS mendukungnya, tetapi di praktik kebanyakan API internal cukup memakai URL yang terdokumentasi di OpenAPI (M09). Cukup kenali konsepnya.
Contoh di dunia backend
Desain lengkap API pesanan toko online — tabel seperti ini biasanya ditulis dan direview sebelum satu baris controller dibuat:
| Method | URL | Fungsi | Sukses | Error umum |
|---|---|---|---|---|
GET | /api/v1/orders?status=&limit=&after= | daftar pesanan milik user | 200 + nextCursor | 400 parameter salah |
POST | /api/v1/orders | buat pesanan dari keranjang | 201 + Location | 400, 422 stok kurang |
GET | /api/v1/orders/{id} | detail pesanan | 200 | 404 |
GET | /api/v1/orders/{id}/items | item dalam pesanan | 200 | 404 |
PATCH | /api/v1/orders/{id} | ubah alamat kirim | 200 | 404, 409 sudah dikirim |
POST | /api/v1/orders/{id}/cancellation | batalkan pesanan | 200 status CANCELLED | 404, 409 sudah dikirim |
POST | /api/v1/orders/{id}/payments | bayar (wajib Idempotency-Key) | 201 | 409, 422 kunci dipakai ulang |
DELETE | /api/v1/orders/{id} | hapus draf pesanan | 204 | 404, 409 bukan draf |
Contoh body daftar pesanan:
json
{
"items": [{ "id": 105, "status": "PAID", "total": 185000, "createdAt": "2026-10-20T08:15:00Z" }],
"nextCursor": "aWQ6MTA1"
}Perhatikan konsistensinya: kata benda jamak, waktu dalam format ISO-8601 UTC, uang dalam satuan terkecil (rupiah tanpa desimal) sebagai angka bulat, dan pembatalan sebagai aksi POST — bukan DELETE, karena data pesanan tetap disimpan untuk audit.
Pertanyaan interview
1. Apa beda PUT dan PATCH, dan mana yang idempotent?
PUT mengganti seluruh representasi resource; field yang tidak dikirim dianggap dikosongkan. Mengirim PUT yang sama berulang kali menghasilkan kondisi yang sama, jadi idempotent. PATCH mengubah sebagian; idempotensinya tergantung operasinya — "set harga ke 90.000" idempotent, "tambah stok 5" tidak. Spesifikasi HTTP tidak menjamin PATCH idempotent.
2. Bagaimana mencegah penagihan ganda saat klien me-retry request pembayaran?
Pakai idempotency key: klien membuat kunci unik per niat pembayaran dan mengirimnya di header Idempotency-Key. Server menyimpan kunci beserta hash request dan hasilnya, idealnya dengan unique constraint agar dua request bersamaan tidak sama-sama diproses. Request ulang dengan kunci sama mengembalikan hasil tersimpan; kunci sama dengan isi berbeda ditolak.
3. Kapan memakai cursor pagination dibanding offset?
Cursor cocok untuk data besar dan terus bertambah (feed, riwayat transaksi) karena kueri memakai index (WHERE id > ?) dan hasilnya stabil walau ada data baru. Offset cocok untuk tabel kecil atau UI yang perlu lompat ke halaman tertentu dan menampilkan total halaman, tetapi melambat di offset besar dan bisa menampilkan item dobel.
Jebakan umum
- Kata kerja di URL (
/createOrder,/getAllProducts) — method HTTP sudah menyatakan aksinya. - Mengubah data lewat
GET— crawler, prefetch browser, atau cache bisa memicunya tanpa sengaja. - Selalu membalas
200dengan{"success": false}di body — monitoring, cache, dan klien HTTP jadi buta terhadap error. POSTpembayaran tanpa idempotency key — retry otomatis klien atau gateway menghasilkan penagihan ganda.- Offset pagination tanpa batas
size—?size=1000000menjadi cara mudah membuat server kehabisan memori. - Breaking change tanpa versi baru — mengganti nama field atau tipe data merusak aplikasi mobile yang belum di-update.
Ringkasan
- URL memodelkan resource: kata benda jamak, hierarki dangkal, filter/sort/pagination di query parameter.
GETaman;GET,PUT,DELETEidempotent;POSTtidak;PATCHtergantung operasinya.- Status code bermakna:
201+Location,204,400,404,409,422— konsisten di seluruh API. - Operasi berisiko seperti pembayaran memakai
Idempotency-Keyagar aman di-retry. - Cursor pagination stabil dan cepat untuk data besar; offset untuk tabel kecil/admin.
- Versioning lewat path atau header (Spring Framework 7: atribut
version); error memakai RFC 9457 Problem Details.