Kontrak Status yang Menempel pada Query, Bukan Parameter
Detail organisasi publik kini memakai method GetActiveByID dengan klausa status di query: baris hilang dan baris non-AKTIF sama-sama 404.
Ringkasan
Organisasi dengan id 17 berstatus NONAKTIF. Permintaan GET /organizations/17 membalas 404. Endpoint admin masih bisa membuka organisasi tersebut.
Baris organizations dengan id 17 berstatus NONAKTIF. Permintaan GET /organizations/17 dari halaman informasi publik membalas 404. Id yang sama dibuka lagi lewat GET /admin/organizations/17 dengan akun pemegang izin organisasi:read, dan responsnya 200 berisi data lengkap beserta daftar anggotanya.
Dugaan pertama: endpoint publik itu kehilangan filter status. Untuk daftar, modul ini memang memakai satu method List dengan bendera AllStatuses yang dipilih admin atau publik. Untuk detail, commit ini mengambil jalan lain: method tersendiri, dengan klausa status menempel langsung pada string query [4].
Kontrak yang Menempel pada Query
// GetActiveByID returns only a status='AKTIF' organization (public detail).
// Missing and non-AKTIF rows both surface as ErrNotFound.
func (r *mysqlRepository) GetActiveByID(ctx context.Context, id int64) (*Organization, error) {
var org Organization
if err := r.db.GetContext(ctx, &org,
"SELECT * FROM organizations WHERE id = ? AND status = 'AKTIF'", id); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("get active organization: %w", err)
}
return &org, nil
}
// route registration (admin)
r.With(guard("organisasi:read")).Get("/organizations/{id}", h.get)
Perbedaannya ada di siapa yang mengingat filter. Dengan parameter status, setiap pemanggil baru harus membawa nilai yang benar; dengan method baru, klausa status = 'AKTIF' hanya ditulis sekali dan tidak bisa dilepas oleh pemanggil mana pun. MySQL mendefinisikan klausa WHERE sebagai kondisi yang harus dipenuhi baris agar terpilih [4]. Tanpa klausa itu SELECT mengembalikan seluruh baris; di method ini klausanya bagian dari query, bukan argumen.
Baris yang tidak ada dan baris yang tidak AKTIF berhenti di titik error yang sama. database/sql membalas ErrNoRows ketika QueryRow tidak mengembalikan baris apa pun [2], lapisan repository menerjemahkannya ke ErrNotFound, dan handler membalas 404. Satu jalur error untuk dua kondisi yang berbeda.
Di lapisan service, ErrNotFound diteruskan apa adanya, bukan dibungkus ulang menjadi error baru. Handler admin menangkapnya di satu tempat: id yang bukan angka positif dijawab 400, baris yang hilang dijawab 404, gagal koneksi dijawab 500. Pemetaan tiga jalur itu berulang di create, update, delete, dan kini di read detail, sehingga perilaku endpoint baru persis seperti tetangganya.
Di sisi admin, method GetByID lama dipertahankan tanpa filter status, sehingga editor dapat membuka organisasi dengan status apa pun. Guard duduk di pendaftaran rute, bukan di dalam handler: r.With(guard("organisasi:read")).Get("/organizations/{id}", h.get). Chi menyediakan route group dan middleware untuk memasang guard per grup seperti ini [3].
Satu 404 untuk Dua Alasan
RFC 9110 menyatakan 404 berarti origin server tidak menemukan representasi untuk resource target atau tidak bersedia mengungkapkan bahwa representasi itu ada [1]. Spesifikasi yang sama bahkan mengizinkan server menyembunyikan keberadaan resource yang memang dilarang dengan membalas 404 [1]. Karena itu dua kondisi yang berbeda, baris belum ada dan baris sudah tidak aktif, boleh berbagi satu kode respons tanpa dianggap keliru.
Konsekuensinya ada di pengujian. Tiga kasus harus diuji terpisah: id yang tidak ada di tabel, id yang ada dengan status selain AKTIF lewat endpoint publik, dan id yang sama lewat endpoint admin. Dua yang pertama wajib 404, yang ketiga wajib 200. Kalau hanya kasus pertama yang diuji, mengembalikan pemanggilan publik ke GetByID lolos tanpa terdeteksi — perubahan satu baris yang mengembalikan bocoran status ke publik.
Alternatif yang tidak dipilih: memberi GetByID sebuah parameter boolean seperti pada List, lalu membiarkan pemanggil memutuskan sendiri. Pilihan itu menghemat satu method, tetapi memindahkan jaminan ke ingatan pemanggil — satu handler publik yang lupa mengisi parameter sudah cukup untuk membuka baris non-AKTIF. Untuk daftar, bendera itu dipakai di dalam satu method yang sama; untuk detail, jaminannya dipindah ke struktur kode.
Keputusan yang tertinggal dari commit ini: status publik untuk detail bukan parameter, melainkan method. Untuk daftar, satu method dengan bendera masih masuk akal karena satu permintaan melayani dua audiens. Untuk detail, kontraknya dua, jadi querynya juga dua.
Sumber:
- rfc-editor.org/rfc/rfc9110.html (diakses lewat snapshot arsip, 2026-10-07)
- pkg.go.dev/database/sql
- go-chi.io
- dev.mysql.com/doc/refman/8.4/en/select.html (snapshot arsip 2026-10-07)