Membangun RESTful API Sederhana dengan Go: Tutorial Step-by-Step
Pendahuluan
Go (Golang) adalah bahasa yang lahir dari kebutuhan nyata: membangun sistem backend yang cepat, efisien, dan mudah di-maintain. Bayangkan kamu ingin membangun layanan backend seperti yang dipakai aplikasi e-commerce atau platform marketplace lokal — REST API adalah jembatan yang menghubungkan frontend dengan logika bisnis di baliknya.
Artikel ini akan membawamu membangun RESTful API dari nol menggunakan Go standard library ditambah router ringan chi (github.com/go-chi/chi). Kamu akan belajar cara membuat endpoint CRUD lengkap untuk resource “produk” — mulai dari setup proyek hingga handler yang siap diuji dengan Postman atau curl.
Sebelum mulai, pastikan kamu sudah memahami konsep dasar Go. Jika belum, artikel-artikel Go sebelumnya di KamusNgoding bisa jadi titik awal yang baik.
Persiapan Lingkungan dan Proyek Go
Pastikan Go versi 1.21+ sudah terinstal. Buat proyek baru:
# Buat folder project dan masuk ke dalamnya
mkdir -p go-rest-api && cd go-rest-api
# Inisialisasi module Go
go mod init github.com/kamu/go-rest-api
# Tambahkan dependency router Chi versi 5
go get github.com/go-chi/chi/v5
Verifikasi setup dengan membuat main.go sederhana:
package main
import (
"fmt"
"net/http"
"github.com/go-chi/chi/v5"
)
func main() {
r := chi.NewRouter()
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "Halo dari Go REST API")
})
fmt.Println("Server berjalan di http://localhost:3000")
http.ListenAndServe(":3000", r)
}
go run main.go
# Output: Server berjalan di http://localhost:3000
Untuk tutorial ini, semua kode akan ditempatkan dalam satu file main.go. Pada proyek nyata, kamu bisa memisahkannya ke package model, store, dan handler di folder terpisah — masing-masing menggunakan deklarasi package model, package store, dan seterusnya (bukan package main).
Mendefinisikan Model Data
Struct Product adalah representasi data yang mengalir antara client dan server. Tag json:"..." menentukan nama field saat data dikonversi ke/dari JSON:
package main
import (
"encoding/json"
"fmt"
)
// Product merepresentasikan data produk dalam sistem.
type Product struct {
ID int `json:"id"`
Nama string `json:"nama"`
Harga float64 `json:"harga"`
Stok int `json:"stok"`
Kategori string `json:"kategori"`
}
func main() {
produk := Product{
ID: 1,
Nama: "Laptop",
Harga: 7500000,
Stok: 12,
Kategori: "Elektronik",
}
dataJSON, err := json.MarshalIndent(produk, "", " ")
if err != nil {
fmt.Println("Gagal mengubah data ke JSON:", err)
return
}
fmt.Println("Data Produk:")
fmt.Println(string(dataJSON))
}
// Output yang diharapkan:
// Data Produk:
// {
// "id": 1,
// "nama": "Laptop",
// "harga": 7500000,
// "stok": 12,
// "kategori": "Elektronik"
// }
Struktur data yang baik adalah fondasi API yang solid. Jika kamu baru mengenal konsep ini, Pengenalan Struktur Data dan Algoritma untuk Pemula bisa membantu membangun intuisimu.
Membangun In-Memory Store
Store bertanggung jawab menyimpan dan mengelola data. Untuk tutorial ini kita gunakan map di memori — tanpa database. sync.Mutex digunakan agar akses concurrent tetap aman:
package main
import (
"errors"
"fmt"
"sync"
)
type Product struct {
ID int `json:"id"`
Nama string `json:"nama"`
Harga float64 `json:"harga"`
Stok int `json:"stok"`
Kategori string `json:"kategori"`
}
// ProductStore menyimpan data produk secara thread-safe.
type ProductStore struct {
mu sync.Mutex
products map[int]Product
nextID int
}
// NewProductStore membuat store baru dengan dua data contoh.
func NewProductStore() *ProductStore {
return &ProductStore{
products: map[int]Product{
1: {ID: 1, Nama: "Laptop", Harga: 12000000, Stok: 5, Kategori: "Elektronik"},
2: {ID: 2, Nama: "Mouse", Harga: 150000, Stok: 20, Kategori: "Aksesori"},
},
nextID: 3,
}
}
func (s *ProductStore) GetAll() []Product {
s.mu.Lock()
defer s.mu.Unlock()
list := make([]Product, 0, len(s.products))
for _, p := range s.products {
list = append(list, p)
}
return list
}
func (s *ProductStore) GetByID(id int) (Product, error) {
s.mu.Lock()
defer s.mu.Unlock()
p, ok := s.products[id]
if !ok {
return Product{}, errors.New("produk tidak ditemukan")
}
return p, nil
}
func (s *ProductStore) Create(p Product) Product {
s.mu.Lock()
defer s.mu.Unlock()
// ID dibuat otomatis oleh store, bukan dari input client.
p.ID = s.nextID
s.products[p.ID] = p
s.nextID++
return p
}
func (s *ProductStore) Update(id int, p Product) (Product, error) {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.products[id]; !ok {
return Product{}, errors.New("produk tidak ditemukan")
}
// Pastikan ID tetap konsisten dengan data yang di-update.
p.ID = id
s.products[id] = p
return p, nil
}
func (s *ProductStore) Delete(id int) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.products[id]; !ok {
return errors.New("produk tidak ditemukan")
}
delete(s.products, id)
return nil
}
func main() {
store := NewProductStore()
// Tambah produk baru
p := store.Create(Product{Nama: "Keyboard", Harga: 450000, Stok: 10, Kategori: "Aksesori"})
fmt.Println("Produk baru:", p)
// Ambil semua produk
fmt.Println("Semua produk:", store.GetAll())
// Update produk ID 2
updated, _ := store.Update(2, Product{Nama: "Mouse Wireless", Harga: 200000, Stok: 15, Kategori: "Aksesori"})
fmt.Println("Diupdate:", updated)
// Hapus produk ID 1
_ = store.Delete(1)
fmt.Println("Setelah hapus ID 1:", store.GetAll())
}
// Output yang diharapkan:
// Produk baru: {3 Keyboard 450000 10 Aksesori}
// Semua produk: [{1 Laptop 12000000 5 Elektronik} {2 Mouse 150000 20 Aksesori} {3 Keyboard 450000 10 Aksesori}]
// Diupdate: {2 Mouse Wireless 200000 15 Aksesori}
// Setelah hapus ID 1: [{2 Mouse Wireless 200000 15 Aksesori} {3 Keyboard 450000 10 Aksesori}]
Penggunaan sync.Mutex mirip dengan konsep kontrak antar komponen yang kamu temui saat belajar Interface dan Type Alias di TypeScript — Go pun mendorong batas yang jelas antara komponen.
Implementasi CRUD Lengkap dengan Routing Chi
Sekarang kita gabungkan model, store, handler, dan routing menjadi satu main.go yang siap dijalankan:
package main
import (
"encoding/json"
"errors"
"log"
"net/http"
"strconv"
"sync"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
// ===== Model =====
type Product struct {
ID int `json:"id"`
Nama string `json:"nama"`
Harga float64 `json:"harga"`
Stok int `json:"stok"`
Kategori string `json:"kategori"`
}
// ===== Store =====
type ProductStore struct {
mu sync.Mutex
products map[int]Product
nextID int
}
func NewProductStore() *ProductStore {
return &ProductStore{
products: map[int]Product{
1: {ID: 1, Nama: "Laptop", Harga: 12000000, Stok: 5, Kategori: "Elektronik"},
2: {ID: 2, Nama: "Mouse", Harga: 150000, Stok: 20, Kategori: "Aksesori"},
},
nextID: 3,
}
}
func (s *ProductStore) GetAll() []Product {
s.mu.Lock()
defer s.mu.Unlock()
list := make([]Product, 0, len(s.products))
for _, p := range s.products {
list = append(list, p)
}
return list
}
func (s *ProductStore) GetByID(id int) (Product, error) {
s.mu.Lock()
defer s.mu.Unlock()
p, ok := s.products[id]
if !ok {
return Product{}, errors.New("produk tidak ditemukan")
}
return p, nil
}
func (s *ProductStore) Create(p Product) Product {
s.mu.Lock()
defer s.mu.Unlock()
p.ID = s.nextID
s.products[p.ID] = p
s.nextID++
return p
}
func (s *ProductStore) Update(id int, p Product) (Product, error) {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.products[id]; !ok {
return Product{}, errors.New("produk tidak ditemukan")
}
p.ID = id
s.products[id] = p
return p, nil
}
func (s *ProductStore) Delete(id int) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.products[id]; !ok {
return errors.New("produk tidak ditemukan")
}
delete(s.products, id)
return nil
}
// ===== Handler =====
type ProductHandler struct {
store *ProductStore
}
func NewProductHandler(store *ProductStore) *ProductHandler {
return &ProductHandler{store: store}
}
// writeJSON mengirim response dalam format JSON.
func writeJSON(w http.ResponseWriter, status int, data any) {
// Header Content-Type harus ditulis sebelum WriteHeader.
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(data)
}
// writeError mengirim pesan error dalam format JSON.
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
// parseID mengambil parameter ID dari URL dan mengonversinya ke integer.
func parseID(r *http.Request) (int, error) {
return strconv.Atoi(chi.URLParam(r, "id"))
}
// validateProduct memeriksa apakah data produk memenuhi syarat minimal.
func validateProduct(p Product) error {
if p.Nama == "" || p.Harga <= 0 {
return errors.New("nama dan harga wajib diisi")
}
return nil
}
// GET /api/v1/products — mengembalikan semua produk
func (h *ProductHandler) GetAll(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, h.store.GetAll())
}
// GET /api/v1/products/{id} — mengembalikan produk berdasarkan ID
func (h *ProductHandler) GetByID(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r)
if err != nil {
writeError(w, http.StatusBadRequest, "ID tidak valid")
return
}
product, err := h.store.GetByID(id)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, product)
}
// POST /api/v1/products — menambahkan produk baru
func (h *ProductHandler) Create(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
var p Product
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
writeError(w, http.StatusBadRequest, "body tidak valid")
return
}
if err := validateProduct(p); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusCreated, h.store.Create(p))
}
// PUT /api/v1/products/{id} — mengubah data produk
func (h *ProductHandler) Update(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r)
if err != nil {
writeError(w, http.StatusBadRequest, "ID tidak valid")
return
}
defer r.Body.Close()
var p Product
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
writeError(w, http.StatusBadRequest, "body tidak valid")
return
}
if err := validateProduct(p); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
updated, err := h.store.Update(id, p)
if err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, updated)
}
// DELETE /api/v1/products/{id} — menghapus produk
func (h *ProductHandler) Delete(w http.ResponseWriter, r *http.Request) {
id, err := parseID(r)
if err != nil {
writeError(w, http.StatusBadRequest, "ID tidak valid")
return
}
if err := h.store.Delete(id); err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"pesan": "produk berhasil dihapus"})
}
// ===== Main =====
func main() {
store := NewProductStore()
handler := NewProductHandler(store)
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
// Kelompokkan semua endpoint produk di bawah /api/v1/products.
r.Route("/api/v1/products", func(r chi.Router) {
r.Get("/", handler.GetAll)
r.Post("/", handler.Create)
r.Get("/{id}", handler.GetByID)
r.Put("/{id}", handler.Update)
r.Delete("/{id}", handler.Delete)
})
log.Println("Server berjalan di http://localhost:8080")
log.Fatal(http.ListenAndServe(":8080", r))
}
// Output yang diharapkan saat server dijalankan:
// 2026/04/05 10:00:00 Server berjalan di http://localhost:8080
// Setiap request akan tercatat oleh middleware.Logger, contoh:
// "GET /api/v1/products" 200 in 123µs
Jalankan server:
go run main.go
# Output: Server berjalan di http://localhost:8080
Menguji API dengan curl
Dengan server berjalan, buka terminal baru dan jalankan perintah berikut secara berurutan:
#!/usr/bin/env bash
BASE_URL="http://localhost:8080/api/v1/products"
# GET - Ambil semua produk (data awal dari store)
curl -s "$BASE_URL"
echo
# POST - Tambah produk baru
curl -s -X POST "$BASE_URL" \
-H "Content-Type: application/json" \
-d '{"nama":"Keyboard Mekanikal","harga":650000,"stok":15,"kategori":"Aksesori"}'
echo
# GET - Ambil produk dengan ID 1
curl -s "$BASE_URL/1"
echo
# PUT - Update produk ID 2
curl -s -X PUT "$BASE_URL/2" \
-H "Content-Type: application/json" \
-d '{"nama":"Mouse Wireless","harga":200000,"stok":25,"kategori":"Aksesori"}'
echo
# DELETE - Hapus produk ID 1
curl -s -X DELETE "$BASE_URL/1"
echo
# GET - Verifikasi daftar produk setelah penghapusan
curl -s "$BASE_URL"
echo
# Output yang diharapkan:
# [{"id":1,"nama":"Laptop",...},{"id":2,"nama":"Mouse",...}]
# {"id":3,"nama":"Keyboard Mekanikal","harga":650000,"stok":15,"kategori":"Aksesori"}
# {"id":1,"nama":"Laptop","harga":12000000,"stok":5,"kategori":"Elektronik"}
# {"id":2,"nama":"Mouse Wireless","harga":200000,"stok":25,"kategori":"Aksesori"}
# {"pesan":"produk berhasil dihapus"}
# [{"id":2,"nama":"Mouse Wireless",...},{"id":3,"nama":"Keyboard Mekanikal",...}]
Contoh response error ketika nama dikosongkan:
curl -s -X POST "$BASE_URL" \
-H "Content-Type: application/json" \
-d '{"nama":"","harga":0}'
# Output:
# {"error":"nama dan harga wajib diisi"}
Jika kamu ingin membangun katalog produk layaknya platform marketplace lokal, pola CRUD ini adalah intinya. Setiap tombol “Tambah ke Keranjang” atau “Update Stok” di panel seller pada dasarnya memanggil endpoint seperti yang baru kita buat.
Menguji Handler Secara Otomatis
Go memiliki package net/http/httptest bawaan yang memungkinkan pengujian handler tanpa menjalankan server sungguhan. Simpan kode berikut sebagai main_test.go:
package main
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestGetAll_ReturnsOK(t *testing.T) {
store := NewProductStore()
handler := NewProductHandler(store)
req := httptest.NewRequest(http.MethodGet, "/api/v1/products", nil)
w := httptest.NewRecorder()
handler.GetAll(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
}
func TestCreate_ValidProduct(t *testing.T) {
store := NewProductStore()
handler := NewProductHandler(store)
body := `{"nama":"Headphone","harga":300000,"stok":10,"kategori":"Audio"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/products",
strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.Create(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d", w.Code)
}
}
func TestCreate_InvalidProduct(t *testing.T) {
store := NewProductStore()
handler := NewProductHandler(store)
// Nama kosong dan harga nol harus menghasilkan error 400.
body := `{"nama":"","harga":0}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/products",
strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
handler.Create(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
}
func TestDelete_NotFound(t *testing.T) {
store := NewProductStore()
handler := NewProductHandler(store)
// ID 999 tidak ada di store, harus menghasilkan 404.
req := httptest.NewRequest(http.MethodDelete, "/api/v1/products/999", nil)
// Simulasi URL param chi
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", "999")
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
w := httptest.NewRecorder()
handler.Delete(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", w.Code)
}
}
Tambahkan import yang diperlukan di bagian atas main_test.go:
package main
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/go-chi/chi/v5"
)
Jalankan semua test:
go test ./...
# Output yang diharapkan:
# ok github.com/kamu/go-rest-api
Pertanyaan yang Sering Diajukan
Apa perbedaan antara chi dan net/http standar Go?
net/http bawaan Go sudah sangat capable, tetapi tidak punya fitur routing parameter seperti /{id}. Chi menambahkan routing berbasis parameter, middleware chaining, dan route grouping tanpa overhead yang berat. Chi tetap compatible 100% dengan http.Handler standar, sehingga kamu tidak terkunci pada satu library.
Mengapa tutorial menggunakan satu file, bukan package terpisah seperti model, store, dan handler?
Satu file memudahkan fokus pada konsep inti tanpa mengurus mekanisme import antar-package. Pada proyek nyata, kamu akan memisahkan kode ke folder model/, store/, dan handler/ — masing-masing menggunakan deklarasi package yang sesuai (misal package model), bukan package main. Hanya main.go di root yang boleh menggunakan package main dan memiliki fungsi main.
Mengapa menggunakan sync.Mutex di store?
API web menerima request secara concurrent (bersamaan). Tanpa mutex, dua goroutine yang menulis ke map secara bersamaan akan menyebabkan race condition yang menghasilkan data korup atau panic. sync.Mutex memastikan hanya satu goroutine yang bisa mengakses data pada satu waktu, sehingga operasi baca dan tulis tetap aman meskipun ada ratusan request masuk sekaligus.
Apa yang dimaksud dengan versioning /api/v1/?
Versioning di URL memungkinkan kamu merilis perubahan besar (breaking changes) tanpa merusak klien lama. Jika suatu saat kamu mengubah struktur response, cukup buat /api/v2/ dan biarkan /api/v1/ tetap berjalan. Ini praktik standar yang diikuti hampir semua API publik besar.
Bagaimana cara mengganti in-memory store dengan database asli?
Cukup buat implementasi baru dari fungsi-fungsi yang sama (GetAll, GetByID, Create, Update, Delete) menggunakan database/sql atau ORM seperti gorm. Handler tidak perlu diubah sama sekali karena hanya bergantung pada perilaku store, bukan implementasi internalnya. Ini adalah keunggulan dari pola desain yang kita gunakan — pemisahan concern yang bersih antara lapisan data dan lapisan HTTP.
Kesimpulan
Kamu baru saja membangun RESTful API lengkap dengan Go — mulai dari model data, in-memory store dengan mutex, routing chi, hingga handler CRUD yang clean dan testable. Pola yang kita pakai (model → store → handler) adalah arsitektur yang mudah dikembangkan: cukup ganti store dengan implementasi database tanpa menyentuh handler.
Langkah lanjutan yang bisa kamu eksplorasi:
- Koneksi ke PostgreSQL dengan
pgxatausqlc - Autentikasi JWT menggunakan
golang-jwt/jwt - Dokumentasi API otomatis dengan Swagger/OpenAPI
- Deploy ke container Docker dan Kubernetes
Go dirancang untuk membangun sistem yang andal dan efisien — dan sekarang kamu sudah punya fondasi yang kokoh untuk melangkah lebih jauh.