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:

LapisanJenis error yang dihasilkanKewajiban pada error
Repositoryerror domain (sentinel) dan error teknis (SQL, driver)bungkus dengan konteks query, jaga penyebab asli
Serviceerror domain hasil bisnistambah konteks operasi, jangan menyaring detail
Handlererror API publikpetakan 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 rantaiRespons APIKeterangan
ErrRekamanTidakAda404, “pengguna tidak ditemukan”kondisi normal, aman diungkap
ErrorValidasi (field kosong)400, pesan per fieldkesalahan klien, detail field membantu
ErrorSQL atau error tak dikenal500, “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:

go
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:

bash
go run boundary-demo.go

Output yang Dihasilkan:

text
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 internal

Mari kita telaah baris demi baris program di atas:

  1. 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.
  2. Baris 25-33 (type ErrorAPI): error publik hanya membawa kode status dan pesan aman; tidak ada field yang bisa membocorkan infrastruktur.
  3. Baris 35-43 (func repoAmbilPengguna): repository menghasilkan dua jenis kegagalan — sentinel domain ErrRekamanTidakAda dan error teknis ErrorSQL — keduanya dengan konteks lapisan.
  4. Baris 45-51 (func serviceAmbilPengguna): service hanya menambah konteks operasi lewat %w; penyaringan bukan tugasnya karena service tidak tahu kontrak publik.
  5. Baris 53-60 (func handlerAmbilPengguna): inilah boundary-nya — errors.Is memetakan sentinel domain ke respons 404, sisanya dicatat penuh ke log internal lalu dijawab respons generik 500.
  6. Baris 66-72 (func main): tiga skenario berjalan: sukses, not found yang terpetakan, dan error SQL yang detailnya hanya tampak pada baris log internal, bukan pada respons 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 #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

boundary-demo.go

Unduh berkas
go
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)
		}
	}
}