Progress belajar
Modul 16 dari 73
0% 0/73 modul selesai
Setelah selesai, tandai modul ini agar progres kursus tetap rapi.
Progress disimpan lokal di browser ini.
Desain Request dan Response API
yang Konsisten
Modul ini menetapkan envelope JSON kanonik proyek skincare, package internal/httpx yang dipakai ulang sampai modul peta API final.
Kenapa Kontrak API Harus Konsisten?
Satu bentuk JSON yang sama di seluruh endpoint
Di React atau mobile client, API yang konsisten terasa seperti komponen dengan props yang jelas: gampang dipakai, gampang dites, dan jarang memaksa kamu menebak.
Di modul sebelumnya kita sudah membuat handler net/http lalu merapikan routing dengan chi. Pada modul routing, response sengaja kita tulis lewat helper sederhana writeJSON dan writeError yang menghasilkan {"error":"pesan"}. Sekarang fokusnya bergeser dari “route apa yang dipanggil” ke “bentuk data apa yang selalu dikirim dan diterima”. Endpoint katalog, cart, checkout, dan admin produk harus memakai pola response yang sama, sehingga frontend cukup menulis satu lapis data fetching dan satu lapis error handling.
Kalau di TypeScript kamu membuat CreateProductPayload dan ProductViewModel, di Go kita membuat struct DTO dengan JSON tag, supaya nama field di JSON tetap stabil walau nama field Go memakai PascalCase yang idiomatik.
Struct yang khusus dipakai di batas masuk dan keluar API, bukan untuk menampung semua aturan domain. Ia adalah “bentuk kabel” data, bukan model bisnis.
Modul ini adalah modul kontrak. Apa yang kita putuskan di sini, mulai dari nama field data, meta, error sampai tipe Meta dan FieldError, akan dipakai ulang oleh modul middleware, validasi, autentikasi, dan peta API final. Jadi anggap ini sebagai menulis konstitusi kecil untuk seluruh API skincare.
Frontend lebih tenang
Sukses dan error selalu berpola sama, jadi data fetching dan error boundary cukup ditulis sekali.
Backend lebih bebas
Domain model boleh berubah tanpa langsung merusak kontrak JSON publik yang dipakai client.
Testing lebih jelas
Handler test bisa assert data, meta, dan error tanpa menebak format khusus tiap endpoint.
Bentuk Envelope yang Kita Sepakati
Empat bentuk JSON: sukses tunggal, sukses list, error, dan validation
Envelope adalah amplop yang membungkus payload. Isinya berbeda tiap endpoint, tetapi amplopnya selalu sama, sehingga client tahu di mana mencari data dan di mana mencari error.
Kita sepakati empat bentuk untuk seluruh proyek skincare. Hafalkan keempatnya, karena ini kontrak yang dipakai dari modul ini sampai modul peta API final.
Sukses tunggal (GET /v1/products/{id}){ "data": { "id": 101, "name": "Hydrating Toner", "category": "toner", "price": 129000, "stock": 42, "status": "active" } }
Sukses list (GET /v1/products?page=1){ "data": [ { "id": 101, "name": "Hydrating Toner", "price": 129000 }, { "id": 102, "name": "Niacinamide Serum", "price": 189000 } ], "meta": { "page": 1, "per_page": 20, "total": 135, "total_pages": 7 } }
Error biasa (404 Not Found){ "error": { "code": "product_not_found", "message": "Produk tidak ditemukan." } }
Error validasi (422 Unprocessable Entity){ "error": { "code": "validation_error", "message": "Validasi gagal", "fields": [ { "field": "price", "message": "harga harus lebih dari 0" } ] } }
data untuk payload utama, meta untuk informasi pendamping seperti pagination, error untuk semua kegagalan yang bisa dipahami client. Sukses tidak pernah membawa error, dan error tidak pernah membawa data.
Di banyak API Laravel atau Express, list dikirim sebagai array JSON polos [...]. Masalahnya, begitu kamu butuh menambah pagination, array telanjang tidak punya tempat untuk meta tanpa mengubah bentuk. Amplop {"data": [...], "meta": {...}} sejak awal menyediakan ruang itu, jadi kontrak tidak pecah belakangan.
Inilah peta endpoint yang akan memakai envelope ini sepanjang proyek.
/v1/products List produk: data array plus meta pagination /v1/products/{id} Detail produk: data object tunggal /v1/admin/products Create produk: 201 dengan data object, atau 422 validation_error /v1/cart/items Tambah item cart: error validasi bila quantity atau product_id tidak valid Request DTO dengan JSON Tags
Gerbang masuk: bentuk JSON yang boleh dikirim client
Request DTO mendeskripsikan JSON yang boleh client kirim ke API. Ia tipis, eksplisit, dan sengaja terpisah dari model domain.
Di Go, field struct yang ingin diakses package lain harus exported, artinya diawali huruf kapital. JSON API biasanya memakai snake_case atau nama domain seperti price. JSON tag adalah jembatan antara PriceRupiah di Go dan price di JSON. Package standard library encoding/json menyediakan NewDecoder, Decode, NewEncoder, dan Encode untuk proses ini.
type CreateProductPayloadhanya hidup saat compile, lalu hilang di runtime JavaScript.- Validasi runtime butuh library seperti Zod atau logic manual.
- Field opsional ditandai
?, danundefinedmembedakan “tidak dikirim” dari “dikirim null”.
structtetap nyata saat runtime, dipakaiencoding/jsonuntuk decode body.- JSON tag menjaga nama field API tanpa mengorbankan naming idiomatik Go.
- Field opsional dipakai sebagai pointer agar bisa membedakan “tidak dikirim” dari “dikirim nol”.
internal/product/dto.gopackage product // CreateProductRequest adalah bentuk JSON saat admin membuat produk baru. type CreateProductRequest struct { Name string `json:"name"` Slug string `json:"slug"` Category string `json:"category"` PriceRupiah int64 `json:"price"` Stock int `json:"stock"` Description string `json:"description"` } // AddCartItemRequest dipakai customer untuk menambah item ke keranjang. type AddCartItemRequest struct { ProductID int64 `json:"product_id"` Quantity int `json:"quantity"` }
Rupiah tidak punya pecahan sen yang dipakai sehari-hari, jadi PriceRupiah bertipe int64 dengan JSON tag price. Angka 129000 berarti Rp 129.000. Hindari float64 untuk uang agar tidak ada galat pembulatan; aturan ini konsisten di seluruh proyek.
Untuk endpoint update parsial (PATCH), kita butuh membedakan “field ini sengaja dikosongkan” dari “field ini tidak disebut sama sekali”. Di sinilah pointer berguna.
internal/product/dto.go// UpdateProductRequest dipakai PATCH /v1/admin/products/{id}. // Pointer membuat kita bisa membedakan field absent (nil) dari field nol. type UpdateProductRequest struct { Name *string `json:"name"` Category *string `json:"category"` PriceRupiah *int64 `json:"price"` Stock *int `json:"stock"` Status *string `json:"status"` }
Di JavaScript, body.price === undefined berarti client tidak mengirim harga, sedangkan body.price === 0 berarti client mengirim nol. Di Go, *int64 melakukan hal yang sama: req.PriceRupiah == nil artinya field absent, sedangkan *req.PriceRupiah == 0 artinya client benar-benar mengirim nol. Tanpa pointer, kamu tidak bisa membedakan keduanya, karena zero value int64 juga 0.
CreateProductRequest adalah bentuk input API, bukan representasi bisnis final. Service layer sebaiknya menerima input command yang lebih dekat ke use case, bukan DTO HTTP mentah. Pemisahan ini menjaga handler tetap tipis dan domain tetap bersih.
Decode Body dengan Aman
MaxBytesReader, DisallowUnknownFields, dan satu error JSON yang stabil
Sebelum membahas response, kita rapikan dulu cara membaca request. Decode body adalah titik masuk paling rawan, jadi kita bungkus dalam satu helper yang konsisten.
Ada tiga pertahanan kecil yang hampir selalu kita pasang saat membaca JSON dari client: batasi ukuran body, tolak field tak dikenal, dan kembalikan satu error stabil saat JSON rusak.
MaxBytesReader
Membatasi body agar client nakal tidak bisa mengirim payload raksasa dan menghabiskan memori server.
DisallowUnknownFields
Menolak field JSON yang tidak ada di struct, sehingga typo seperti pirce ketahuan, bukan diam-diam diabaikan.
Satu error JSON
Apa pun bentuk kerusakan, client menerima invalid_json yang sama, bukan pesan internal yang bocor.
Kita taruh helper decode di package httpx agar semua handler memakainya tanpa mengulang boilerplate.
internal/httpx/decode.gopackage httpx import ( "encoding/json" "errors" "io" "net/http" ) // maxBodyBytes membatasi ukuran request body menjadi 1 MB. const maxBodyBytes = 1 << 20 // ErrInvalidJSON adalah satu-satunya error decode yang kita ekspos ke handler. // Detail teknis (syntax error, EOF, tipe salah) sengaja tidak dibocorkan. var ErrInvalidJSON = errors.New("invalid json") // DecodeJSON membaca body JSON ke dst dengan tiga pertahanan: // batas ukuran, tolak field tak dikenal, dan tolak body kosong atau ganda. func DecodeJSON(w http.ResponseWriter, r *http.Request, dst any) error { r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes) dec := json.NewDecoder(r.Body) dec.DisallowUnknownFields() if err := dec.Decode(dst); err != nil { return ErrInvalidJSON } // Pastikan tidak ada JSON kedua menempel setelah object pertama. if err := dec.Decode(&struct{}{}); err != io.EOF { return ErrInvalidJSON } return nil }
dec.Decode(&struct{}{}) memastikan body hanya berisi satu nilai JSON. Tanpa cek ini, body seperti {"name":"A"}{"name":"B"} akan lolos dengan hanya object pertama terbaca. Kita ingin io.EOF, yang berarti “tidak ada apa-apa lagi setelahnya”.
Di Express, middleware express.json() mengisi req.body secara ajaib sebelum handler jalan, dan limit ukuran diatur di satu tempat global. Di Go, decode terlihat eksplisit di awal handler lewat httpx.DecodeJSON(w, r, &req). Lebih banyak satu baris, tetapi alurnya jelas dan tidak ada middleware tersembunyi yang mengubah body.
Pemakaian di handler menjadi ringkas, dan error JSON selalu dipetakan ke satu kode stabil.
internal/product/handler.govar req CreateProductRequest if err := httpx.DecodeJSON(w, r, &req); err != nil { httpx.Error(w, http.StatusBadRequest, "invalid_json", "Body JSON tidak valid.") return }
Response DTO Terpisah dari Domain
Tampilkan hanya field yang aman, jangan bocorkan isi database
Response DTO adalah bentuk data yang sengaja kita tampilkan ke client, bukan seluruh isi domain atau baris database.
Domain model sering punya field internal seperti CostRupiah, SupplierID, DeletedAt, atau, paling berbahaya, PasswordHash di model user. Field seperti itu tidak boleh bocor ke client. Karena itu response dipetakan eksplisit dari domain ke DTO, mirip API Resource di Laravel atau serialization DTO di NestJS, tetapi di Go biasanya cukup struct plus satu fungsi mapper kecil.
internal/product/model.gopackage product import "time" // Product adalah model domain. Tidak semua field di sini boleh tampil di API. type Product struct { ID int64 Name string Slug string Category string PriceRupiah int64 CostRupiah int64 // harga modal, internal, JANGAN bocor ke client SupplierID int64 // internal Stock int Description string Status string CreatedAt time.Time DeletedAt *time.Time // soft delete, internal }
internal/product/dto.gopackage product // ProductResponse hanya memuat field yang aman dan stabil untuk client. type ProductResponse struct { ID int64 `json:"id"` Name string `json:"name"` Slug string `json:"slug"` Category string `json:"category"` PriceRupiah int64 `json:"price"` Stock int `json:"stock"` Description string `json:"description,omitempty"` Status string `json:"status"` CreatedAt string `json:"created_at"` }
internal/product/mapper.gopackage product import "time" // NewProductResponse memetakan domain Product ke ProductResponse. // Hanya field aman yang ikut; CostRupiah, SupplierID, DeletedAt ditinggalkan. func NewProductResponse(p Product) ProductResponse { return ProductResponse{ ID: p.ID, Name: p.Name, Slug: p.Slug, Category: p.Category, PriceRupiah: p.PriceRupiah, Stock: p.Stock, Description: p.Description, Status: p.Status, CreatedAt: p.CreatedAt.UTC().Format(time.RFC3339), } } // NewProductResponseList memetakan slice domain menjadi slice DTO. func NewProductResponseList(items []Product) []ProductResponse { out := make([]ProductResponse, 0, len(items)) for _, p := range items { out = append(out, NewProductResponse(p)) } return out }
Di Laravel, ProductResource memilih dan memformat field sebelum dikirim. Di NestJS, class-transformer dengan @Exclude() menyembunyikan field sensitif. Di Go, NewProductResponse mengambil peran yang sama secara manual dan eksplisit: tidak ada field yang lolos kecuali kamu menulisnya. Kelebihannya, tidak ada “kebocoran ajaib” karena lupa memasang dekorator.
json.NewEncoder(w).Encode(product) akan men-serialize SEMUA field exported, termasuk yang internal. Untuk model User, ini berarti PasswordHash bisa bocor ke client. Selalu lewati mapper menuju response DTO. Untuk lapis pertahanan tambahan, beri field sensitif tag json:"-" di model agar tidak pernah ikut diserialisasi.
Package httpx: Satu Tempat untuk Semua Response
JSON, Data, List, Error, ValidationFailed
Daripada mengulang set header, status, dan encode di setiap handler, kita pusatkan semuanya di satu package kecil bernama httpx. Inilah inti modul ini.
Package internal/httpx adalah kontrak teknis envelope. Lima helper publiknya menutup semua kebutuhan response API skincare. Mari bangun dari bawah ke atas.
internal/httpx/response.gopackage httpx import ( "encoding/json" "log/slog" "net/http" ) // JSON adalah primitif terendah: set header, tulis status, encode payload. // Semua helper lain memanggil JSON di akhir. func JSON(w http.ResponseWriter, status int, payload any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(status) if err := json.NewEncoder(w).Encode(payload); err != nil { // Status sudah terkirim, jadi kita hanya bisa mencatat, bukan membalas. slog.Error("encode response", "error", err) } } // dataEnvelope membungkus payload sukses tunggal menjadi {"data": ...}. type dataEnvelope struct { Data any `json:"data"` } // Data menulis response sukses tunggal: {"data": <obj>}. func Data(w http.ResponseWriter, status int, data any) { JSON(w, status, dataEnvelope{Data: data}) } // listEnvelope membungkus list menjadi {"data": [...], "meta": {...}}. type listEnvelope struct { Data any `json:"data"` Meta Meta `json:"meta"` } // List menulis response sukses list dengan meta pagination. func List(w http.ResponseWriter, status int, data any, meta Meta) { JSON(w, status, listEnvelope{Data: data, Meta: meta}) }
dataEnvelope dan listEnvelope huruf kecil (tak-exported), jadi tidak ada package lain yang bisa membuat envelope sendiri secara langsung. Satu-satunya jalan keluar adalah lewat Data dan List. Ini menjaga bentuk JSON tetap seragam: tidak ada handler yang diam-diam menulis data dengan struktur berbeda.
Sekarang sisi error. Kita definisikan Meta dan FieldError di sini karena keduanya dipakai lintas modul (pagination dan validasi).
internal/httpx/response.go// Meta adalah informasi pagination untuk response list. type Meta struct { Page int `json:"page"` PerPage int `json:"per_page"` Total int64 `json:"total"` TotalPages int `json:"total_pages"` } // FieldError menjelaskan satu field yang gagal validasi. type FieldError struct { Field string `json:"field"` Message string `json:"message"` }
Pemakaian helper sukses di handler menjadi sangat ringkas, dan bentuk JSON dijamin konsisten.
internal/product/handler.go// Detail produk: satu object. httpx.Data(w, http.StatusOK, NewProductResponse(p)) // Produk baru dibuat: 201 dengan object. httpx.Data(w, http.StatusCreated, NewProductResponse(created)) // List produk: array plus meta pagination. httpx.List(w, http.StatusOK, NewProductResponseList(items), meta)
Perhatikan data any pada signature Data dan List. Inilah idiom Go “accept interfaces”: helper menerima tipe selonggar mungkin (any) sehingga bisa menampung ProductResponse, []ProductResponse, atau DTO apa pun. Di TypeScript kamu akan memakai generic <T>; di Go, any sudah cukup karena encoding/json memakai refleksi untuk membaca JSON tag pada saat runtime.
Envelope Error yang Stabil
code machine-readable, message untuk manusia
Error response yang stabil membuat frontend bisa membedakan salah input, belum login, data tidak ditemukan, dan konflik bisnis tanpa parsing teks bebas.
Jangan kirim sekadar {"error":"bad request"}. Client butuh code yang machine-readable (untuk logic) dan message yang aman ditampilkan ke pengguna. Kita pakai code snake_case agar stabil dan mudah dicocokkan.
internal/httpx/errors.gopackage httpx import "net/http" // errBody adalah isi dari {"error": {...}}. type errBody struct { Code string `json:"code"` Message string `json:"message"` Fields []FieldError `json:"fields,omitempty"` } // errEnvelope membungkus error menjadi {"error": {...}}. type errEnvelope struct { Error errBody `json:"error"` } // Error menulis response error biasa: {"error": {"code": ..., "message": ...}}. func Error(w http.ResponseWriter, status int, code, message string) { JSON(w, status, errEnvelope{ Error: errBody{Code: code, Message: message}, }) } // ValidationFailed menulis 422 dengan daftar field bermasalah. func ValidationFailed(w http.ResponseWriter, fields []FieldError) { JSON(w, http.StatusUnprocessableEntity, errEnvelope{ Error: errBody{ Code: "validation_error", Message: "Validasi gagal", Fields: fields, }, }) }
Kode error dipakai konsisten di seluruh proyek. Hafalkan daftar pendek ini.
invalid_json · 400
Body JSON rusak, kosong, ganda, atau memuat field tak dikenal.
validation_error · 422
JSON terbaca, tetapi nilai field melanggar aturan aplikasi. Selalu membawa fields.
unauthorized · 401
Token tidak ada atau tidak valid. Dipakai oleh middleware auth di modul berikutnya.
forbidden · 403
Sudah login, tetapi role tidak cukup, misalnya customer mengakses route admin.
product_not_found · 404
Resource spesifik tidak ada. Pakai juga order_not_found, atau not_found generik.
conflict · 409
Bentrok state, misalnya slug produk sudah dipakai atau stok tidak cukup saat checkout.
internal_error · 500
Kegagalan tak terduga di server. Detail dicatat di log, bukan dikirim ke client.
internal/product/handler.go// Produk tidak ditemukan. httpx.Error(w, http.StatusNotFound, "product_not_found", "Produk tidak ditemukan.") // Slug bentrok saat create. httpx.Error(w, http.StatusConflict, "conflict", "Slug produk sudah dipakai.")
Jangan pernah bocorkan error database mentah seperti duplicate key value violates unique constraint "products_slug_key". Catat detail itu di log server dengan slog, lalu kirim conflict plus pesan yang aman ke client. Untuk error tak terduga, balas internal_error dengan pesan generik, dan log error aslinya untuk debugging.
Di Laravel kamu mungkin menangkap ModelNotFoundException dan memetakannya ke 404. Di Go tidak ada exception, jadi pemetaan dilakukan eksplisit di handler: cek error dari service, lalu panggil httpx.Error dengan kode yang sesuai. Lebih banyak kode, tetapi tidak ada jalur error tersembunyi yang lompat ke middleware jauh di atas.
Validation Error per Field
Kumpulkan semua kesalahan, jangan berhenti di yang pertama
Validation error harus menandai field mana yang bermasalah, bukan hanya bilang request gagal. Frontend butuh ini untuk menaruh pesan tepat di bawah input form.
Pada modul ini kita validasi secara manual agar polanya jelas. Hasil validasi adalah []httpx.FieldError, lalu diteruskan ke httpx.ValidationFailed. Modul Validasi API berikutnya akan memperdalam ini dengan aturan panjang string, format email, dan opsi library validator, tetapi bentuk outputnya tetap []httpx.FieldError yang sama.
internal/product/validation.gopackage product import ( "strings" "github.com/kamu/skincare-backend/internal/httpx" ) // ValidateCreateProduct mengumpulkan SEMUA error, bukan berhenti di yang pertama. func ValidateCreateProduct(req CreateProductRequest) []httpx.FieldError { var fields []httpx.FieldError if strings.TrimSpace(req.Name) == "" { fields = append(fields, httpx.FieldError{ Field: "name", Message: "nama produk wajib diisi", }) } if strings.TrimSpace(req.Slug) == "" { fields = append(fields, httpx.FieldError{ Field: "slug", Message: "slug produk wajib diisi", }) } if strings.TrimSpace(req.Category) == "" { fields = append(fields, httpx.FieldError{ Field: "category", Message: "kategori produk wajib dipilih", }) } if req.PriceRupiah <= 0 { fields = append(fields, httpx.FieldError{ Field: "price", Message: "harga harus lebih dari 0", }) } if req.Stock < 0 { fields = append(fields, httpx.FieldError{ Field: "stock", Message: "stok tidak boleh negatif", }) } return fields }
internal/product/handler.gofunc (h *Handler) CreateProduct(w http.ResponseWriter, r *http.Request) { var req CreateProductRequest if err := httpx.DecodeJSON(w, r, &req); err != nil { httpx.Error(w, http.StatusBadRequest, "invalid_json", "Body JSON tidak valid.") return } if fields := ValidateCreateProduct(req); len(fields) > 0 { httpx.ValidationFailed(w, fields) return } created, err := h.service.CreateProduct(r.Context(), CreateProductInput{ Name: strings.TrimSpace(req.Name), Slug: strings.TrimSpace(req.Slug), Category: strings.TrimSpace(req.Category), PriceRupiah: req.PriceRupiah, Stock: req.Stock, Description: strings.TrimSpace(req.Description), }) if err != nil { httpx.Error(w, http.StatusInternalServerError, "internal_error", "Produk gagal dibuat.") return } httpx.Data(w, http.StatusCreated, NewProductResponse(created)) }
Perhatikan validator tidak return di error pertama, melainkan menambah ke slice fields lalu lanjut. Frontend bisa menyorot semua input bermasalah sekaligus, bukan memaksa pengguna memperbaiki satu per satu lalu submit ulang. Inilah perbedaan UX antara validasi yang menyebalkan dan yang membantu.
400 Bad Request cocok saat JSON rusak atau tidak bisa dibaca (invalid_json). 422 Unprocessable Entity cocok saat JSON terbaca sempurna, tetapi nilainya melanggar aturan aplikasi seperti harga nol. Memisahkan keduanya membuat client tahu apakah masalahnya di format atau di isi.
Di React kamu mungkin pakai Zod, yang menghasilkan error.issues berisi path dan message. Bentuk fields[] kita di sini sengaja mirip: field adalah path, message adalah pesannya. Jadi adapter di frontend untuk memetakan fields[] ke state form terasa familier.
Pagination: Page, PerPage, Total, TotalPages
Clamp input, hitung total_pages, kembalikan meta yang jujur
Endpoint list hampir selalu butuh pagination agar response tetap ringan dan predictable. Kuncinya: jangan percaya angka dari client mentah-mentah, clamp dulu.
Client mengirim page dan per_page lewat query string, dan keduanya bisa berisi apa saja: nol, negatif, ribuan, atau bukan angka. Kita clamp ke rentang aman, lalu hitung total_pages dari total yang diberikan repository. Helper ini hidup di httpx agar dipakai semua endpoint list.
internal/httpx/pagination.gopackage httpx import ( "net/http" "strconv" ) const ( defaultPerPage = 20 maxPerPage = 100 ) // PageParams membaca page dan per_page dari query lalu meng-clamp ke rentang aman. // page minimal 1; per_page antara 1 dan maxPerPage, default 20. func PageParams(r *http.Request) (page, perPage int) { page = atoiDefault(r.URL.Query().Get("page"), 1) if page < 1 { page = 1 } perPage = atoiDefault(r.URL.Query().Get("per_page"), defaultPerPage) if perPage < 1 { perPage = defaultPerPage } if perPage > maxPerPage { perPage = maxPerPage } return page, perPage } // Offset menghitung jumlah baris yang dilewati untuk LIMIT/OFFSET di SQL nanti. func Offset(page, perPage int) int { return (page - 1) * perPage } // NewMeta membangun Meta lengkap dengan total_pages yang dihitung dari total. func NewMeta(page, perPage int, total int64) Meta { totalPages := 0 if perPage > 0 { // Pembulatan ke atas: ceil(total / perPage). totalPages = int((total + int64(perPage) - 1) / int64(perPage)) } return Meta{ Page: page, PerPage: perPage, Total: total, TotalPages: totalPages, } } func atoiDefault(raw string, fallback int) int { n, err := strconv.Atoi(raw) if err != nil { return fallback } return n }
Tanpa clamp, request GET /v1/products?per_page=1000000 memaksa server memuat sejuta baris ke memori dalam satu query. Itu pintu DoS yang dibuka tanpa sengaja. Batas maxPerPage melindungi server, dan page minimal 1 mencegah offset negatif yang bisa membuat query SQL error.
Perhitungan total_pages memakai trik pembulatan ke atas tanpa float: (total + perPage - 1) / perPage. Untuk 135 produk dengan per_page 20, hasilnya (135 + 19) / 20 = 7 halaman, sesuai harapan.
internal/product/handler.gofunc (h *Handler) ListProducts(w http.ResponseWriter, r *http.Request) { page, perPage := httpx.PageParams(r) category := strings.TrimSpace(r.URL.Query().Get("category")) q := strings.TrimSpace(r.URL.Query().Get("q")) items, total, err := h.service.ListProducts(r.Context(), ListProductFilter{ Category: category, Query: q, Limit: perPage, Offset: httpx.Offset(page, perPage), }) if err != nil { httpx.Error(w, http.StatusInternalServerError, "internal_error", "Gagal memuat produk.") return } meta := httpx.NewMeta(page, perPage, total) httpx.List(w, http.StatusOK, NewProductResponseList(items), meta) }
GET /v1/products?page=1&per_page=20{ "data": [ { "id": 101, "name": "Hydrating Toner", "category": "toner", "price": 129000, "stock": 42, "status": "active", "created_at": "2026-06-06T02:15:00Z" } ], "meta": { "page": 1, "per_page": 20, "total": 135, "total_pages": 7 } }
Di Laravel, Product::paginate(20) otomatis menghasilkan data, current_page, last_page, dan total. Di Go kita merakitnya sendiri, tetapi karena itu kita bebas menamai field (page, per_page, total, total_pages) dan tahu persis berapa query yang berjalan: satu untuk data, satu untuk COUNT. Tidak ada query tersembunyi.
Pagination berbasis page dan offset cukup untuk katalog dan admin. Saat data sangat besar atau sering berubah, cursor pagination (?after=<id>) lebih stabil dan cepat. Kita akan menyentuhnya di roadmap scaling; untuk sekarang, offset sudah memadai.
Alur Handler dari Decode sampai Encode
Jalur yang sama untuk setiap endpoint
Handler yang rapi punya alur yang mudah dibaca dan selalu sama: decode, validate, panggil service, map ke response DTO, lalu encode lewat httpx.
flowchart LR REQ([HTTP Request]) --> DEC["httpx.DecodeJSON<br/>(MaxBytes + DisallowUnknown)"] DEC -->|invalid_json 400| ERR1([httpx.Error]) DEC -->|ok| VAL["Validate<br/>[]FieldError"] VAL -->|gagal 422| ERR2([httpx.ValidationFailed]) VAL -->|lolos| SVC["Service<br/>(business logic)"] SVC -->|error| ERR3([httpx.Error]) SVC -->|domain| MAP["Mapper<br/>domain to DTO"] MAP --> ENC["httpx.Data / httpx.List"] ENC --> RESP([JSON Response])
Gambar 1. Setiap handler melewati jalur yang sama. Cabang error selalu lewat httpx, sehingga bentuk error JSON konsisten di seluruh API.
Diagram berikut menunjukkan urutan waktu satu request create produk, dari frontend sampai response kembali.
sequenceDiagram
participant FE as Frontend React / Mobile
participant H as Go Handler
participant V as Validator
participant SVC as Product Service
participant HX as httpx
FE->>H: POST /v1/admin/products dengan JSON
H->>H: httpx.DecodeJSON ke CreateProductRequest
H->>V: ValidateCreateProduct(req)
V-->>H: []FieldError (kosong jika valid)
H->>SVC: CreateProduct(ctx, input)
SVC-->>H: Product domain
H->>HX: Data(w, 201, NewProductResponse(p))
HX-->>FE: 201 Created {"data": {...}}Gambar 2. Handler menjaga batas antara JSON contract, validasi, service layer, dan response DTO. Context request (r.Context()) diteruskan ke service sebagai parameter pertama.
internal/product/handler.gopackage product import ( "context" "net/http" "github.com/kamu/skincare-backend/internal/httpx" ) // ProductService adalah interface yang dibutuhkan handler. // Handler menerima interface (accept interfaces), bukan implementasi konkret. type ProductService interface { CreateProduct(ctx context.Context, in CreateProductInput) (Product, error) ListProducts(ctx context.Context, f ListProductFilter) ([]Product, int64, error) } type Handler struct { service ProductService } func NewHandler(service ProductService) *Handler { return &Handler{service: service} }
Di Express, req membawa segala konteks request. Di Go, r.Context() diambil dari request lalu diteruskan ke service sebagai argumen pertama (ctx context.Context). Ini idiom Go: context membawa deadline, cancellation, dan nilai request-scoped, sehingga saat client memutus koneksi, query database pun ikut dibatalkan.
Struktur DTO di Proyek Skincare
DTO dekat fiturnya, helper response di httpx
DTO sebaiknya hidup dekat fitur yang memakainya, sementara helper response umum tinggal di package kecil internal/httpx.
- cmd/
- api/
- main.go wiring router dan server
- internal/
- httpx/
- response.go JSON, Data, List, Meta, FieldError
- errors.go Error, ValidationFailed, error codes
- decode.go DecodeJSON: MaxBytes + DisallowUnknownFields
- pagination.go PageParams, Offset, NewMeta
- product/
- model.go domain Product (punya field internal)
- dto.go CreateProductRequest, ProductResponse, UpdateProductRequest
- mapper.go NewProductResponse, NewProductResponseList
- validation.go ValidateCreateProduct -> []httpx.FieldError
- handler.go decode, validate, service, encode
- cart/
- dto.go AddCartItemRequest, CartItemResponse
- handler.go
- router/
- router.go route /v1/products, /v1/cart, /v1/orders
- go.mod module github.com/kamu/skincare-backend, go 1.26
go.modmodule github.com/kamu/skincare-backend go 1.26 require github.com/go-chi/chi/v5 v5.3.0
internal/router/router.gopackage router import ( "github.com/go-chi/chi/v5" "github.com/kamu/skincare-backend/internal/product" ) func New(productHandler *product.Handler) chi.Router { r := chi.NewRouter() r.Route("/v1", func(r chi.Router) { r.Get("/products", productHandler.ListProducts) r.Get("/products/{id}", productHandler.GetProduct) // Route admin produk akan dilindungi middleware auth di modul berikutnya. r.Post("/admin/products", productHandler.CreateProduct) }) return r }
Nama package yang spesifik membantu pembaca tahu konteksnya. httpx berarti “helper HTTP internal”, sehingga isinya jelas: encode, decode, envelope, pagination. Sebaliknya utils cenderung jadi tempat campur aduk yang tumbuh tak terkendali. Di Go, nama package adalah dokumentasi.
Hands-on: Kontrak Create dan List Produk
Bangun envelope lalu uji sukses, validasi, dan pagination
Latihan ini merakit kontrak create dan list produk lengkap, dari request, validasi, sampai response sukses, error, dan pagination.
Buat response.go, errors.go, decode.go, dan pagination.go sesuai kode di modul ini. Ini fondasi yang dipakai modul-modul berikutnya.
Tambah CreateProductRequest, ProductResponse, dan NewProductResponse di package product.
Tulis ValidateCreateProduct yang mengembalikan []httpx.FieldError, lalu sambungkan ke httpx.ValidationFailed.
Kirim request valid, request tidak valid, dan list dengan query pagination, lalu pastikan tiap envelope sesuai kontrak.
Kirim produk valid dan amati 201 Created dengan amplop data.
Terminalcurl -i -X POST http://localhost:8080/v1/admin/products \ -H 'Content-Type: application/json' \ -d '{"name":"Hydrating Toner","slug":"hydrating-toner","category":"toner","price":129000,"stock":42,"description":"Toner pelembap harian"}'
Expected 201 Created{ "data": { "id": 101, "name": "Hydrating Toner", "slug": "hydrating-toner", "category": "toner", "price": 129000, "stock": 42, "description": "Toner pelembap harian", "status": "active", "created_at": "2026-06-06T02:15:00Z" } }
Kirim produk tidak valid dan amati 422 dengan daftar fields.
Terminalcurl -i -X POST http://localhost:8080/v1/admin/products \ -H 'Content-Type: application/json' \ -d '{"name":"","slug":"","category":"","price":0,"stock":-1}'
Expected 422 Unprocessable Entity{ "error": { "code": "validation_error", "message": "Validasi gagal", "fields": [ { "field": "name", "message": "nama produk wajib diisi" }, { "field": "slug", "message": "slug produk wajib diisi" }, { "field": "category", "message": "kategori produk wajib dipilih" }, { "field": "price", "message": "harga harus lebih dari 0" }, { "field": "stock", "message": "stok tidak boleh negatif" } ] } }
Ambil daftar produk dengan pagination dan amati amplop data plus meta.
Terminalcurl -i "http://localhost:8080/v1/products?page=1&per_page=20&category=toner"
Expected 200 OK{ "data": [ { "id": 101, "name": "Hydrating Toner", "category": "toner", "price": 129000, "stock": 42, "status": "active", "created_at": "2026-06-06T02:15:00Z" } ], "meta": { "page": 1, "per_page": 20, "total": 1, "total_pages": 1 } }
Coba kirim per_page=99999 dan pastikan meta.per_page ter-clamp ke 100. Coba kirim body dengan field asing seperti {"name":"X","secret":"y"} dan pastikan kamu menerima invalid_json. Menguji jalur kotor lebih penting daripada jalur ideal, karena di situlah bug kontrak bersembunyi.
Jebakan Umum dari JS dan PHP
Bug kontrak yang sering terbawa dari Express atau Laravel
Sebagian bug API Go bukan karena sintaks sulit, tetapi karena kontrak JSON tidak sengaja berubah atau terlalu dekat dengan database.
Meng-encode domain mentah
Encode(product) ikut membawa field internal. Untuk model User, ini bisa membocorkan PasswordHash. Selalu lewati mapper ke response DTO.
Lupa JSON tag
Tanpa tag, PriceRupiah menjadi PriceRupiah di JSON, bukan price. Kontrak langsung melenceng dari kesepakatan.
Menganggap omitempty sebagai validasi
omitempty hanya memengaruhi output JSON saat encode. Ia tidak menolak input kosong saat decode.
Field opsional tanpa pointer
Untuk PATCH, int64 biasa tidak bisa membedakan “tidak dikirim” dari “dikirim nol”. Pakai *int64 agar absent terbaca sebagai nil.
Error code berubah-ubah
Frontend jangan bergantung pada teks error bebas. Beri code stabil snake_case seperti validation_error dan product_not_found.
List tanpa amplop
Mengirim array telanjang [...] membuat kamu tidak punya tempat untuk meta saat butuh pagination. Bungkus sejak awal.
Lupa clamp pagination
Menerima per_page apa adanya membuka pintu DoS. Selalu clamp ke rentang aman sebelum query.
Membocorkan error internal
Pesan database mentah jangan dikirim ke client. Log detail di server, balas kode bisnis yang aman.
encoding/json secara default mengabaikan field JSON yang tidak ada di struct. Typo seperti {"pirce": 10000} akan diterima diam-diam dengan price tetap nol. DisallowUnknownFields di httpx.DecodeJSON menutup celah ini, jadi typo client langsung ditolak sebagai invalid_json.
Di PHP, array response bisa tumbuh spontan dengan key baru di mana saja. Di Go, struct membuat bentuk response eksplisit dan terkunci saat compile, tetapi kamu harus disiplin membuat DTO agar kontrak tidak tersebar liar. Disiplin ini terbayar saat tim frontend bisa mengandalkan bentuk yang sama selamanya.
Ringkasan & Poin Penting
Desain request dan response adalah fondasi kontrak publik API skincare. Package internal/httpx yang kita bangun di sini akan dipakai ulang di modul middleware, validasi, autentikasi, sampai peta API final.
Yang Wajib Menempel
- Empat bentuk envelope kanonik: sukses tunggal
{"data": {...}}, sukses list{"data": [...], "meta": {...}}, error{"error": {"code","message"}}, dan validation denganfields[]. - Request DTO terpisah dari domain, memakai JSON tag, dan pointer (
*int64,*string) untuk field opsional di PATCH agar absent bisa dibedakan dari nol. - Decode body lewat
httpx.DecodeJSON:MaxBytesReadermembatasi ukuran,DisallowUnknownFieldsmenolak typo, dan semua kerusakan dipetakan keinvalid_json. - Response DTO tidak pernah meng-encode model domain langsung; mapper memilih field aman saja, sehingga
PasswordHashdan field internal tidak bocor. - Package
httpxmemusatkan response:JSON,Data,List,Error,ValidationFailed, dengan tipeMetadanFieldErroryang dipakai lintas modul. - Error memakai
codesnake_case yang stabil (invalid_json,validation_error,not_found,conflict,internal_error) dan status code yang sesuai. - Pagination meng-clamp
pagedanper_page, lalu menghitungtotal_pagesdengan pembulatan ke atas tanpa float. - Harga selalu
PriceRupiah int64dengan JSON tagprice, dan module path proyek adalahgithub.com/kamu/skincare-backend.
Untuk proyek online shop skincare, modul ini menetapkan konstitusi response API. Langkah berikutnya adalah modul Middleware, tempat kita memasang logging, recoverer, request ID, dan CORS di sekitar handler ini, lalu modul Validasi API yang memperdalam ValidateCreateProduct dengan aturan panjang string dan format email, tetap menghasilkan []httpx.FieldError yang sama. Saat contoh masih in-memory, ingat bahwa repository PostgreSQL dengan pgx akan datang di Roadmap 3, dan httpx akan tetap menjadi lapisan response yang tidak berubah.
Progress disimpan lokal di browser ini.