Chi #
Chi adalah router HTTP untuk Go yang ringan, idiomatic, dan sangat dekat dengan net/http standar. Tidak seperti Gin atau Fiber yang membawa abstraksi framework sendiri, Chi hanya menambahkan routing, middleware composition, dan helper kecil di atas http.Handler standar. Hasilnya: code yang kamu tulis dengan Chi tetaplah code Go yang bisa dipahami developer mana pun — tanpa kurva belajar framework baru.
Untuk tim yang menghargai kesederhanaan, kontainabilitas, dan kompatibilitas dengan ekosistem net/http (OpenTelemetry, prometheus, middleware populer), Chi adalah pilihan yang sangat solid. Artikel ini membahas setup Docker Compose untuk local development Chi, dari Dockerfile multi-stage, hot reload dengan Air, hingga integrasi database dan best practice.
Prasyarat #
Pastikan sudah terinstall:
- Docker dan Docker Compose versi terbaru
- Go 1.22+ (opsional, untuk development di host)
- Git
Struktur project Chi standar:
my-chi-app/
├── cmd/
│ └── api/
│ └── main.go
├── internal/
│ ├── http/
│ │ ├── router.go
│ │ └── middleware.go
│ ├── handler/
│ │ └── user.go
│ ├── service/
│ │ └── user.go
│ ├── repository/
│ │ └── user.go
│ └── model/
│ └── user.go
├── go.mod
├── go.sum
├── Dockerfile
├── Dockerfile.dev
├── docker-compose.yml
├── .air.toml
├── .env
└── .dockerignore
Pemisahan direktori internal/http/ untuk routing+middleware dan internal/handler/ untuk handler endpoint adalah konvensi yang jelas — saat ada developer baru, mereka langsung tahu di mana mencari kode untuk routing versus business logic.
Apa yang Membuat Chi Berbeda #
Sebelum masuk ke teknis, pahami dulu posisi Chi di ekosistem Go.
| Framework | Pendekatan | Kompabilitas net/http |
|---|---|---|
net/http standar |
Minimal, http.ServeMux |
Native |
| Chi | Router di atas http.Handler |
100% kompatibel |
| Gin | Framework dengan *gin.Context |
Partial, adapter untuk middleware |
| Fiber | Framework dengan *fiber.Ctx di atas fasthttp |
Tidak kompatibel |
Karena Chi adalah http.Handler (bukan wrapper), semua middleware ekosistem Go bisa langsung dipakai:
// Middleware dari third-party yang langsung jalan di Chi
import "github.com/go-chi/cors"
import "github.com/go-chi/httprate"
import "github.com/prometheus/client_golang/prometheus/promhttp"
r.Use(cors.Handler(cors.Options{...}))
r.Use(httprate.LimitByIP(100, time.Minute))
r.Handle("/metrics", promhttp.Handler())
Tidak perlu adaptor, tidak perlu cors.New() constructor yang berbeda, tidak ada kebingungan apakah middleware ini “bekerja di framework ini”. Semuanya jalan.
Dockerfile Multi-Stage #
Pattern multi-stage standar untuk Go.
# syntax=docker/dockerfile:1.6
FROM golang:1.22-alpine AS builder
WORKDIR /app
RUN apk add --no-cache git ca-certificates
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/api ./cmd/api
FROM alpine:3.19
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
RUN addgroup -S app && adduser -S app -G app
USER app:app
COPY --from=builder /out/api /app/api
EXPOSE 8080
ENTRYPOINT ["/app/api"]
Image akhir ramping, < 20 MB, berbasis alpine.
Dockerfile untuk Development #
# Dockerfile.dev
FROM golang:1.22-alpine
WORKDIR /app
RUN apk add --no-cache git curl bash
# Install Air
RUN go install github.com/air-verse/air@latest
COPY go.mod go.sum ./
RUN go mod download
EXPOSE 8080
CMD ["air", "-c", ".air.toml"]
Konfigurasi Air (.air.toml):
root = "."
tmp_dir = "tmp"
[build]
bin = "./tmp/main"
cmd = "go build -o ./tmp/main ./cmd/api"
delay = 1000
exclude_dir = ["assets", "tmp", "vendor", "testdata"]
include_dir = ["cmd", "internal"]
include_ext = ["go", "yaml", "yml", "tmpl", "html"]
exclude_regex = ["_test.go"]
exclude_unchanged = true
[log]
time = true
docker-compose.yml #
# docker-compose.yml
services:
api:
build:
context: .
dockerfile: Dockerfile.dev
image: chi-app:dev
container_name: chi-api
ports:
- "8080:8080"
volumes:
- ./cmd:/app/cmd
- ./internal:/app/internal
- ./go.mod:/app/go.mod
- ./go.sum:/app/go.sum
- go-build:/app/tmp
environment:
- APP_ENV=development
- SERVER_PORT=8080
- DATABASE_URL=postgres://app:dev@db:5432/chiapp?sslmode=disable
- REDIS_URL=redis://cache:6379/0
- JWT_SECRET=local-dev-secret-change-me
depends_on:
db:
condition: service_healthy
cache:
condition: service_healthy
command: air -c .air.toml
db:
image: postgres:16-alpine
container_name: chi-db
environment:
- POSTGRES_USER=app
- POSTGRES_PASSWORD=dev
- POSTGRES_DB=chiapp
volumes:
- db-data:/var/lib/postgresql/data
- ./db/init:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d chiapp"]
interval: 10s
timeout: 5s
retries: 5
ports:
- "5432:5432"
cache:
image: redis:7-alpine
container_name: chi-cache
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 3
volumes:
- cache-data:/data
ports:
- "6379:6379"
volumes:
db-data:
cache-data:
go-build:
Contoh Aplikasi Chi #
Router (internal/http/router.go):
package http
import (
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/cors"
"github.com/go-chi/httprate"
"myapp/internal/handler"
)
type Router struct {
*chi.Mux
userHandler *handler.UserHandler
}
func NewRouter(userHandler *handler.UserHandler) *Router {
r := chi.NewRouter()
// Built-in middleware Chi
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.Timeout(60 * time.Second))
// CORS
r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
ExposedHeaders: []string{"Link"},
AllowCredentials: false,
MaxAge: 300,
}))
// Rate limit
r.Use(httprate.LimitByIP(100, time.Minute))
// Routes
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ok"}`))
})
r.Route("/api/v1", func(r chi.Router) {
r.Route("/users", func(r chi.Router) {
r.Post("/", userHandler.Create)
r.Get("/{id}", userHandler.Get)
r.Get("/", userHandler.List)
})
})
return &Router{Mux: r, userHandler: userHandler}
}
Handler (internal/handler/user.go):
package handler
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"github.com/go-chi/chi/v5"
"myapp/internal/service"
)
type UserHandler struct {
svc *service.UserService
}
func NewUserHandler(svc *service.UserService) *UserHandler {
return &UserHandler{svc: svc}
}
type createUserRequest struct {
Email string `json:"email"`
Name string `json:"name"`
Password string `json:"password"`
}
type errorResponse struct {
Error string `json:"error"`
}
func writeJSON(w http.ResponseWriter, code int, payload interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(payload)
}
func (h *UserHandler) Create(w http.ResponseWriter, r *http.Request) {
var req createUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, errorResponse{Error: "invalid body"})
return
}
if req.Email == "" || req.Name == "" || len(req.Password) < 8 {
writeJSON(w, http.StatusBadRequest, errorResponse{Error: "email, name, password (min 8) required"})
return
}
u, err := h.svc.Register(r.Context(), req.Email, req.Name, req.Password)
if err != nil {
writeJSON(w, http.StatusBadRequest, errorResponse{Error: err.Error()})
return
}
writeJSON(w, http.StatusCreated, u)
}
func (h *UserHandler) Get(w http.ResponseWriter, r *http.Request) {
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
writeJSON(w, http.StatusBadRequest, errorResponse{Error: "invalid id"})
return
}
u, err := h.svc.Get(r.Context(), id)
if err != nil {
writeJSON(w, http.StatusInternalServerError, errorResponse{Error: err.Error()})
return
}
if u == nil {
writeJSON(w, http.StatusNotFound, errorResponse{Error: "user not found"})
return
}
writeJSON(w, http.StatusOK, u)
}
func (h *UserHandler) List(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
if limit == 0 {
limit = 20
}
users, err := h.svc.List(r.Context(), limit, offset)
if err != nil {
writeJSON(w, http.StatusInternalServerError, errorResponse{Error: err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"data": users,
"limit": limit,
"offset": offset,
})
}
Service (internal/service/user.go):
package service
import (
"context"
"errors"
"strings"
"golang.org/x/crypto/bcrypt"
"myapp/internal/model"
"myapp/internal/repository"
)
type UserService struct {
repo *repository.UserRepository
}
func NewUserService(repo *repository.UserRepository) *UserService {
return &UserService{repo: repo}
}
func (s *UserService) Register(ctx context.Context, email, name, password string) (*model.User, error) {
email = strings.ToLower(strings.TrimSpace(email))
existing, _ := s.repo.FindByEmail(ctx, email)
if existing != nil {
return nil, errors.New("email already registered")
}
hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
u := &model.User{
Email: email,
Name: strings.TrimSpace(name),
Password: string(hashed),
}
if err := s.repo.Create(ctx, u); err != nil {
return nil, err
}
return u, nil
}
func (s *UserService) Get(ctx context.Context, id uint64) (*model.User, error) {
return s.repo.FindByID(ctx, id)
}
func (s *UserService) List(ctx context.Context, limit, offset int) ([]model.User, error) {
return s.repo.List(ctx, limit, offset)
}
cmd/api/main.go:
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/redis/go-redis/v9"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"myapp/internal/handler"
httpx "myapp/internal/http"
"myapp/internal/model"
"myapp/internal/repository"
"myapp/internal/service"
)
func main() {
// Database dengan retry
var db *gorm.DB
var err error
dsn := os.Getenv("DATABASE_URL")
for i := 0; i < 30; i++ {
db, err = gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err == nil {
break
}
log.Printf("waiting for db: %v", err)
time.Sleep(2 * time.Second)
}
if err != nil {
log.Fatal("db connection failed: ", err)
}
if err := db.AutoMigrate(&model.User{}); err != nil {
log.Fatal("migrate failed: ", err)
}
// Redis
rdb := redis.NewClient(&redis.Options{Addr: os.Getenv("REDIS_URL")})
_ = rdb
// Layers
userRepo := repository.NewUserRepository(db)
userSvc := service.NewUserService(userRepo)
userHandler := handler.NewUserHandler(userSvc)
// Router
r := httpx.NewRouter(userHandler)
srv := &http.Server{
Addr: ":8080",
Handler: r,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
}
// Graceful shutdown
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Printf("server stopped: %v", err)
}
}()
log.Printf("listening on %s", srv.Addr)
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("shutting down...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Fatal(err)
}
}
Middleware Bawaan Chi #
Chi punya paket middleware resmi yang sangat lengkap.
| Middleware | Fungsi |
|---|---|
RequestID |
Generate/extract X-Request-ID |
RealIP |
Ambil IP asli dari X-Forwarded-For |
Logger |
Log setiap request |
Recoverer |
Tangkap panic, return 500 |
Timeout |
Timeout handler setelah durasi tertentu |
URLFormat |
Format URL: /users.{format} |
Compress |
Gzip response |
ContentCharset |
Validasi Content-Type charset |
GetHead |
Auto-handle HEAD untuk GET |
StripSlashes |
Hapus trailing slash |
Heartbeat |
Endpoint /healthz untuk liveness |
Throttle |
Batasi concurrent request |
WithValue |
Inject value ke context |
Kombinasi yang aman untuk production API:
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.Timeout(60 * time.Second))
r.Use(middleware.Compress(5, "gzip"))
r.Use(middleware.Heartbeat("/healthz"))
RequestID paling awal agar ID bisa di-log. RealIP sebelum Logger agar IP yang di-log adalah IP asli. Recoverer sebelum Logger agar panic juga ter-log. Timeout di akhir agar timeout mencakup handler, bukan middleware.
Routing Kompleks dengan Chi #
Salah satu kekuatan Chi adalah grouping dan sub-router.
r := chi.NewRouter()
// Public routes
r.Get("/", listLanding)
r.Get("/healthz", healthz)
r.Handle("/metrics", promhttp.Handler())
// API versioning
r.Route("/api/v1", func(r chi.Router) {
// Public endpoints
r.Group(func(r chi.Router) {
r.Post("/auth/login", authHandler.Login)
r.Post("/auth/register", authHandler.Register)
})
// Protected endpoints
r.Group(func(r chi.Router) {
r.Use(authMiddleware)
r.Route("/users", func(r chi.Router) {
r.Get("/", userHandler.List)
r.Post("/", userHandler.Create)
r.Route("/{id}", func(r chi.Router) {
r.Get("/", userHandler.Get)
r.Put("/", userHandler.Update)
r.Delete("/", userHandler.Delete)
})
})
r.Route("/posts", func(r chi.Router) {
r.Get("/", postHandler.List)
r.Post("/", postHandler.Create)
})
})
})
Pola r.Route("/path", func(r chi.Router) { ... }) membuat sub-router dengan path prefix sendiri. r.Group(func(r chi.Router) { ... }) membuat group dengan middleware tambahan tanpa mengubah path. Kombinasi keduanya menghasilkan struktur route yang sangat ekspresif.
Middleware Kustom #
Karena Chi hanya menambahkan routing di atas http.Handler, middleware kustom sangat mudah ditulis.
package http
import (
"context"
"net/http"
"time"
)
type ctxKey string
const (
ctxKeyRequestID ctxKey = "requestID"
ctxKeyUserID ctxKey = "userID"
)
func RequestTime(header string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
w.Header().Set(header, time.Since(start).String())
})
}
}
func RequireAuth(secret string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
if token == "" {
http.Error(w, "missing token", http.StatusUnauthorized)
return
}
// validasi token (JWT, session, dll)
userID, err := validateToken(token, secret)
if err != nil {
http.Error(w, "invalid token", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), ctxKeyUserID, userID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// Pakai
r.Use(RequestTime("X-Response-Time"))
r.Group(func(r chi.Router) {
r.Use(RequireAuth(os.Getenv("JWT_SECRET")))
r.Get("/me", meHandler)
})
Fungsi middleware Chi mengikuti pola func(http.Handler) http.Handler — sama persis dengan standar library. Ini membuat mereka kompatibel dengan framework Go lain.
Build dan Run #
# Build image
docker compose build
# Jalankan
docker compose up -d
# Lihat log
docker compose logs -f api
# Stop
docker compose down
Akses:
- API: http://localhost:8080
- PostgreSQL:
localhost:5432 - Redis:
localhost:6379
Test:
# Register
curl -X POST http://localhost:8080/api/v1/users \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","name":"Andi","password":"password123"}'
# Get user
curl http://localhost:8080/api/v1/users/1
# List users
curl "http://localhost:8080/api/v1/users?limit=10&offset=0"
OpenTelemetry dan Prometheus #
Karena Chi adalah http.Handler, integrasi observability standar Go langsung jalan.
import (
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
r := chi.NewRouter()
// OpenTelemetry: instrument semua handler
r.Use(otelhttp.Middleware("my-app"))
// Prometheus: expose metrics
r.Handle("/metrics", promhttp.Handler())
Untuk tracing span custom:
func (h *UserHandler) Get(w http.ResponseWriter, r *http.Request) {
tracer := otel.Tracer("myapp/handler")
ctx, span := tracer.Start(r.Context(), "UserHandler.Get")
defer span.End()
// ... business logic
span.SetAttributes(attribute.Int64("user.id", int64(id)))
}
Span otomatis dikirim ke Jaeger/Tempo/Honeycomb. Tanpa instrumentasi custom, setiap HTTP request sudah di-trace lewat otelhttp.Middleware.
Kapan Chi Tepat #
Pakai Chi jika:
✓ Mau router ringan, bukan full framework
✓ Butuh kompatibilitas net/http 100%
✓ Pakai middleware ekosistem Go standar
✓ Microservice yang sangat minimal
✓ Tim ingin belajar Go idiomatic
Hindari Chi jika:
✗ Butuh fitur framework lengkap (validation, ORM, dll)
✗ Suka API yang deklaratif seperti Express
✗ Butuh performa lebih tinggi dari stdlib (Gin/fasthttp)
Chi jujur tentang apa dirinya: router, bukan framework. Ia tidak membawa ORM, validator, atau template engine. Itu semua dependency terpisah yang kamu pilih. Untuk proyek yang ingin “stay close to the metal” tapi tetap punya routing yang powerful, Chi adalah sweet spot.
Best Practice #
Cache Dependency Module #
Selalu copy go.mod + go.sum lebih dulu. Build inkremental super cepat.
Pakai Air untuk Hot Reload #
Tanpa hot reload, Go terasa lambat untuk iterasi. Air memantau file dan rebuild otomatis.
Pakai r.Route dan r.Group untuk Organisasi
#
Jangan tulis semua route flat di satu fungsi. Group berdasarkan resource (users, posts) dan versi (v1, v2). Middleware bisa dipasang per-group.
Manfaatkan Middleware Ekosistem #
Chi kompatibel dengan middleware net/http manapun. Pakai library populer:
github.com/go-chi/corsuntuk CORSgithub.com/go-chi/httprateuntuk rate limitgithub.com/prometheus/client_golanguntuk metricsgo.opentelemetry.io/contrib/instrumentation/net/http/otelhttpuntuk tracing
Tulis Middleware Kustom untuk Hal Spesifik #
Butuh request time logging? Tulis middleware sendiri. Butuh feature flag? Tulis middleware. Pola func(http.Handler) http.Handler sederhana dan idiom Go.
Bind Mount Source Code di Dev #
Pakai volumes di Compose untuk mount source code ke container. Container pakai Air untuk rebuild.
Healthcheck untuk Dependency #
PostgreSQL dan Redis harus punya healthcheck. Service api pakai depends_on: condition: service_healthy. Tambahkan retry loop di kode Go.
Troubleshooting #
Air Tidak Detect Perubahan #
Pastikan bind mount include direktori cmd/ dan internal/. Naikkan delay di .air.toml kalau rebuild terlalu sering.
Database Connection Refused #
Pakai depends_on: condition: service_healthy di Compose, dan tambahkan retry loop di kode Go.
Chi Middleware Tidak Jalan #
Pastikan middleware di-register dengan r.Use() sebelum route. r.Use() setelah r.Get() tidak akan mengeksekusi middleware untuk route yang dideklarasikan sebelumnya.
Chi vs net/http Standar #
Go 1.22+ memperkenalkan enhanced http.ServeMux dengan pattern matching dan method routing. Untuk service yang sangat minimal, pertimbangkan net/http murni dulu. Tambahkan Chi hanya kalau butuh grouping, middleware composition, atau sub-router.
Ringkasan #
- Chi ideal untuk service Go yang ingin tetap idiomatic dan dekat dengan
net/httpstandar.- Chi adalah router, bukan framework — tidak membawa ORM, validator, atau template engine. Pilih dependency sendiri sesuai kebutuhan.
- Kompabilitas
net/http100% — middleware ekosistem Go standar (cors, httprate, prometheus, otelhttp) langsung jalan tanpa adaptor.- Dockerfile multi-stage untuk production: builder stage dengan Go toolchain, runtime stage ramping. Image akhir < 20 MB.
- Dockerfile.dev untuk development: full toolchain + Air untuk hot reload.
- Hot reload dengan Air: pantau file
.go, rebuild, restart. Konfigurasi lewat.air.toml.- Layer cache Go: copy
go.mod+go.sumdulu, jalankango mod download, baru copy source.- Routing ekspresif dengan
r.Route()untuk sub-router danr.Group()untuk middleware group.- Middleware built-in: RequestID, RealIP, Logger, Recoverer, Timeout, Compress, Heartbeat, Throttle.
- Middleware kustom mengikuti pola
func(http.Handler) http.Handler— standar library.- Observability: OpenTelemetry lewat
otelhttp.Middleware, Prometheus lewatpromhttp.Handler(). Tanpa konfigurasi khusus, langsung jalan.- Pakai Chi jika mau router ringan dengan kompatibilitas
net/http100%. Hindari jika butuh fitur framework lengkap atau API deklaratif.- Best practice: cache dependency, hot reload, organize routes dengan
r.Route/r.Group, manfaatkan middleware ekosistem, tulis middleware kustom untuk hal spesifik.- Alternatif: Gin untuk full framework, Fiber untuk API Express-like, net/http standar untuk service paling minimal.