Kontrak API Go yang longgar sering menjadi sumber nil check berlebihan di handler, service, dan middleware auth. Masalahnya bukan sekadar gaya penulisan Go, melainkan desain batas antar-layer yang tidak tegas: field wajib dibungkus pointer, context auth bisa ada atau tidak tanpa aturan jelas, dan request integrasi mencampur arti missing, null, dan zero value.
Solusinya bukan menambah lebih banyak if x == nil, tetapi memperbaiki kontrak API. Di artikel ini, kita terjemahkan ide bahwa excessive nil pointer checks usually indicate a design problem ke praktik backend Go: kapan memakai pointer vs value pada DTO, bagaimana memodelkan field opsional dengan benar, dan bagaimana kontrak yang lebih tegas menyederhanakan alur auth, idempotency key, serta verifikasi webhook.
Mengapa nil check berlebih muncul di layer auth
Di banyak codebase Go, alur request terlihat seperti ini:
- Middleware mencoba membaca token.
- Jika token ada dan valid, user dimasukkan ke context.
- Handler membaca user dari context sebagai pointer.
- Service menerima pointer user, pointer idempotency key, pointer payload, lalu memeriksa satu per satu apakah
nil.
Hasilnya, setiap layer mengulang pertanyaan yang sama: apakah data ini sebenarnya wajib atau opsional?
Contoh bug yang umum:
type AuthInfo struct {
UserID *string
Scope *[]string
}
func (h *Handler) CreateOrder(w http.ResponseWriter, r *http.Request) {
auth := GetAuthInfo(r.Context())
if auth == nil || auth.UserID == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
var req *CreateOrderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if req == nil || req.Amount == nil {
http.Error(w, "amount required", http.StatusBadRequest)
return
}
err := h.service.CreateOrder(r.Context(), auth.UserID, req)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}Masalah di atas bukan hanya banyak branch. Ada kontrak yang kabur:
- Apakah endpoint ini wajib login? Jika ya, mengapa
AuthInfomasih bolehnil? - Apakah
UserIDwajib ada bila auth valid? Jika ya, mengapa pointer? - Apakah body request boleh kosong? Jika tidak, mengapa decode ke
*CreateOrderRequest? - Apakah
Amountwajib? Jika ya, mengapa pointer?
Ketika kontrak tidak tegas, nil check menyebar ke mana-mana dan menutupi logika bisnis yang sebenarnya.
Prinsip dasar kontrak API Go yang lebih tegas
1. Gunakan value untuk data yang wajib ada
Jika sebuah nilai wajib ada setelah tahap tertentu, representasikan sebagai value, bukan pointer. Contoh:
- User yang sudah lolos middleware auth seharusnya punya
UserID string, bukan*string. - Request yang sudah lolos parsing dan validasi seharusnya punya field wajib sebagai value.
- Service method sebaiknya menerima argumen yang sudah valid, bukan struktur mentah yang masih penuh kemungkinan
nil.
2. Pakai pointer hanya bila memang perlu membedakan “tidak dikirim”
Pada JSON request, pointer berguna bila Anda harus membedakan:
- field tidak ada sama sekali,
- field dikirim sebagai
null, - field dikirim dengan zero value seperti
0,false, atau string kosong.
Jika pembedaan ini tidak diperlukan, value biasa lebih sederhana dan lebih aman.
3. Pisahkan DTO transport dari model domain/input service
Request/response HTTP tidak harus sama dengan input service internal. DTO HTTP boleh memakai pointer untuk menangkap nuansa payload, lalu setelah validasi dipetakan ke struktur domain yang lebih ketat dan bebas dari nil.
4. Auth wajib seharusnya ditegakkan oleh middleware, bukan diulang di handler
Jika endpoint privat, buat middleware yang menjamin context selalu memuat principal valid. Handler tidak perlu lagi memeriksa nil untuk data yang menurut kontrak sudah wajib tersedia.
Pointer vs value pada request dan response
Kapan memakai value pada request
Pakai value jika field:
- wajib dikirim,
- tidak perlu dibedakan antara missing dan zero value,
- secara bisnis memang harus selalu ada.
Contoh request pembuatan resource:
type CreateOrderRequest struct {
ProductID string `json:"product_id"`
Amount int64 `json:"amount"`
}Dengan bentuk ini, ProductID dan Amount tidak pernah nil. Validasi fokus pada isi, misalnya ProductID != "" dan Amount > 0.
Ini lebih jelas daripada:
type CreateOrderRequest struct {
ProductID *string `json:"product_id"`
Amount *int64 `json:"amount"`
}Bentuk pointer di atas membuat semua consumer internal dipaksa menghadapi keadaan yang sebenarnya tidak valid secara bisnis.
Kapan memakai pointer pada request
Pakai pointer jika endpoint memang mendukung partial update atau membutuhkan pembedaan antar status field.
Contoh PATCH profil:
type PatchProfileRequest struct {
DisplayName *string `json:"display_name"`
MarketingOptIn *bool `json:"marketing_opt_in"`
}Di sini:
nilberarti field tidak dikirim, jangan ubah data lama.""padaDisplayNameberarti client sengaja mengosongkan nama.falsepadaMarketingOptInberarti client sengaja mematikan opsi, berbeda dari field tidak dikirim.
Ini penggunaan pointer yang masuk akal karena ada kebutuhan semantik yang nyata.
Bagaimana dengan null?
Perlu dibedakan dua hal:
- Field tidak ada: key JSON tidak dikirim.
- Field null: key ada dengan nilai
null.
Pada banyak kasus API, Anda tidak perlu membedakan keduanya. Jika memang perlu, pointer saja belum selalu cukup, karena hasil unmarshal dapat menyamakan beberapa keadaan. Untuk kasus seperti itu, gunakan tipe khusus yang merekam status field secara eksplisit, atau lakukan decoding yang lebih terkontrol.
Namun untuk mayoritas endpoint internal dan integrasi umum, aturan praktis ini cukup:
- value untuk field wajib,
- pointer untuk field opsional atau partial update,
- tipe khusus hanya jika perlu membedakan missing vs
nullsecara eksplisit.
Response: lebih baik sederhana dan stabil
Pada response, pointer sering dipakai terlalu agresif. Jika field selalu ada menurut kontrak, kirim sebagai value. Ini membuat API lebih stabil dan memudahkan client.
Contoh yang baik:
type OrderResponse struct {
ID string `json:"id"`
Status string `json:"status"`
Amount int64 `json:"amount"`
CreatedAt string `json:"created_at"`
}Gunakan pointer di response hanya jika:
- field benar-benar opsional,
- ketiadaan field punya makna,
- Anda ingin menghindari mengirim sub-objek yang tidak tersedia.
Jika terlalu banyak field response yang berupa pointer, client integrasi akan menulis banyak null-check yang seharusnya tidak perlu.
Membedakan field wajib, opsional, null, dan zero value
Masalah terbesar biasanya bukan teknis encoding, tetapi definisi kontrak yang tidak tertulis. Sebelum menentukan pointer atau value, jawab empat pertanyaan ini untuk setiap field:
- Apakah field wajib dikirim?
- Jika tidak dikirim, apa artinya?
- Jika dikirim sebagai
null, apa artinya? Apakah diizinkan? - Jika dikirim dengan zero value, apakah itu valid atau dianggap kosong?
Contoh tabel keputusan yang berguna saat desain:
- Wajib: gunakan value + validasi.
- Opsional, tidak perlu bedakan missing/null: pointer atau tipe nullable sederhana, tergantung kebutuhan serialisasi.
- Partial update: pointer agar bisa membedakan “tidak dikirim” dari “dikirim dengan nilai tertentu”.
- Perlu bedakan missing vs null: gunakan tipe field kustom dengan status eksplisit.
Contoh tipe kustom untuk field yang perlu status eksplisit:
type OptionalString struct {
Set bool
Null bool
Value string
}
func (o *OptionalString) UnmarshalJSON(data []byte) error {
o.Set = true
if string(data) == "null" {
o.Null = true
o.Value = ""
return nil
}
return json.Unmarshal(data, &o.Value)
}Tipe seperti ini tidak perlu dipakai di semua tempat. Gunakan hanya pada endpoint yang benar-benar memerlukan semantik tersebut, misalnya sinkronisasi profil ke sistem pihak ketiga yang membedakan “hapus field” dari “jangan ubah field”.
Refactor alur auth: dari kontrak longgar ke kontrak tegas
Contoh desain yang rapuh
type Principal struct {
UserID *string
Email *string
}
func AuthFromContext(ctx context.Context) *Principal {
v := ctx.Value(principalKey{})
if v == nil {
return nil
}
p, _ := v.(*Principal)
return p
}Dengan desain ini, semua handler harus menebak-nebak apakah principal ada, apakah UserID ada, dan apakah email ada. Padahal untuk endpoint privat, pertanyaan itu seharusnya sudah selesai di middleware.
Desain yang lebih tegas
type Principal struct {
UserID string
Email string
}
type contextKey struct{}
func WithPrincipal(ctx context.Context, p Principal) context.Context {
return context.WithValue(ctx, contextKey{}, p)
}
func PrincipalFromContext(ctx context.Context) (Principal, bool) {
p, ok := ctx.Value(contextKey{}).(Principal)
return p, ok
}
func RequirePrincipal(ctx context.Context) Principal {
p, ok := PrincipalFromContext(ctx)
if !ok {
panic("principal missing in authenticated route")
}
return p
}Di sini ada dua pola pemakaian:
PrincipalFromContextuntuk route publik yang boleh anonymous.RequirePrincipaluntuk route yang menurut kontrak harus sudah diautentikasi.
Intinya bukan harus panic di semua aplikasi, tetapi menyediakan API internal yang tegas. Jika route dijaga middleware auth wajib, handler tidak perlu lagi menulis pemeriksaan null yang berulang.
Middleware auth yang memegang kontrak
func Authenticated(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := extractBearerToken(r.Header.Get("Authorization"))
if token == "" {
http.Error(w, "missing bearer token", http.StatusUnauthorized)
return
}
principal, err := verifyToken(token)
if err != nil {
http.Error(w, "invalid token", http.StatusUnauthorized)
return
}
if principal.UserID == "" {
http.Error(w, "invalid principal", http.StatusUnauthorized)
return
}
next.ServeHTTP(w, r.WithContext(WithPrincipal(r.Context(), principal)))
})
}Setelah titik ini, handler privat boleh mengasumsikan principal valid sudah ada.
Handler sebelum dan sesudah refactor
Sebelum:
func (h *Handler) Me(w http.ResponseWriter, r *http.Request) {
p := AuthFromContext(r.Context())
if p == nil || p.UserID == nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
user, err := h.svc.GetProfile(r.Context(), *p.UserID)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
writeJSON(w, user)
}Sesudah:
func (h *Handler) Me(w http.ResponseWriter, r *http.Request) {
p := RequirePrincipal(r.Context())
user, err := h.svc.GetProfile(r.Context(), p.UserID)
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
writeJSON(w, user)
}Refactor ini sederhana, tetapi dampaknya besar: handler lebih fokus pada use case, bukan pada kemungkinan state yang seharusnya tidak valid.
DTO transport vs input service: tempat terbaik untuk validasi
Salah satu cara paling efektif mengurangi nil check adalah memisahkan:
- DTO HTTP: menangkap bentuk payload mentah.
- Input service/domain: bentuk data yang sudah valid dan siap diproses.
Contoh:
type CreatePaymentRequest struct {
Amount int64 `json:"amount"`
Currency string `json:"currency"`
IdempotencyKey string `json:"-"`
Description *string `json:"description"`
}
type CreatePaymentInput struct {
UserID string
Amount int64
Currency string
IdempotencyKey string
Description string
}
func (r CreatePaymentRequest) Validate() error {
if r.Amount <= 0 {
return errors.New("amount must be > 0")
}
if r.Currency == "" {
return errors.New("currency is required")
}
if r.IdempotencyKey == "" {
return errors.New("idempotency key is required")
}
return nil
}
func (r CreatePaymentRequest) ToInput(userID string) CreatePaymentInput {
input := CreatePaymentInput{
UserID: userID,
Amount: r.Amount,
Currency: r.Currency,
IdempotencyKey: r.IdempotencyKey,
}
if r.Description != nil {
input.Description = *r.Description
}
return input
}Service lalu menerima CreatePaymentInput yang lebih bersih:
func (s *Service) CreatePayment(ctx context.Context, in CreatePaymentInput) error {
// tidak perlu cek in.UserID == nil, in.Amount == nil, dst.
// kontrak input sudah tegas.
return nil
}Pola ini memindahkan kompleksitas ke satu titik yang tepat: tahap parsing dan validasi request. Setelah lolos, layer berikutnya tidak lagi dibebani keadaan yang tidak sah.
Dampak pada idempotency key
Idempotency key sering diperlakukan sebagai header opsional, lalu service dipaksa bercabang:
func (s *Service) CreatePayment(ctx context.Context, userID string, key *string, req *CreatePaymentRequest) error {
if key != nil && *key != "" {
// use idempotency
} else {
// create without idempotency
}
return nil
}Desain ini rawan karena tidak jelas endpoint mana yang mewajibkan idempotency. Untuk operasi yang berpotensi diduplikasi oleh retry client, proxy, atau network timeout, lebih baik kontraknya eksplisit.
Pola yang lebih aman
- Jika endpoint wajib idempotent, jadikan
Idempotency-Keysebagai syarat request. - Validasi header di handler atau middleware khusus.
- Suntikkan hasilnya ke input service sebagai string non-kosong.
Contoh:
func readRequiredIdempotencyKey(r *http.Request) (string, error) {
key := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
if key == "" {
return "", errors.New("missing Idempotency-Key header")
}
return key, nil
}
func (h *Handler) CreatePayment(w http.ResponseWriter, r *http.Request) {
p := RequirePrincipal(r.Context())
var req CreatePaymentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
key, err := readRequiredIdempotencyKey(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
req.IdempotencyKey = key
if err := req.Validate(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := h.svc.CreatePayment(r.Context(), req.ToInput(p.UserID)); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
}Keuntungan pendekatan ini:
- kontrak integrasi lebih jelas bagi client,
- service tidak perlu memutuskan apakah akan idempotent atau tidak berdasarkan pointer/header kosong,
- debugging retry dan duplikasi request menjadi lebih mudah.
Dampak pada webhook verifier
Pada webhook, kontrak yang kabur juga sering memicu nil check berlebihan. Contoh umum:
- signature header kadang diambil, kadang tidak,
- body dibaca setelah stream habis,
- event type dianggap opsional padahal wajib untuk routing.
Untuk webhook, sebaiknya layer verifikasi menghasilkan objek event yang sudah tervalidasi, bukan payload mentah yang masih penuh kemungkinan kosong.
Contoh desain yang lebih tegas
type VerifiedWebhook struct {
Provider string
EventID string
EventType string
Body []byte
}
func VerifyWebhook(r *http.Request, secret string) (VerifiedWebhook, error) {
sig := strings.TrimSpace(r.Header.Get("X-Signature"))
if sig == "" {
return VerifiedWebhook{}, errors.New("missing signature header")
}
body, err := io.ReadAll(r.Body)
if err != nil {
return VerifiedWebhook{}, errors.New("failed to read body")
}
if err := verifySignature(body, sig, secret); err != nil {
return VerifiedWebhook{}, errors.New("invalid signature")
}
var payload struct {
ID string `json:"id"`
Type string `json:"type"`
}
if err := json.Unmarshal(body, &payload); err != nil {
return VerifiedWebhook{}, errors.New("invalid payload")
}
if payload.ID == "" || payload.Type == "" {
return VerifiedWebhook{}, errors.New("missing required webhook fields")
}
return VerifiedWebhook{
Provider: "example",
EventID: payload.ID,
EventType: payload.Type,
Body: body,
}, nil
}Handler webhook kemudian menerima hasil verifikasi sebagai objek dengan field wajib non-pointer:
func (h *Handler) Webhook(w http.ResponseWriter, r *http.Request) {
wh, err := VerifyWebhook(r, h.webhookSecret)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := h.svc.HandleWebhook(r.Context(), wh); err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}Dengan pola ini, service tidak perlu lagi menanyakan apakah event ID ada, apakah type ada, atau apakah signature sempat diverifikasi.
Bug nyata yang sering muncul karena kontrak longgar
1. False negative pada auth
Middleware berhasil memuat user, tetapi karena UserID pointer dan ada jalur yang lupa mengisinya, handler mengembalikan 401 secara acak. Bug ini sulit dilacak karena auth “terlihat” berhasil, namun kontrak principal tidak pernah dijaga dengan ketat.
2. Zero value dianggap sama dengan field tidak dikirim
Pada endpoint PATCH, false atau 0 tidak pernah tersimpan karena request memakai value biasa tanpa cara membedakan field yang sengaja dikirim dengan zero value dari field yang tidak dikirim.
3. Nil panic pada integrasi pihak ketiga
Response dari provider diasumsikan selalu punya sub-objek tertentu, padahal kontrak tidak menjaminnya. Karena DTO internal langsung memakai pointer di mana-mana tanpa validasi satu pintu, panic terjadi jauh di downstream logic.
4. Service memikul logika transport
Service domain ikut memeriksa header kosong, body kosong, signature kosong, dan context auth kosong. Ini membuat service sulit diuji dan mencampur concern HTTP dengan logika bisnis.
Validasi yang membantu, bukan menambah kompleksitas
Validasi yang baik harus menegakkan kontrak lebih awal. Beberapa praktik yang berguna:
- Validasi request segera setelah decode.
- Jangan meneruskan DTO mentah ke service bila masih mengandung state ambigu.
- Bangun input service yang bebas dari pointer untuk field wajib.
- Bedakan error 400, 401, 403, dan 422 sesuai kontrak API Anda.
Yang penting, validasi bukan alasan untuk memakai pointer di semua field. Jika field wajib, value + validasi biasanya cukup dan lebih sederhana.
Catatan praktis: Jika Anda memakai library validasi struct, tetap pikirkan semantik field lebih dulu. Library validasi membantu memeriksa aturan, tetapi tidak otomatis memperbaiki desain kontrak yang salah.
Kapan pointer tetap pilihan yang benar
Artikel ini bukan berarti pointer harus dihindari total. Pointer tetap tepat untuk beberapa kasus:
- partial update,
- field opsional yang memang boleh tidak ada,
- response dengan sub-resource yang benar-benar opsional,
- integrasi dengan API eksternal yang semantiknya membedakan state field secara eksplisit,
- optimisasi tertentu saat menunda alokasi objek besar, meski ini jarang jadi alasan utama pada DTO kecil.
Yang perlu dihindari adalah pointer sebagai default tanpa alasan semantik yang jelas.
Trade-off dan keterbatasan
Kontrak tegas bisa menambah tahap mapping
Memisahkan DTO dari input service berarti ada kode konversi tambahan. Namun biaya ini biasanya sepadan karena menurunkan kompleksitas di layer lain dan memusatkan validasi di satu tempat.
Tipe kustom untuk missing/null menambah kompleksitas
Jika Anda perlu membedakan missing vs null, implementasi custom unmarshal memang lebih rumit. Gunakan hanya saat benar-benar dibutuhkan oleh kontrak bisnis atau integrasi.
Route publik tetap butuh jalur opsional
Tidak semua endpoint bisa memakai RequirePrincipal. Untuk endpoint publik dengan personalisasi opsional, sediakan API yang jelas untuk membaca principal bila ada, tanpa memaksakan semua handler menjadi privat.
Tips debugging bila masih banyak nil check
- Lacak asal setiap
nil: apakah dari decode JSON, context auth, header, atau response provider eksternal. - Tandai setiap argumen pointer di service. Tanyakan apakah pointer itu benar-benar merepresentasikan state yang sah.
- Periksa apakah handler meneruskan DTO mentah ke domain tanpa validasi dan mapping.
- Cek route mana yang sebenarnya wajib auth tetapi masih memakai helper context yang opsional.
- Untuk webhook, pastikan verifikasi signature dan parsing event dilakukan sebelum logika bisnis.
Sering kali, satu refactor kecil pada batas layer bisa menghapus banyak nil check sekaligus.
Checklist review kontrak API agar integrasi tidak rapuh
- Apakah setiap field request sudah jelas statusnya: wajib, opsional, nullable, atau partial update?
- Apakah field wajib memakai value, bukan pointer?
- Apakah pointer hanya dipakai saat perlu membedakan state field?
- Apakah request HTTP dipisahkan dari input service/domain?
- Apakah validasi dilakukan sebelum masuk ke service?
- Apakah middleware auth menjamin principal valid untuk route privat?
- Apakah service menerima principal/user ID sebagai value yang sudah valid?
- Apakah endpoint yang butuh idempotency mewajibkan header-nya secara eksplisit?
- Apakah webhook verifier mengembalikan event yang sudah tervalidasi, bukan payload ambigu?
- Apakah response API cukup stabil sehingga client tidak perlu null-check berlebihan?
- Apakah dokumentasi API menjelaskan arti field tidak ada,
null, kosong,0, danfalse? - Apakah test mencakup kasus missing field, zero value, null, auth gagal, idempotency key hilang, dan signature webhook salah?
Penutup
Kontrak API Go yang baik mengurangi nil check bukan dengan trik sintaks, tetapi dengan batas layer yang jelas. Data yang wajib ada seharusnya direpresentasikan sebagai value setelah melewati tahap auth, parsing, dan validasi. Pointer dipakai saat memang ada kebutuhan semantik untuk membedakan state field.
Jika handler dan service Anda penuh dengan if x == nil, sering kali akar masalahnya adalah kontrak yang belum tegas. Mulailah dari titik masuk request: tentukan mana yang wajib, mana yang opsional, siapa yang bertanggung jawab menegakkan auth, dan kapan payload mentah harus diubah menjadi input yang valid. Dari sana, alur auth dan integrasi biasanya menjadi jauh lebih sederhana dan lebih tahan terhadap bug.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!