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:

  1. Middleware mencoba membaca token.
  2. Jika token ada dan valid, user dimasukkan ke context.
  3. Handler membaca user dari context sebagai pointer.
  4. 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 AuthInfo masih boleh nil?
  • Apakah UserID wajib ada bila auth valid? Jika ya, mengapa pointer?
  • Apakah body request boleh kosong? Jika tidak, mengapa decode ke *CreateOrderRequest?
  • Apakah Amount wajib? 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:

  • nil berarti field tidak dikirim, jangan ubah data lama.
  • "" pada DisplayName berarti client sengaja mengosongkan nama.
  • false pada MarketingOptIn berarti 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 null secara 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:

  1. Apakah field wajib dikirim?
  2. Jika tidak dikirim, apa artinya?
  3. Jika dikirim sebagai null, apa artinya? Apakah diizinkan?
  4. 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:

  • PrincipalFromContext untuk route publik yang boleh anonymous.
  • RequirePrincipal untuk 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-Key sebagai 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

  1. Apakah setiap field request sudah jelas statusnya: wajib, opsional, nullable, atau partial update?
  2. Apakah field wajib memakai value, bukan pointer?
  3. Apakah pointer hanya dipakai saat perlu membedakan state field?
  4. Apakah request HTTP dipisahkan dari input service/domain?
  5. Apakah validasi dilakukan sebelum masuk ke service?
  6. Apakah middleware auth menjamin principal valid untuk route privat?
  7. Apakah service menerima principal/user ID sebagai value yang sudah valid?
  8. Apakah endpoint yang butuh idempotency mewajibkan header-nya secara eksplisit?
  9. Apakah webhook verifier mengembalikan event yang sudah tervalidasi, bukan payload ambigu?
  10. Apakah response API cukup stabil sehingga client tidak perlu null-check berlebihan?
  11. Apakah dokumentasi API menjelaskan arti field tidak ada, null, kosong, 0, dan false?
  12. 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.