Progress belajar
Modul 14 dari 73
0% 0/73 modul selesai
Setelah selesai, tandai modul ini agar progres kursus tetap rapi.
Progress disimpan lokal di browser ini.
Membangun HTTP Handler
dengan net/http
Di modul ini kita membuat API produk skincare memakai standard library dulu, supaya saat masuk chi kamu tahu fondasi yang sedang dibungkus.
Dari Route Handler ke HTTP Handler
Sebelum chi, pahami kontrak HTTP bawaan Go
Kalau kamu pernah menulis Express.js, Laravel controller, atau Next.js route handler, konsep handler di Go punya tujuan yang sama: menerima request, memutuskan respons, lalu mengembalikannya ke client.
Di Express.js kamu biasa menulis fungsi seperti (req, res) => res.json(data). Di Go bentuknya mirip secara mental, tetapi berbeda secara kontrak: handler menerima http.ResponseWriter dan *http.Request, lalu menulis respons melalui writer.
reqmembawa method, path, query, header, dan body.respunya helper sepertistatus()danjson().- Framework memberi banyak convenience sejak awal.
*http.Requestmembawa method, URL, header, body, dan context.http.ResponseWriterdipakai untuk header, status code, dan body.- Standard library kecil dan eksplisit, helper perlu kamu buat sendiri.
Di Laravel, controller sering langsung punya akses ke request object, validation helper, response helper, middleware group, dan dependency container. Di net/http, fondasinya sengaja minimal, sehingga kamu melihat jelas batas antara protokol HTTP, routing, validasi, dan business logic.
Tujuan modul ini bukan membuat arsitektur final. Tujuannya adalah membuat kamu nyaman dengan bahan mentah yang akan dipakai lagi saat memakai chi, handler testing, middleware, context, dan repository PostgreSQL.
http.Handler dan HandlerFunc
Kontrak paling kecil untuk menerima HTTP request
Di Go, server HTTP tidak peduli handler kamu berupa struct, function, atau router besar, selama nilainya memenuhi interface http.Handler.
Interface dari package net/http yang memiliki satu method, yaitu ServeHTTP(w http.ResponseWriter, r *http.Request).
Bentuk sederhananya seperti ini.
kontrak-handler.gotype Handler interface { ServeHTTP(ResponseWriter, *Request) }
Karena interface Go dipenuhi secara implicit, struct apa pun yang punya method ServeHTTP otomatis bisa menjadi handler.
internal/product/handler_struct.gotype ProductHandler struct { // nanti diisi service atau repository } func (h ProductHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) w.Write([]byte("product handler")) }
Untuk kasus sederhana, kamu tidak perlu membuat struct. http.HandlerFunc adalah adapter yang mengubah fungsi biasa menjadi http.Handler.
handlerfunc.gofunc healthHandler(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) w.Write([]byte("ok")) } mux.HandleFunc("GET /health", healthHandler)
Gunakan function handler untuk endpoint kecil atau modul awal. Gunakan struct handler ketika handler butuh dependency seperti service, logger, validator, atau konfigurasi.
Yang membuat kontrak ini kuat: http.ServeMux sendiri juga memenuhi http.Handler. Jadi router, middleware, dan handler kamu adalah jenis nilai yang sama, dan bisa saling dibungkus tanpa batas.
flowchart LR REQ([HTTP Request]) --> SRV["http.Server"] SRV --> MUX["ServeMux<br/>(juga http.Handler)"] MUX -->|cocokkan pattern route| H["HandlerFunc / struct<br/>ServeHTTP(w, r)"] H --> RESP([HTTP Response])
Gambar 1. Semua komponen di rantai ini adalah http.Handler, sehingga middleware nanti cukup membungkus handler dan mengembalikan handler lagi.
http.Handler
Kontrak abstrak. Cocok untuk middleware, router, dan dependency yang bisa dibungkus.
http.HandlerFunc
Convenience untuk fungsi biasa dengan signature handler.
ServeHTTP
Method yang dipanggil server untuk setiap request yang match route.
Membaca Request dan Menulis Response
Dua objek utama dalam setiap handler
Semua handler Go berputar di sekitar dua parameter: w untuk menulis respons dan r untuk membaca request.
*http.Request menyimpan data yang datang dari client. Untuk backend API, bagian yang paling sering kamu baca adalah r.Method, r.URL.Path, r.URL.Query(), r.Header, r.Body, dan r.Context().
membaca-request.gofunc debugHandler(w http.ResponseWriter, r *http.Request) { method := r.Method path := r.URL.Path category := r.URL.Query().Get("category") requestID := r.Header.Get("X-Request-ID") _ = method _ = path _ = category _ = requestID w.WriteHeader(http.StatusNoContent) }
http.ResponseWriter adalah interface dengan tiga method: Header() untuk mengubah header, WriteHeader(status) untuk mengirim status code, dan Write([]byte) untuk menulis body. Urutan yang aman adalah set header dulu, tulis status code, lalu tulis body.
menulis-response.gofunc helloHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.WriteHeader(http.StatusOK) w.Write([]byte("hello from Go API")) }
Diagram berikut menjelaskan kenapa urutan tadi penting. Begitu status terkirim, header membeku dan tidak bisa diubah lagi.
stateDiagram-v2 [*] --> HeaderTerbuka HeaderTerbuka --> HeaderTerbuka: w.Header().Set(...) HeaderTerbuka --> StatusTerkirim: w.WriteHeader(status) StatusTerkirim --> BodyMengalir: w.Write(...) BodyMengalir --> BodyMengalir: w.Write(...) lagi BodyMengalir --> [*] note right of StatusTerkirim: Header beku di sini.<br/>Set header setelahnya diabaikan.
Gambar 2. Siklus menulis response. Set semua header sebelum WriteHeader, karena setelah status terkirim header tidak berpengaruh lagi.
Setelah WriteHeader atau Write dipanggil, sebagian besar perubahan header tidak akan berpengaruh. Anggap saja respons sudah mulai dikirim ke client.
Dalam proyek online shop skincare, handler produk akan membaca query seperti category=serum, header seperti Authorization, dan body JSON saat admin membuat produk baru.
/v1/products?category=serum Membaca query parameter untuk filter katalog /v1/products Membaca request body JSON untuk membuat produk baru /v1/products/{id} Membaca path parameter untuk detail produk JSON Response dan Request Body
Cara paling umum React frontend bicara dengan Go API
React frontend biasanya mengirim dan menerima JSON. Di Go, package encoding/json sudah cukup untuk tahap awal.
Untuk respons JSON, set Content-Type, status code, lalu pakai json.NewEncoder(w).Encode(payload).
json-response.gotype ProductResponse struct { ID int64 `json:"id"` Name string `json:"name"` PriceRupiah int64 `json:"price"` } func productHandler(w http.ResponseWriter, r *http.Request) { product := ProductResponse{ ID: 1, Name: "Niacinamide Serum", PriceRupiah: 189000, } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(product) }
Untuk request JSON, buat struct request, lalu decode dari r.Body.
json-request.gotype CreateProductRequest struct { Name string `json:"name"` Category string `json:"category"` PriceRupiah int64 `json:"price"` Stock int `json:"stock"` } func createProductHandler(w http.ResponseWriter, r *http.Request) { var req CreateProductRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid JSON", http.StatusBadRequest) return } w.WriteHeader(http.StatusCreated) }
Di JavaScript, response.json() membaca body response menjadi object. Di Go server, json.NewEncoder(w).Encode(value) melakukan kebalikannya: mengubah value Go menjadi JSON lalu menulisnya ke response body.
Rupiah tidak punya pecahan sen yang dipakai sehari-hari, jadi kita simpan harga sebagai bilangan bulat rupiah (189000 berarti Rp 189.000). Menghindari float64 untuk uang adalah praktik standar agar tidak ada galat pembulatan.
Ada dua helper kecil yang hampir selalu kita buat agar handler tidak mengulang boilerplate.
response.gofunc writeJSON(w http.ResponseWriter, status int, payload any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) if err := json.NewEncoder(w).Encode(payload); err != nil { slog.Error("encode response", "error", err) } } func writeError(w http.ResponseWriter, status int, message string) { writeJSON(w, status, map[string]string{"error": message}) }
Untuk endpoint publik, batasi ukuran r.Body dengan http.MaxBytesReader. Tanpa batas, client nakal bisa mengirim body sangat besar dan menghabiskan resource server.
decode-dengan-batas.gor.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MB decoder := json.NewDecoder(r.Body) decoder.DisallowUnknownFields() if err := decoder.Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "request body tidak valid") return }
Bila body melewati batas, Decode mengembalikan error bertipe *http.MaxBytesError. Untuk respons yang lebih jujur, kamu bisa memeriksanya dengan errors.As lalu membalas 413 Request Entity Too Large, bukan sekadar 400.
Status Code sebagai Kontrak API
Client React bergantung pada status code yang konsisten
Status code bukan kosmetik. Ia adalah kontrak yang membuat frontend, mobile app, worker, dan payment provider tahu hasil request tanpa menebak isi body.
Di Go, status code dikirim dengan w.WriteHeader(code). Bila kamu langsung memanggil w.Write(...) tanpa WriteHeader, Go akan mengirim 200 OK secara implicit.
200 OK
Request sukses dan respons membawa data, misalnya daftar produk.
201 Created
Resource berhasil dibuat, misalnya produk admin atau order checkout.
400 Bad Request
JSON rusak, parameter invalid, atau format request salah.
404 Not Found
Resource tidak ada, misalnya produk dengan ID tertentu tidak ditemukan.
422 Unprocessable Entity
JSON valid, tetapi aturan bisnis gagal, misalnya harga produk 0.
500 Internal Server Error
Error yang tidak bisa dipulihkan di server. Detail internal jangan dibocorkan ke client.
status-code.gofunc createProductHandler(w http.ResponseWriter, r *http.Request) { // setelah produk berhasil dibuat w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) json.NewEncoder(w).Encode(product) }
Dalam satu response normal, kamu hanya punya satu status akhir. Setelah WriteHeader(http.StatusCreated), jangan mencoba mengubahnya menjadi 400 di bawahnya.
Pola guard clause dari React sangat cocok di Go. Validasi error lebih baik return lebih awal.
guard-clause.goif err := decoder.Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "JSON tidak valid") return } if req.PriceRupiah <= 0 { writeError(w, http.StatusUnprocessableEntity, "harga harus lebih dari 0") return }
Contoh Lengkap Handler Produk
Satu file lengkap tanpa chi, fokus ke net/http
Contoh ini sengaja memakai in-memory store agar perhatian kita tetap pada handler HTTP, bukan database.
- skincare-api/
- go.mod
- handler.go contoh lengkap modul ini
handler.gopackage main import ( "encoding/json" "errors" "log/slog" "net/http" "os" "sort" "strconv" "strings" "sync" ) type Product struct { ID int64 `json:"id"` Name string `json:"name"` Category string `json:"category"` PriceRupiah int64 `json:"price"` Stock int `json:"stock"` Description string `json:"description,omitempty"` Status string `json:"status"` } type CreateProductRequest struct { Name string `json:"name"` Category string `json:"category"` PriceRupiah int64 `json:"price"` Stock int `json:"stock"` Description string `json:"description"` } type ErrorResponse struct { Error string `json:"error"` } type ProductStore struct { mu sync.RWMutex nextID int64 products map[int64]Product } func main() { store := NewProductStore() mux := http.NewServeMux() mux.HandleFunc("GET /health", healthHandler) mux.HandleFunc("GET /v1/products", store.ListProducts) mux.HandleFunc("POST /v1/products", store.CreateProduct) mux.HandleFunc("GET /v1/products/{id}", store.GetProduct) addr := ":8080" slog.Info("server listening", "addr", addr) if err := http.ListenAndServe(addr, mux); err != nil { slog.Error("server stopped", "error", err) os.Exit(1) } } func NewProductStore() *ProductStore { return &ProductStore{ nextID: 4, products: map[int64]Product{ 1: {ID: 1, Name: "Hydrating Cleanser", Category: "cleanser", PriceRupiah: 129000, Stock: 40, Status: "active"}, 2: {ID: 2, Name: "Niacinamide Serum", Category: "serum", PriceRupiah: 189000, Stock: 22, Status: "active"}, 3: {ID: 3, Name: "Daily Sunscreen SPF 50", Category: "sunscreen", PriceRupiah: 159000, Stock: 35, Status: "active"}, }, } } func healthHandler(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } func (s *ProductStore) ListProducts(w http.ResponseWriter, r *http.Request) { category := strings.TrimSpace(r.URL.Query().Get("category")) keyword := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("q"))) s.mu.RLock() products := make([]Product, 0, len(s.products)) for _, product := range s.products { if category != "" && product.Category != category { continue } if keyword != "" && !strings.Contains(strings.ToLower(product.Name), keyword) { continue } products = append(products, product) } s.mu.RUnlock() // Iterasi map di Go acak. Urutkan agar urutan list stabil untuk frontend. sort.Slice(products, func(i, j int) bool { return products[i].ID < products[j].ID }) writeJSON(w, http.StatusOK, products) } func (s *ProductStore) GetProduct(w http.ResponseWriter, r *http.Request) { id, err := parseID(r.PathValue("id")) if err != nil { writeError(w, http.StatusBadRequest, "product id harus berupa angka positif") return } s.mu.RLock() product, ok := s.products[id] s.mu.RUnlock() if !ok { writeError(w, http.StatusNotFound, "produk tidak ditemukan") return } writeJSON(w, http.StatusOK, product) } func (s *ProductStore) CreateProduct(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, 1<<20) var req CreateProductRequest decoder := json.NewDecoder(r.Body) decoder.DisallowUnknownFields() if err := decoder.Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "request body harus berupa JSON produk yang valid") return } if err := validateCreateProduct(req); err != nil { writeError(w, http.StatusUnprocessableEntity, err.Error()) return } s.mu.Lock() product := Product{ ID: s.nextID, Name: strings.TrimSpace(req.Name), Category: strings.TrimSpace(req.Category), PriceRupiah: req.PriceRupiah, Stock: req.Stock, Description: strings.TrimSpace(req.Description), Status: "active", } s.products[product.ID] = product s.nextID++ s.mu.Unlock() writeJSON(w, http.StatusCreated, product) } func validateCreateProduct(req CreateProductRequest) error { if strings.TrimSpace(req.Name) == "" { return errors.New("nama produk wajib diisi") } if strings.TrimSpace(req.Category) == "" { return errors.New("kategori produk wajib diisi") } if req.PriceRupiah <= 0 { return errors.New("harga produk harus lebih dari 0") } if req.Stock < 0 { return errors.New("stok produk tidak boleh negatif") } return nil } func parseID(raw string) (int64, error) { id, err := strconv.ParseInt(raw, 10, 64) if err != nil || id <= 0 { return 0, errors.New("invalid id") } return id, nil } func writeJSON(w http.ResponseWriter, status int, payload any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) if err := json.NewEncoder(w).Encode(payload); err != nil { slog.Error("encode response", "error", err) } } func writeError(w http.ResponseWriter, status int, message string) { writeJSON(w, status, ErrorResponse{Error: message}) }
Diagram berikut menunjukkan alur satu request dari React app sampai respons JSON kembali.
sequenceDiagram participant FE as React Frontend participant MUX as net/http ServeMux participant H as Product Handler participant S as In-memory Store FE->>MUX: POST /v1/products dengan JSON MUX->>H: Panggil CreateProduct(w, r) H->>H: Decode JSON dari r.Body H->>H: Validasi input produk H->>S: Simpan product baru S-->>H: Product dengan ID H-->>FE: 201 Created application/json
Gambar 3. Alur request di modul ini masih tanpa database. Di Roadmap 3, store akan diganti repository PostgreSQL dengan pgx.
Server HTTP Go memproses banyak request secara concurrent. Karena contoh memakai map in-memory yang bisa dibaca dan ditulis beberapa request, kita memakai mutex agar aman. Saat pindah ke PostgreSQL, konsistensi data akan dikelola oleh database dan transaksi.
Berbeda dengan object JavaScript yang menjaga urutan insert, iterasi map di Go sengaja diacak. Untuk endpoint list, selalu urutkan hasilnya (di sini dengan sort.Slice) supaya frontend menerima urutan yang stabil.
ServeMux dan ListenAndServe
Router minimal dan cara menjalankan server
http.ServeMux adalah router bawaan standard library. Ia menerima pattern route, lalu memilih handler yang sesuai.
Sejak Go 1.22, ServeMux mendukung pattern yang lebih ekspresif, termasuk method dan wildcard path. Itu sebabnya contoh kita bisa menulis route seperti ini.
routes.gomux := http.NewServeMux() mux.HandleFunc("GET /health", healthHandler) mux.HandleFunc("GET /v1/products", store.ListProducts) mux.HandleFunc("POST /v1/products", store.CreateProduct) mux.HandleFunc("GET /v1/products/{id}", store.GetProduct)
Nilai wildcard bisa dibaca dengan r.PathValue("id").
path-value.gofunc (s *ProductStore) GetProduct(w http.ResponseWriter, r *http.Request) { idRaw := r.PathValue("id") // parse idRaw ke int64 }
http.ListenAndServe menjalankan server pada address tertentu dan memakai handler yang kamu berikan.
listen.goaddr := ":8080" if err := http.ListenAndServe(addr, mux); err != nil { slog.Error("server stopped", "error", err) os.Exit(1) }
Multiplexer HTTP bawaan Go yang mencocokkan request masuk ke handler berdasarkan pattern route.
Fungsi yang membuka TCP listener, menerima koneksi HTTP, lalu meneruskan request ke handler.
Bila dua pattern cocok, ServeMux memilih yang paling spesifik. GET /v1/products/{id} menang atas GET /v1/products/ untuk path /v1/products/2, jadi kamu tidak perlu mengurutkan route secara manual seperti di beberapa router lain.
http.ListenAndServe(":8080", nil) memakai http.DefaultServeMux, yaitu mux global. Untuk proyek serius, buat http.NewServeMux() sendiri agar route tidak tersebar lewat global state.
Kenapa Nanti Tetap Pakai chi?
Standard library cukup kuat, tetapi proyek nyata butuh ergonomi lebih
Pertanyaan bagus untuk Go modern: kalau ServeMux sudah punya method pattern dan wildcard, kenapa masih belajar chi?
Jawabannya bukan karena net/http buruk. Justru chi dibangun di atas kontrak http.Handler, sehingga kamu tetap memakai fondasi yang sama. chi membantu saat route makin banyak, middleware makin serius, dan struktur API perlu rapi.
Middleware ergonomis
Logging, recoverer, timeout, auth, request ID, CORS, dan rate limit lebih mudah dirangkai per group route.
Route grouping
API versi /v1, admin routes, public catalog, cart, checkout, dan webhook bisa dikelompokkan dengan jelas.
Integrasi komunitas
Banyak middleware dan contoh production yang langsung memakai chi di atas net/http.
Arsitektur modular
Setiap module domain bisa punya function Routes() sendiri dan dipasang ke router utama.
- Cukup untuk API kecil dan belajar fondasi.
- Sudah mendukung method pattern dan wildcard path.
- Middleware bisa dibuat, tetapi grouping dan composition lebih manual.
- Cocok untuk API production yang route dan middleware-nya tumbuh.
- Group, mount, middleware stack, dan URL params terasa lebih nyaman.
- Tetap compatible dengan
http.Handler,http.HandlerFunc, danhttptest.
Belajar net/http dulu membuat chi terasa seperti alat bantu, bukan sulap. Saat nanti ada bug middleware atau test handler, kamu tahu kontrak aslinya tetap http.Handler.
Hands-on: Jalankan API Produk
Latihan cepat sebelum masuk router chi
Sekarang jalankan contoh handler produk dan coba request dari terminal.
Gunakan module kecil khusus latihan agar tidak bercampur dengan modul lain.
handler.goLetakkan file contoh lengkap dari section sebelumnya di root folder latihan.
Pakai go run ., lalu biarkan terminal tetap terbuka.
Pakai curl dari terminal lain untuk melihat response JSON dan status code.
Terminalmkdir skincare-api cd skincare-api go mod init github.com/kamu/skincare-backend # salin handler.go ke folder ini go run .
Coba health check.
Terminalcurl -i http://localhost:8080/health
Coba daftar produk dengan filter query.
Terminalcurl -i "http://localhost:8080/v1/products?category=serum"
Coba detail produk dengan path parameter.
Terminalcurl -i http://localhost:8080/v1/products/2
Coba membuat produk baru.
Terminalcurl -i -X POST http://localhost:8080/v1/products \ -H "Content-Type: application/json" \ -d '{"name":"Barrier Repair Moisturizer","category":"moisturizer","price":219000,"stock":18,"description":"Krim pelembap untuk skin barrier"}'
Perhatikan status 201 Created, header Content-Type: application/json, dan body JSON berisi id produk baru. Itulah kontrak yang akan dipakai React frontend.
Jebakan Umum dari JS/PHP
Kesalahan kecil yang sering bikin handler Go membingungkan
Sebagian bug handler bukan karena Go sulit, tetapi karena kebiasaan dari Express.js atau Laravel terbawa tanpa disesuaikan.
Lupa return setelah error
Setelah writeError, handler tetap lanjut jika tidak return. Ini sering membuat response ditulis dua kali.
Header ditulis terlambat
Set Content-Type setelah WriteHeader biasanya sudah telat. Tulis header sebelum status dan body.
Menganggap JSON otomatis valid
Decode hanya parsing JSON. Validasi business rule tetap harus kamu tulis sendiri.
Membocorkan error internal
Jangan kirim pesan error database mentah ke client. Log detail di server, kirim pesan aman ke client.
Menggunakan map global tanpa proteksi
Server HTTP concurrent. Map yang ditulis banyak request perlu mutex atau diganti database.
Mengandalkan DefaultServeMux
Global mux terasa praktis di awal, tetapi menyulitkan testing dan membuat route tersebar.
Iterasi map dianggap terurut
Tidak seperti object JS, urutan iterasi map Go acak. Urutkan sebelum mengirim list ke client.
Lupa membatasi ukuran body
Tanpa http.MaxBytesReader, satu request besar bisa membebani server. Batasi sejak awal.
Di Express, middleware adalah function dengan next(). Di Go, middleware biasanya function yang menerima http.Handler dan mengembalikan http.Handler. Kita akan membahas pola ini sebelum masuk chi middleware.
bentuk-middleware-go.gofunc logging(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { slog.Info("request", "method", r.Method, "path", r.URL.Path) next.ServeHTTP(w, r) }) }
Ringkasan & Poin Penting
Sekarang kamu sudah bisa membaca dan menulis HTTP request secara langsung dengan standard library Go.
Yang Wajib Menempel
http.Handleradalah kontrak dasar server HTTP Go, danhttp.HandlerFuncadalah adapter untuk function biasa.ServeMuxpun memenuhi kontrak yang sama.*http.Requestdipakai untuk membaca method, URL, query, header, body, path value, dan context.http.ResponseWriterdipakai untuk set header, status code, dan body response, dengan urutan header dulu baru status dan body.json.NewEncoder(w).Encode(...)adalah cara praktis mengirim respons JSON, sedangkanjson.NewDecoder(r.Body).Decode(&req)membaca JSON request, dilengkapiMaxBytesReaderagar body tidak tak terbatas.- Status code adalah kontrak API, bukan detail kosmetik. Pakai
201untuk created,400untuk request rusak,422untuk validasi bisnis, dan404untuk resource tidak ada. - Iterasi
mapGo acak, jadi urutkan output list secara eksplisit agar kontrak ke frontend stabil. ServeMuxmodern sudah cukup untuk API kecil, tetapi chi akan membantu route grouping, middleware stack, dan modularisasi API production.
Untuk proyek online shop skincare, modul ini adalah fondasi endpoint produk. Di modul berikutnya, kita akan mulai merapikan routing dengan chi, lalu lanjut ke middleware, request context, dan akhirnya repository PostgreSQL dengan pgx.
Progress disimpan lokal di browser ini.