Desain REST API yang Baik

12 min readFundamental
RESTAPIDesignBackend

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:

ConstraintMakna SingkatContoh Penerapan
Client-ServerKlien dan server terpisah, masing-masing bisa berkembang sendiriFrontend diganti tanpa menyentuh backend
StatelessSetiap request berdiri sendiri; server tidak menyimpan sesi klienToken dikirim di tiap request, bukan disimpan di server
CacheableResponse menyatakan apakah bisa di-cacheHeader Cache-Control pada daftar data yang jarang berubah
Uniform InterfaceAntarmuka yang seragam dan konsistenSemua resource diakses dengan pola URL + method yang sama
Layered SystemServer 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

  1. Gunakan kata benda jamak untuk koleksi: /users, /orders, /products.
  2. Gunakan {id} untuk item tunggal: /users/{id}, /orders/{id}.
  3. Gunakan relasi sebagai sub-resource: /users/{id}/orders artinya "order milik user {id}".
  4. Hindari kata kerja di URL — kata kerja adalah tugas HTTP method.
URL BenarURL SalahAlasan
GET /usersGET /getUsers/getUsers memakai kata kerja
POST /ordersPOST /createOrderKata kerja create sudah diwakili POST
GET /users/{id}/ordersGET /getOrdersByUser?id=1Hierarki resource lebih jelas sebagai path
DELETE /users/{id}GET /deleteUser?id=1Aksi 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 paramMaknaContoh nilai
statusFilter berdasarkan kolomPAID, PENDING
sortUrutan; awali - untuk descending-date (terbaru dulu)
pageNomor 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

MethodAksi pada ResourceContohIdempoten
GETMembaca (list/detail)GET /orders atau GET /orders/{id}Ya
POSTMembuat resource baruPOST /ordersTidak
PUTMengganti resource seutuhnyaPUT /orders/{id}Ya
PATCHMengubah sebagian fieldPATCH /orders/{id}Tidak
DELETEMenghapus resourceDELETE /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:

KelompokKapan DigunakanContoh Konkret
2xxOperasi sukses200 list ditemukan; 201 resource berhasil dibuat; 204 hapus tanpa isi
4xxKesalahan di sisi klien400 body tidak valid; 401 belum login; 403 tidak berhak; 404 resource tak ada
5xxKesalahan di sisi server500 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 dengan 5xx — dan catat lognya. Menjawab 200 untuk 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" }
}
AspekReturn LangsungEnvelope
KesederhanaanStruktur sejelas datanyaAda lapisan ekstra yang harus di-unwrap klien
MetadataTidak ada tempat untuk info tambahanAda slot message, success, timestamp
Error handlingPakai status code + body error terpisahSemua respons satu bentuk, mudah di-parse
Developer experienceLebih ringkas saat 200Konsisten 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 (misal code == "TOKEN_EXPIRED" → redirect ke halaman login), sedangkan message hanya 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:

AspekOffset (page/size atau offset/limit)Cursor (berbasis token)
Cara kerjaLewati N baris pertamaLanjut dari penanda (cursor) data terakhir
ImplementasiPaling sederhana (LIMIT x OFFSET y)Butuh kolom unik terurut (misal id atau created_at)
KinerjaLambat di halaman dalam (OFFSET besar)Stabil berapa pun halamannya
Data berubah di tengahHalaman bisa bergeser/duplikatKonsisten 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/size saat 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:

PendekatanContohKelebihanKekurangan
Path versioningGET /api/v1/ordersPaling jelas, mudah di-cache & di-debugVersi "menempel" di URL selamanya
Header versioningAccept: application/vnd.api+json;version=1URL tetap bersihKlien harus memikirkan header dengan benar
Query versioningGET /orders?api-version=1SederhanaMudah 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:

  1. Konsistensi nama field: pilih satu konvensi. JSON umumnya memakai camelCase (totalPrice, bukan total_price), sementara database boleh tetap snake_case.
  2. Timezone dan format waktu: selalu gunakan UTC dan format ISO 8601 (2026-08-05T09:30:00Z), biarkan klien yang menyesuaikan ke zona waktu lokal.
  3. Nama yang deskriptif: sort=-createdAt, bukan order=1.
  4. 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.

EndpointMethodFungsiStatus SuksesError umum
/api/v1/orders?status=&sort=&page=GETList order (dengan pagination)200400 filter tidak dikenal
/api/v1/orders/{id}GETDetail order + items200404 order tidak ada
/api/v1/ordersPOSTBuat order baru201400 validasi gagal, 409 stok kurang
/api/v1/orders/{id}/cancelPOSTBatalkan order200409 status bukan PENDING
/api/v1/users/{userId}/ordersGETRiwayat order milik user200404 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 aksi POST dengan sub-path.
  • Envelope dipakai konsisten sehingga success, message, dan data selalu ada di semua respons sukses.
  • Error selalu lewat ApiError dari GlobalExceptionHandler, dengan code yang bisa diprogram (misal INVALID_ORDER_STATE untuk kasus 409).
  • Pagination menyertakan metadata totalElements/totalPages di 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

← Back to technical articles