Aplikasi jarang berdiri satu lapisan: handler HTTP memanggil service, service memanggil repository, repository bicara dengan database. Error hasilnya harus menyeberangi lapisan-lapisan itu — tetapi tidak semua detail pantas ikut menyeberang. Pesan seperti query "SELECT * FROM pengguna" gagal berisi informasi berharga untuk log internal, sekaligus informasi berbahaya untuk respons API publik. Pada chapter ini kita menerapkan error boundary: garis batas tempat error internal dipetakan menjadi error yang aman dikonsumsi pihak luar.
Masalah Error yang Bocor #
Bayangkan endpoint GET /pengguna/p-sql menjawab dengan teks error database mentah. Tiga masalah muncul seketika. Pertama, keamanan: nama tabel, nama kolom, dan jenis driver memberi peta serangan bagi pihak yang tidak berhak. Kedua, coupling: klien yang terbiasa mem-parsing teks pesan akan rusak begitu pesan internal diubah. Ketiga, kebisingan: detail infrastruktur tidak membantu klien mengambil keputusan — yang ia butuhkan hanya “tidak ditemukan” atau “coba lagi nanti”.
Solusinya bukan menyembunyikan error sama sekali, melainkan memisahkan dua konsumen yang berbeda: log internal menerima detail penuh, respons publik menerima pesan terkendali.
Tugas Error di Tiap Lapisan #
Setiap lapisan punya kewajiban berbeda terhadap error yang melintasinya:
| Lapisan | Jenis error yang dihasilkan | Kewajiban pada error |
|---|---|---|
| Repository | error domain (sentinel) dan error teknis (SQL, driver) | bungkus dengan konteks query, jaga penyebab asli |
| Service | error domain hasil bisnis | tambah konteks operasi, jangan menyaring detail |
| Handler | error API publik | petakan error domain, log detail internal, sanitasi pesan |
Sistem berlapis menempatkan pemetaan di lapisan paling luar karena hanya lapisan itu yang tahu bentuk kontrak publik — status HTTP, format pesan, kode kesalahan aplikasi.
Memetakan Error Domain ke Error API #
Pemetaan pada batas biasanya berbentuk tabel kecil yang eksplisit:
| Error domain di rantai | Respons API | Keterangan |
|---|---|---|
ErrRekamanTidakAda | 404, “pengguna tidak ditemukan” | kondisi normal, aman diungkap |
ErrorValidasi (field kosong) | 400, pesan per field | kesalahan klien, detail field membantu |
ErrorSQL atau error tak dikenal | 500, “kesalahan server internal” | detail masuk log, bukan respons |
Pola lengkap desain respons error API — format JSON, kode aplikasi, dan header keamanan — dibahas pada chapter Desain Error API .
Program Utuh #
Berkas boundary-demo.go membangun tiga lapisan tersebut dan memetakan error di batas handler:
package main
import (
"errors"
"fmt"
)
// ErrRekamanTidakAda adalah error domain milik lapisan repository.
var ErrRekamanTidakAda = errors.New("rekaman tidak ditemukan")
// ErrorSQL adalah error internal lapisan repository.
type ErrorSQL struct {
Query string
Sebab error
}
func (e *ErrorSQL) Error() string {
return fmt.Sprintf("query %q gagal: %v", e.Query, e.Sebab)
}
func (e *ErrorSQL) Unwrap() error {
return e.Sebab
}
// ErrorAPI adalah error publik yang aman dikirim ke klien.
type ErrorAPI struct {
Kode int
Pesan string
}
func (e *ErrorAPI) Error() string {
return fmt.Sprintf("status %d: %s", e.Kode, e.Pesan)
}
func repoAmbilPengguna(id string) (string, error) {
switch id {
case "p-404":
return "", fmt.Errorf("repo pengguna: %w", ErrRekamanTidakAda)
case "p-sql":
return "", &ErrorSQL{Query: "SELECT * FROM pengguna", Sebab: errors.New("driver: koneksi direset")}
}
return "Budi Santoso", nil
}
func serviceAmbilPengguna(id string) (string, error) {
nama, err := repoAmbilPengguna(id)
if err != nil {
return "", fmt.Errorf("service pengguna: %w", err)
}
return nama, nil
}
func handlerAmbilPengguna(id string) *ErrorAPI {
nama, err := serviceAmbilPengguna(id)
if err != nil {
if errors.Is(err, ErrRekamanTidakAda) {
return &ErrorAPI{Kode: 404, Pesan: "pengguna tidak ditemukan"}
}
fmt.Println("log internal:", err)
return &ErrorAPI{Kode: 500, Pesan: "kesalahan server internal"}
}
fmt.Printf("200 OK: nama=%q\n", nama)
return nil
}
func main() {
for _, id := range []string{"p-1", "p-404", "p-sql"} {
if apiErr := handlerAmbilPengguna(id); apiErr != nil {
fmt.Printf("respons API: %v\n", apiErr)
}
}
}Jalankan program dari direktori chapter dengan perintah berikut:
go run boundary-demo.goOutput yang Dihasilkan:
200 OK: nama="Budi Santoso"
respons API: status 404: pengguna tidak ditemukan
log internal: service pengguna: query "SELECT * FROM pengguna" gagal: driver: koneksi direset
respons API: status 500: kesalahan server internalMari kita telaah baris demi baris program di atas:
- Baris 11-23 (
type ErrorSQL+Error()+Unwrap()): error internal repository membawa teks query dan penyebab driver — detail yang berguna untuk debugging dan tetap boleh hadir di log. - Baris 25-33 (
type ErrorAPI): error publik hanya membawa kode status dan pesan aman; tidak ada field yang bisa membocorkan infrastruktur. - Baris 35-43 (
func repoAmbilPengguna): repository menghasilkan dua jenis kegagalan — sentinel domainErrRekamanTidakAdadan error teknisErrorSQL— keduanya dengan konteks lapisan. - Baris 45-51 (
func serviceAmbilPengguna): service hanya menambah konteks operasi lewat%w; penyaringan bukan tugasnya karena service tidak tahu kontrak publik. - Baris 53-60 (
func handlerAmbilPengguna): inilah boundary-nya —errors.Ismemetakan sentinel domain ke respons 404, sisanya dicatat penuh ke log internal lalu dijawab respons generik 500. - Baris 66-72 (
func main): tiga skenario berjalan: sukses, not found yang terpetakan, dan error SQL yang detailnya hanya tampak pada barislog internal, bukan padarespons API.
Perhatikan dua baris terakhir output: klien hanya menerima status 500: kesalahan server internal, sementara teks query dan penyebab driver utuh di sisi log. Pemisahan dua konsumen inilah inti error boundary. Satu pertanyaan terakhir menyusun seluruh section ini: begitu error tiba di tangan kita, apa yang sebaiknya dilakukan — kembalikan, bungkus, coba lagi, atau gagal cepat? Chapter penutup, Strategi Error Handling
, merangkum keputusan tersebut.
Referensi #
- Error handling and Go — filosofi error lintas lapisan dari blog resmi Go.
- Working with Errors in Go 1.13 — dasar rantai error yang dipetakan di batas.
- Errors are values — pandangan Rob Pike soal error sebagai nilai yang dapat diprogram.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
boundary-demo.go
Unduh berkaspackage main
import (
"errors"
"fmt"
)
// ErrRekamanTidakAda adalah error domain milik lapisan repository.
var ErrRekamanTidakAda = errors.New("rekaman tidak ditemukan")
// ErrorSQL adalah error internal lapisan repository.
type ErrorSQL struct {
Query string
Sebab error
}
func (e *ErrorSQL) Error() string {
return fmt.Sprintf("query %q gagal: %v", e.Query, e.Sebab)
}
func (e *ErrorSQL) Unwrap() error {
return e.Sebab
}
// ErrorAPI adalah error publik yang aman dikirim ke klien.
type ErrorAPI struct {
Kode int
Pesan string
}
func (e *ErrorAPI) Error() string {
return fmt.Sprintf("status %d: %s", e.Kode, e.Pesan)
}
func repoAmbilPengguna(id string) (string, error) {
switch id {
case "p-404":
return "", fmt.Errorf("repo pengguna: %w", ErrRekamanTidakAda)
case "p-sql":
return "", &ErrorSQL{Query: "SELECT * FROM pengguna", Sebab: errors.New("driver: koneksi direset")}
}
return "Budi Santoso", nil
}
func serviceAmbilPengguna(id string) (string, error) {
nama, err := repoAmbilPengguna(id)
if err != nil {
return "", fmt.Errorf("service pengguna: %w", err)
}
return nama, nil
}
func handlerAmbilPengguna(id string) *ErrorAPI {
nama, err := serviceAmbilPengguna(id)
if err != nil {
if errors.Is(err, ErrRekamanTidakAda) {
return &ErrorAPI{Kode: 404, Pesan: "pengguna tidak ditemukan"}
}
fmt.Println("log internal:", err)
return &ErrorAPI{Kode: 500, Pesan: "kesalahan server internal"}
}
fmt.Printf("200 OK: nama=%q\n", nama)
return nil
}
func main() {
for _, id := range []string{"p-1", "p-404", "p-sql"} {
if apiErr := handlerAmbilPengguna(id); apiErr != nil {
fmt.Printf("respons API: %v\n", apiErr)
}
}
}