API yang baik bisa ditolak dengan sopan: klien tahu persis field mana yang salah, format galat selalu sama, dan galat internal tidak membocorkan isi server. Bagian ini merakit ketiganya dari errors.go dan models.go — galat domain di satu sisi, galat transport di sisi lain, lalu satu fungsi penulis respons yang menyatukan semuanya. Setiap jalur galat diuji oleh api_test.go, dan output eksekusi aslinya kita baca bersama di akhir.

Galat Domain: Sentinel dan ValidationError #

Dua bentuk galat domain hidup berdampingan:

go
// ErrNotFound adalah sentinel error untuk data yang tidak ditemukan di
// penyimpanan. Handler memeriksa galat ini lewat errors.Is untuk
// memilih status 404.
var ErrNotFound = errors.New("data tidak ditemukan")

// ValidationError menjelaskan satu field yang melanggar aturan domain.
// Handler mengumpulkan galat ini menjadi body 400 yang rinci.
type ValidationError struct {
	Field   string
	Message string
}

// Error memenuhi kontrak interface error.
func (e ValidationError) Error() string {
	return fmt.Sprintf("field %s: %s", e.Field, e.Message)
}

ErrNotFound (baris 13) adalah sentinel error: satu nilai global yang diperiksa lewat errors.Is, cocok untuk kondisi tunggal tanpa data tambahan. ValidationError (baris 17–25) adalah error bertipe struct: satu nilai bisa menyimpan field dan pesan, dan pemeriksaannya memakai errors.As. Aturan validasinya sendiri terkumpul rapi di domain:

go
// Validasi memeriksa seluruh aturan domain pada ItemInput dan
// mengembalikan gabungan ValidationError lewat errors.Join. Nilai nil
// berarti masukan lolos validasi.
func (in ItemInput) Validasi() error {
	var galat []error
	name := strings.TrimSpace(in.Name)

	switch {
	case name == "":
		galat = append(galat, ValidationError{Field: "name", Message: "wajib diisi"})
	case utf8.RuneCountInString(name) > namaMaksRune:
		galat = append(galat, ValidationError{Field: "name", Message: "maksimal 100 karakter"})
	}
	if in.Price < 0 {
		galat = append(galat, ValidationError{Field: "price", Message: "tidak boleh negatif"})
	}
	if in.Price > hargaMaks {
		galat = append(galat, ValidationError{Field: "price", Message: "maksimal 1000000000000"})
	}
	return errors.Join(galat...)
}

Baris 37–38 menyiapkan dua hal: penampung galat dan nama yang sudah dipangkas spasi — "" hasil trim menandakan nama kosong. switch pada baris 40–45 memastikan satu field hanya menghasilkan satu pesan (nama kosong dan terlalu panjang tidak pernah terjadi bersamaan). Baris 52 lalu menggabungkan seluruh galat dengan errors.Join, yang mengembalikan nil ketika tidak ada satu pun pelanggaran.

Satu Bentuk Body untuk Semua Galat #

Kontrak bentuk galat dinyatakan sekali, dipakai di mana-mana:

go
// Kode galat yang stabil untuk konsumen API. Kode ini dirancang tidak
// berubah ketika implementasi internal berganti.
const (
	codeNotFound       = "NOT_FOUND"
	codeValidasi       = "VALIDATION_ERROR"
	codeJSONInvalid    = "INVALID_JSON"
	codeArgumenInvalid = "INVALID_ARGUMENT"
	codeBodyKebesaran  = "REQUEST_TOO_LARGE"
	codeMediaType      = "UNSUPPORTED_MEDIA_TYPE"
	codeInternal       = "INTERNAL_ERROR"
)

// errorEnvelope adalah bentuk body galat yang konsisten di seluruh
// endpoint: {"error":{"code":"...","message":"..."}}.
type errorEnvelope struct {
	Error errorBody `json:"error"`
}

type errorBody struct {
	Code    string            `json:"code"`
	Message string            `json:"message"`
	Fields  map[string]string `json:"fields,omitempty"`
}

// galatBody adalah galat transport HTTP yang sudah membawa status dan
// kode galatnya sendiri, misalnya body JSON yang rusak.
type galatBody struct {
	status  int
	code    string
	message string
}

func (e *galatBody) Error() string { return e.message }

Konstanta baris 30–36 adalah kosakata kode galat yang stabil — klien boleh menulis switch atas nilai-nilai ini tanpa takut berubah saat internal direfaktor. Struct errorEnvelope (baris 41–49) membentuk {"error":{"code","message","fields"}}; field fields bersifat omitempty sehingga hanya muncul untuk galat validasi. Fungsi writeJSON (baris 64–68) mengatur Content-Type sebelum WriteHeader — urutan yang salah membuat header hilang, materi yang dibahas mendalam di chapter Response Format .

Penerjemah pusatnya ada di writeError:

go
// writeError menerjemahkan error domain menjadi respons JSON konsisten.
// ValidationError menjadi 400 dengan peta fields; ErrNotFound menjadi
// 404; galat lain menjadi 500 tanpa membocorkan detail internal.
func writeError(w http.ResponseWriter, err error) {
	if fields := petaGalatValidasi(err); len(fields) > 0 {
		writeJSON(w, http.StatusBadRequest, errorEnvelope{Error: errorBody{
			Code:    codeValidasi,
			Message: "masukan tidak lolos validasi",
			Fields:  fields,
		}})
		return
	}
	status, code, message := terjemahkanGalat(err)
	writeJSON(w, status, errorEnvelope{Error: errorBody{Code: code, Message: message}})
}

// terjemahkanGalat memetakan error domain ke status HTTP, kode galat,
// dan pesan yang aman untuk klien.
func terjemahkanGalat(err error) (status int, code, message string) {
	if errors.Is(err, ErrNotFound) {
		return http.StatusNotFound, codeNotFound, err.Error()
	}
	// Pesan galat internal (koneksi, SQL, dsb.) tidak dibocorkan;
	// klien hanya menerima kode INTERNAL_ERROR yang stabil.
	return http.StatusInternalServerError, codeInternal, "galat internal server"
}

Baris 74 memanggil petaGalatValidasi; jika ada field yang melanggar, respons 400 dibangun lengkap dengan peta fields. Baris 82–83 menangani sisa galat lewat terjemahkanGalat: ErrNotFound menjadi 404 dengan pesan aslinya (aman karena hanya berisi nomor item), sedangkan galat lain menjadi 500 dengan pesan generik. Baris 92–94 adalah keputusan keamanan yang disengaja — pesan connection refused atau SQL mentah dari database tidak boleh sampai ke klien.

Membongkas Gabungan errors.Join #

Karena Validasi() mengembalikan gabungan, pengumpulnya perlu menelusuri struktur tersebut:

go
// kumpulkanValidasi menelusuri err secara rekursif. errors.Join
// menghasilkan error dengan method Unwrap() []error, sehingga setiap
// anggota gabungan diperiksa satu per satu.
func kumpulkanValidasi(err error, hasil *map[string]string) {
	if err == nil {
		return
	}
	if joined, ok := err.(interface{ Unwrap() []error }); ok {
		for _, e := range joined.Unwrap() {
			kumpulkanValidasi(e, hasil)
		}
		return
	}
	var ve ValidationError
	if errors.As(err, &ve) {
		if *hasil == nil {
			*hasil = make(map[string]string)
		}
		(*hasil)[ve.Field] = ve.Message
	}
}

Baris 112 memeriksa apakah error memiliki method Unwrap() []error — bentuk yang dihasilkan errors.Join. Setiap anggota diperiksa ulang secara rekursif (baris 113–115), lalu errors.As (baris 118–119) mengambil ValidationError dan baris 123 menyimpannya ke peta. encoding/json mengurutkan kunci map saat marshal, sehingga body selalu deterministik — buktinya terlihat pada demo Bagian 3: "fields":{"name":"wajib diisi","price":"tidak boleh negatif"} selalu terurut alfabetis.

Galat Transport di Pintu Masuk #

Galat yang terjadi sebelum validasi domain — Content-Type salah, body terlalu besar, JSON rusak — dibungkus galatBody supaya membawa status dan kodenya sendiri:

go
// bacaInput memeriksa Content-Type, membatasi ukuran body, lalu
// mendekode JSON ke input sambil menolak field yang tidak dikenal.
// Galat transport dibungkus galatBody agar handler tinggal
// meneruskannya ke tulisGalatBody.
func bacaInput(w http.ResponseWriter, r *http.Request, input *ItemInput) error {
	ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
	if err != nil || ct != "application/json" {
		return &galatBody{
			status:  http.StatusUnsupportedMediaType,
			code:    codeMediaType,
			message: "Content-Type harus application/json",
		}
	}
	r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
	dec := json.NewDecoder(r.Body)
	dec.DisallowUnknownFields()
	if err := dec.Decode(input); err != nil {
		var maxErr *http.MaxBytesError
		switch {
		case errors.As(err, &maxErr):
			return &galatBody{
				status:  http.StatusRequestEntityTooLarge,
				code:    codeBodyKebesaran,
				message: fmt.Sprintf("body melebihi batas %d byte", maxErr.Limit),
			}
		case errors.Is(err, io.EOF):
			return &galatBody{
				status:  http.StatusBadRequest,
				code:    codeJSONInvalid,
				message: "body kosong; kirim JSON seperti {\"name\":\"Buku Go\",\"price\":75000}",
			}
		default:
			return &galatBody{
				status:  http.StatusBadRequest,
				code:    codeJSONInvalid,
				message: "body bukan JSON valid: " + err.Error(),
			}
		}
	}
	return nil
}

Baris 142–149 memeriksa Content-Type dengan mime.ParseMediaType, yang tetap menerima application/json; charset=utf-8 karena parameter charset dipisahkan parse. Baris 150 memasang http.MaxBytesReader — batas 1 MiB dijalankan sambil membaca, dan decoder melaporkannya sebagai *http.MaxBytesError (baris 156–161) yang statusnya 413. Baris 152 mengaktifkan DisallowUnknownFields sehingga ketik salah seperti "nama" ditolak 400 alih-alih diabaikan diam-diam. Baris 162–167 membedakan body kosong (EOF) dari JSON rusak; keduanya berbagi kode INVALID_JSON dengan pesan berbeda. Handler pemanggil tidak perlu memilah: tulisGalatBody cukup memeriksa errors.As(err, &gb) lalu jatuh ke writeError untuk galat domain.

Peta lengkapnya ringkas:

Kode galatStatusPemicu
VALIDATION_ERROR400Aturan domain dilanggar (fields terisi)
INVALID_JSON400Body kosong atau bukan JSON valid
INVALID_ARGUMENT400Segmen {id} bukan bilangan bulat
UNSUPPORTED_MEDIA_TYPE415Content-Type bukan application/json
REQUEST_TOO_LARGE413Body melebihi 1 MiB
NOT_FOUND404Item tidak ada di penyimpanan
INTERNAL_ERROR500Galat tak terduga (tanpa detail)

Bukti dari Test Sungguhan #

Tiga test ini menekan jalur galat paling penting: validasi ganda, tiga variasi transport, dan 404:

bash
go test -v -run 'TestValidasiDitolak|TestBodyTidakValid|TestItemTidakDikenal' ./...

Output yang Dihasilkan:

text
=== RUN   TestValidasiDitolak
--- PASS: TestValidasiDitolak (0.00s)
=== RUN   TestBodyTidakValid
--- PASS: TestBodyTidakValid (0.00s)
=== RUN   TestItemTidakDikenal
--- PASS: TestItemTidakDikenal (0.00s)
PASS
ok  	example.com/rest-api-postgresql	0.008s

Angka durasi pada baris PASS dan ok bervariasi antar komputer, namun nama test dan hasilnya selalu sama. TestValidasiDitolak memastikan peta fields memuat kedua pesan; TestBodyTidakValid membuktikan tiga status berbeda (415, 400, 400); TestItemTidakDikenal memastikan kode NOT_FOUND hadir di body, bukan hanya statusnya.

Dengan pintu masuk yang rapat dan kontrak galat yang stabil, proyek tinggal dibuktikan ujung ke ujung: integration test melawan PostgreSQL sungguhan, race detector, dan pengemasan Docker.

Referensi #

Berkas Sumber

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

api_test.go

Unduh berkas
go
package main

import (
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"time"
)

// waktuUji adalah waktu beku untuk seluruh unit test API.
var waktuUji = time.Date(2026, 1, 15, 8, 0, 0, 0, time.UTC)

// serverUji membangun API lengkap di atas MemoryItemRepository lalu
// menjalankannya di httptest.Server. Tidak ada database dan tidak ada
// port yang perlu dikelola manual.
func serverUji() *httptest.Server {
	svc := NewItemService(NewMemoryItemRepository(), FixedClock{Waktu: waktuUji})
	return httptest.NewServer(NewRouter(svc))
}

// bacaEnvelope mendekode body {"data": ...} menjadi struct.
func bacaEnvelope[T any](t *testing.T, resp *http.Response) T {
	t.Helper()
	var env struct {
		Data T `json:"data"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&env); err != nil {
		t.Fatalf("mendekode body: %v", err)
	}
	return env.Data
}

// bacaGalat mendekode body {"error": ...} menjadi errorBody.
func bacaGalat(t *testing.T, resp *http.Response) errorBody {
	t.Helper()
	var env errorEnvelope
	if err := json.NewDecoder(resp.Body).Decode(&env); err != nil {
		t.Fatalf("mendekode body galat: %v", err)
	}
	return env.Error
}

func TestBuatDanBacaItem(t *testing.T) {
	ts := serverUji()
	defer ts.Close()

	resp, err := http.Post(ts.URL+"/items", "application/json",
		strings.NewReader(`{"name":"Buku Go","price":75000}`))
	if err != nil {
		t.Fatalf("POST /items: %v", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusCreated {
		t.Fatalf("POST /items status = %d, ingin 201", resp.StatusCode)
	}
	if loc := resp.Header.Get("Location"); loc != "/items/1" {
		t.Errorf("Location = %q, ingin /items/1", loc)
	}
	item := bacaEnvelope[Item](t, resp)
	if item.ID != 1 || item.Name != "Buku Go" || item.Price != 75000 {
		t.Errorf("item tersimpan = %+v, tidak sesuai masukan", item)
	}
	if !item.CreatedAt.Equal(waktuUji) {
		t.Errorf("created_at = %v, ingin %v (waktu beku)", item.CreatedAt, waktuUji)
	}

	resp2, err := http.Get(ts.URL + "/items/1")
	if err != nil {
		t.Fatalf("GET /items/1: %v", err)
	}
	defer resp2.Body.Close()
	if resp2.StatusCode != http.StatusOK {
		t.Fatalf("GET /items/1 status = %d, ingin 200", resp2.StatusCode)
	}
	lagi := bacaEnvelope[Item](t, resp2)
	if lagi != item {
		t.Errorf("item terbaca = %+v, ingin %+v", lagi, item)
	}
}

func TestUbahDanHapusItem(t *testing.T) {
	ts := serverUji()
	defer ts.Close()

	resp, err := http.Post(ts.URL+"/items", "application/json",
		strings.NewReader(`{"name":"Mouse","price":150000}`))
	if err != nil {
		t.Fatalf("POST /items: %v", err)
	}
	resp.Body.Close()

	req, err := http.NewRequest(http.MethodPut, ts.URL+"/items/1",
		strings.NewReader(`{"name":"Mouse Nirkabel","price":175000}`))
	if err != nil {
		t.Fatalf("membuat permintaan PUT: %v", err)
	}
	req.Header.Set("Content-Type", "application/json")
	resp2, err := http.DefaultClient.Do(req)
	if err != nil {
		t.Fatalf("PUT /items/1: %v", err)
	}
	defer resp2.Body.Close()
	if resp2.StatusCode != http.StatusOK {
		t.Fatalf("PUT /items/1 status = %d, ingin 200", resp2.StatusCode)
	}
	item := bacaEnvelope[Item](t, resp2)
	if item.Name != "Mouse Nirkabel" || item.Price != 175000 {
		t.Errorf("item setelah PUT = %+v, ingin data baru", item)
	}
	if !item.CreatedAt.Equal(waktuUji) {
		t.Errorf("created_at berubah setelah PUT: %v", item.CreatedAt)
	}

	reqDel, err := http.NewRequest(http.MethodDelete, ts.URL+"/items/1", nil)
	if err != nil {
		t.Fatalf("membuat permintaan DELETE: %v", err)
	}
	resp3, err := http.DefaultClient.Do(reqDel)
	if err != nil {
		t.Fatalf("DELETE /items/1: %v", err)
	}
	defer resp3.Body.Close()
	if resp3.StatusCode != http.StatusNoContent {
		t.Fatalf("DELETE /items/1 status = %d, ingin 204", resp3.StatusCode)
	}

	resp4, err := http.Get(ts.URL + "/items/1")
	if err != nil {
		t.Fatalf("GET /items/1: %v", err)
	}
	defer resp4.Body.Close()
	if resp4.StatusCode != http.StatusNotFound {
		t.Fatalf("GET setelah DELETE status = %d, ingin 404", resp4.StatusCode)
	}
	galat := bacaGalat(t, resp4)
	if galat.Code != "NOT_FOUND" {
		t.Errorf("kode galat = %q, ingin NOT_FOUND", galat.Code)
	}
}

func TestValidasiDitolak(t *testing.T) {
	ts := serverUji()
	defer ts.Close()

	resp, err := http.Post(ts.URL+"/items", "application/json",
		strings.NewReader(`{"name":"","price":-100}`))
	if err != nil {
		t.Fatalf("POST /items: %v", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusBadRequest {
		t.Fatalf("status = %d, ingin 400", resp.StatusCode)
	}
	galat := bacaGalat(t, resp)
	if galat.Code != "VALIDATION_ERROR" {
		t.Errorf("kode galat = %q, ingin VALIDATION_ERROR", galat.Code)
	}
	if galat.Fields["name"] != "wajib diisi" {
		t.Errorf("fields[name] = %q, ingin pesan wajib diisi", galat.Fields["name"])
	}
	if galat.Fields["price"] != "tidak boleh negatif" {
		t.Errorf("fields[price] = %q, ingin pesan tidak boleh negatif", galat.Fields["price"])
	}
}

func TestBodyTidakValid(t *testing.T) {
	ts := serverUji()
	defer ts.Close()

	resp, err := http.Post(ts.URL+"/items", "text/plain",
		strings.NewReader(`{"name":"Buku"}`))
	if err != nil {
		t.Fatalf("POST text/plain: %v", err)
	}
	resp.Body.Close()
	if resp.StatusCode != http.StatusUnsupportedMediaType {
		t.Errorf("Content-Type salah status = %d, ingin 415", resp.StatusCode)
	}

	resp2, err := http.Post(ts.URL+"/items", "application/json",
		strings.NewReader(`{nama: "buku"}`))
	if err != nil {
		t.Fatalf("POST JSON rusak: %v", err)
	}
	resp2.Body.Close()
	if resp2.StatusCode != http.StatusBadRequest {
		t.Errorf("JSON rusak status = %d, ingin 400", resp2.StatusCode)
	}

	resp3, err := http.Get(ts.URL + "/items/abc")
	if err != nil {
		t.Fatalf("GET /items/abc: %v", err)
	}
	resp3.Body.Close()
	if resp3.StatusCode != http.StatusBadRequest {
		t.Errorf("id bukan angka status = %d, ingin 400", resp3.StatusCode)
	}
}

func TestItemTidakDikenal(t *testing.T) {
	ts := serverUji()
	defer ts.Close()

	resp, err := http.Get(ts.URL + "/items/999")
	if err != nil {
		t.Fatalf("GET /items/999: %v", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusNotFound {
		t.Fatalf("status = %d, ingin 404", resp.StatusCode)
	}
	galat := bacaGalat(t, resp)
	if galat.Code != "NOT_FOUND" {
		t.Errorf("kode galat = %q, ingin NOT_FOUND", galat.Code)
	}
}

compose.yaml

Unduh berkas
yaml
services:
  db:
    image: postgres:17
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: pass
      POSTGRES_DB: items
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U user -d items"]
      interval: 5s
      timeout: 3s
      retries: 10
    volumes:
      - pgdata:/var/lib/postgresql/data

  api:
    build: .
    environment:
      APP_PORT: "8080"
      DATABASE_URL: postgres://user:pass@db:5432/items?sslmode=disable
    ports:
      - "8080:8080"
    depends_on:
      db:
        condition: service_healthy

volumes:
  pgdata:

config_test.go

Unduh berkas
go
package main

import (
	"os"
	"strings"
	"testing"
)

const dsnUji = "postgres://user:pass@localhost:5432/items?sslmode=disable"

// kosongkanEnv menghapus variabel lingkungan name selama test berjalan
// lalu memulihkan nilainya setelah test selesai. t.Setenv mendaftarkan
// pemulihannya; os.Unsetenv melakukan penghapusan sesungguhnya.
func kosongkanEnv(t *testing.T, name string) {
	t.Helper()
	t.Setenv(name, "")
	os.Unsetenv(name)
}

func TestLoadNilaiDefault(t *testing.T) {
	t.Setenv("DATABASE_URL", dsnUji)

	cfg, err := Load()
	if err != nil {
		t.Fatalf("Load() galat tak terduga: %v", err)
	}
	if cfg.Port != "8080" {
		t.Errorf("Port = %q, ingin default \"8080\"", cfg.Port)
	}
	if cfg.DatabaseURL != dsnUji {
		t.Errorf("DatabaseURL = %q, ingin %q", cfg.DatabaseURL, dsnUji)
	}
}

func TestLoadDatabaseURLKosong(t *testing.T) {
	kosongkanEnv(t, "DATABASE_URL")
	kosongkanEnv(t, "TEST_DATABASE_URL")
	cfg, err := Load()
	if err == nil {
		t.Fatal("Load() ingin galat saat DATABASE_URL kosong")
	}
	if cfg.DatabaseURL != "" {
		t.Errorf("DatabaseURL = %q, ingin kosong", cfg.DatabaseURL)
	}
	if !strings.Contains(err.Error(), "DATABASE_URL") {
		t.Errorf("pesan galat %q tidak menyebut DATABASE_URL", err.Error())
	}
}

func TestLoadUtamakanTestDatabaseURL(t *testing.T) {
	t.Setenv("DATABASE_URL", "postgres://user:pass@db-produksi/items")
	t.Setenv("TEST_DATABASE_URL", "postgres://user:pass@db-uji/items_test")

	cfg, err := Load()
	if err != nil {
		t.Fatalf("Load() galat tak terduga: %v", err)
	}
	if cfg.DatabaseURL != "postgres://user:pass@db-uji/items_test" {
		t.Errorf("DatabaseURL = %q, ingin TEST_DATABASE_URL diutamakan", cfg.DatabaseURL)
	}
}

func TestLoadPortTidakValid(t *testing.T) {
	t.Setenv("DATABASE_URL", dsnUji)

	for _, port := range []string{"abc", "0", "-1", "70000"} {
		t.Setenv("APP_PORT", port)
		if _, err := Load(); err == nil {
			t.Errorf("APP_PORT=%q ingin galat, tetapi Load() sukses", port)
		}
	}
}

config.go

Unduh berkas
go
package main

import (
	"errors"
	"fmt"
	"os"
	"strconv"
)

// Config menyimpan seluruh pengaturan aplikasi yang dibaca dari
// environment variable.
type Config struct {
	// Port adalah nomor port TCP tempat server HTTP mendengarkan permintaan.
	Port string
	// DatabaseURL adalah connection string PostgreSQL, misalnya
	// postgres://user:pass@localhost:5432/items?sslmode=disable
	DatabaseURL string
}

// defaultPort dipakai ketika APP_PORT tidak disetel.
const defaultPort = "8080"

// Load membaca environment variable, menerapkan nilai default, lalu
// memvalidasi hasilnya. Galat konfigurasi dikembalikan sejak awal
// (fail-fast) supaya kesalahan deploy terdeteksi sebelum server
// sempat menerima permintaan.
func Load() (Config, error) {
	cfg := Config{
		Port:        envOr("APP_PORT", defaultPort),
		DatabaseURL: firstNonEmpty(os.Getenv("TEST_DATABASE_URL"), os.Getenv("DATABASE_URL")),
	}

	if cfg.DatabaseURL == "" {
		return cfg, errors.New("DATABASE_URL kosong: setel misalnya DATABASE_URL=postgres://user:pass@localhost:5432/items?sslmode=disable")
	}
	if err := cekPort(cfg.Port); err != nil {
		return cfg, err
	}
	return cfg, nil
}

// envOr mengembalikan nilai environment variable name, atau fallback
// ketika variabel tersebut tidak disetel atau kosong.
func envOr(name, fallback string) string {
	if v := os.Getenv(name); v != "" {
		return v
	}
	return fallback
}

// firstNonEmpty mengembalikan string pertama yang tidak kosong.
// TEST_DATABASE_URL diutamakan agar integration test bisa mengarah
// ke database khusus tanpa mengubah DATABASE_URL produksi.
func firstNonEmpty(values ...string) string {
	for _, v := range values {
		if v != "" {
			return v
		}
	}
	return ""
}

// cekPort memastikan port berupa bilangan bulat antara 1 dan 65535.
func cekPort(port string) error {
	n, err := strconv.Atoi(port)
	if err != nil {
		return fmt.Errorf("APP_PORT tidak valid: %q bukan bilangan bulat", port)
	}
	if n < 1 || n > 65535 {
		return fmt.Errorf("APP_PORT tidak valid: %d di luar rentang 1-65535", n)
	}
	return nil
}

database.go

Unduh berkas
go
package main

import (
	"context"
	"database/sql"
	"embed"
	"fmt"
	"io/fs"
	"path/filepath"
	"strings"
	"time"

	// Blank import mendaftarkan driver "pgx" ke database/sql.
	_ "github.com/jackc/pgx/v5/stdlib"
)

// migrationFS menyimpan seluruh berkas SQL di folder migrations ke
// dalam binary, sehingga image Docker tidak perlu menyalin folder ini.
//
//go:embed migrations/*.sql
var migrationFS embed.FS

// OpenDatabase membuka pool koneksi PostgreSQL lalu memastikan
// database benar-benar merespons lewat PingContext. sql.Open bersifat
// malas: tanpa ping, galat koneksi baru muncul saat query pertama.
func OpenDatabase(ctx context.Context, dsn string) (*sql.DB, error) {
	db, err := sql.Open("pgx", dsn)
	if err != nil {
		return nil, fmt.Errorf("membuka koneksi: %w", err)
	}

	pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
	defer cancel()
	if err := db.PingContext(pingCtx); err != nil {
		db.Close()
		return nil, fmt.Errorf("ping database: %w", err)
	}
	return db, nil
}

// RunMigrations menjalankan seluruh berkas migrations/*.sql yang belum
// diterapkan. Versi tercatat di tabel schema_migrations sehingga
// menjalankan migration dua kali aman (idempoten).
func RunMigrations(ctx context.Context, db *sql.DB) error {
	if _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (
	version    TEXT PRIMARY KEY,
	applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`); err != nil {
		return fmt.Errorf("membuat tabel schema_migrations: %w", err)
	}

	files, err := fs.Glob(migrationFS, "migrations/*.sql")
	if err != nil {
		return fmt.Errorf("membaca daftar migration: %w", err)
	}

	for _, name := range files {
		version := strings.TrimSuffix(filepath.Base(name), ".sql")

		var sudah bool
		err := db.QueryRowContext(ctx,
			`SELECT EXISTS (SELECT 1 FROM schema_migrations WHERE version = $1)`,
			version,
		).Scan(&sudah)
		if err != nil {
			return fmt.Errorf("memeriksa migration %s: %w", version, err)
		}
		if sudah {
			continue
		}

		body, err := migrationFS.ReadFile(name)
		if err != nil {
			return fmt.Errorf("membaca berkas %s: %w", name, err)
		}

		// Satu migration dijalankan dalam satu transaksi bersama
		// pencatatannya, sehingga skema dan daftar versi selalu
		// sejalan walau proses berhenti di tengah jalan.
		tx, err := db.BeginTx(ctx, nil)
		if err != nil {
			return fmt.Errorf("memulai transaksi migration %s: %w", version, err)
		}
		if _, err := tx.ExecContext(ctx, string(body)); err != nil {
			tx.Rollback()
			return fmt.Errorf("menjalankan migration %s: %w", version, err)
		}
		if _, err := tx.ExecContext(ctx,
			`INSERT INTO schema_migrations (version) VALUES ($1)`, version,
		); err != nil {
			tx.Rollback()
			return fmt.Errorf("mencatat migration %s: %w", version, err)
		}
		if err := tx.Commit(); err != nil {
			return fmt.Errorf("mengcommit migration %s: %w", version, err)
		}
	}
	return nil
}

demo.go

Unduh berkas
go
package main

import (
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"strings"
	"time"
)

// waktuDemo adalah waktu beku supaya output demo selalu identik.
var waktuDemo = time.Date(2026, 1, 15, 8, 0, 0, 0, time.UTC)

// demo menjalankan seluruh endpoint di atas MemoryItemRepository
// tanpa PostgreSQL dan tanpa membuka port. Setiap permintaan dikirim
// lewat httptest.NewRecorder, lalu status dan body dicetak. Gunakan
// `go run . demo` untuk mencoba API tanpa database.
func demo() {
	svc := NewItemService(NewMemoryItemRepository(), FixedClock{Waktu: waktuDemo})
	router := NewRouter(svc)

	fmt.Println("=== Demo REST API item (mode memori, tanpa PostgreSQL) ===")
	skenario := []struct {
		metode string
		target string
		body   string
	}{
		{"POST", "/items", `{"name":"Buku Go","price":75000}`},
		{"POST", "/items", `{"name":"Mouse","price":150000}`},
		{"POST", "/items", `{"name":"","price":-100}`},
		{"GET", "/items", ""},
		{"GET", "/items/1", ""},
		{"PUT", "/items/2", `{"name":"Mouse Nirkabel","price":175000}`},
		{"GET", "/items/999", ""},
		{"DELETE", "/items/1", ""},
		{"GET", "/items", ""},
	}
	for _, tt := range skenario {
		kirimDemo(router, tt.metode, tt.target, tt.body)
	}
}

// kirimDemo mengirim satu permintaan ke router lalu mencetak status,
// header Location, dan body respons.
func kirimDemo(router http.Handler, metode, target, body string) {
	var rdr io.Reader
	if body != "" {
		rdr = strings.NewReader(body)
	}
	req := httptest.NewRequest(metode, target, rdr)
	if body != "" {
		req.Header.Set("Content-Type", "application/json")
	}
	rec := httptest.NewRecorder()
	router.ServeHTTP(rec, req)

	fmt.Printf("%s %s → %d %s\n", metode, target, rec.Code, http.StatusText(rec.Code))
	if loc := rec.Header().Get("Location"); loc != "" {
		fmt.Printf("  Location: %s\n", loc)
	}
	if b := strings.TrimSpace(rec.Body.String()); b != "" {
		fmt.Printf("  body: %s\n", b)
	}
}

errors.go

Unduh berkas
go
package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"net/http"
)

// ErrNotFound adalah sentinel error untuk data yang tidak ditemukan di
// penyimpanan. Handler memeriksa galat ini lewat errors.Is untuk
// memilih status 404.
var ErrNotFound = errors.New("data tidak ditemukan")

// ValidationError menjelaskan satu field yang melanggar aturan domain.
// Handler mengumpulkan galat ini menjadi body 400 yang rinci.
type ValidationError struct {
	Field   string
	Message string
}

// Error memenuhi kontrak interface error.
func (e ValidationError) Error() string {
	return fmt.Sprintf("field %s: %s", e.Field, e.Message)
}

// Kode galat yang stabil untuk konsumen API. Kode ini dirancang tidak
// berubah ketika implementasi internal berganti.
const (
	codeNotFound       = "NOT_FOUND"
	codeValidasi       = "VALIDATION_ERROR"
	codeJSONInvalid    = "INVALID_JSON"
	codeArgumenInvalid = "INVALID_ARGUMENT"
	codeBodyKebesaran  = "REQUEST_TOO_LARGE"
	codeMediaType      = "UNSUPPORTED_MEDIA_TYPE"
	codeInternal       = "INTERNAL_ERROR"
)

// errorEnvelope adalah bentuk body galat yang konsisten di seluruh
// endpoint: {"error":{"code":"...","message":"..."}}.
type errorEnvelope struct {
	Error errorBody `json:"error"`
}

type errorBody struct {
	Code    string            `json:"code"`
	Message string            `json:"message"`
	Fields  map[string]string `json:"fields,omitempty"`
}

// galatBody adalah galat transport HTTP yang sudah membawa status dan
// kode galatnya sendiri, misalnya body JSON yang rusak.
type galatBody struct {
	status  int
	code    string
	message string
}

func (e *galatBody) Error() string { return e.message }

// writeJSON menuliskan v sebagai respons JSON dengan status code yang
// diberikan. Content-Type diatur sebelum WriteHeader supaya header
// benar-benar terkirim ke klien.
func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(v)
}

// writeError menerjemahkan error domain menjadi respons JSON konsisten.
// ValidationError menjadi 400 dengan peta fields; ErrNotFound menjadi
// 404; galat lain menjadi 500 tanpa membocorkan detail internal.
func writeError(w http.ResponseWriter, err error) {
	if fields := petaGalatValidasi(err); len(fields) > 0 {
		writeJSON(w, http.StatusBadRequest, errorEnvelope{Error: errorBody{
			Code:    codeValidasi,
			Message: "masukan tidak lolos validasi",
			Fields:  fields,
		}})
		return
	}
	status, code, message := terjemahkanGalat(err)
	writeJSON(w, status, errorEnvelope{Error: errorBody{Code: code, Message: message}})
}

// terjemahkanGalat memetakan error domain ke status HTTP, kode galat,
// dan pesan yang aman untuk klien.
func terjemahkanGalat(err error) (status int, code, message string) {
	if errors.Is(err, ErrNotFound) {
		return http.StatusNotFound, codeNotFound, err.Error()
	}
	// Pesan galat internal (koneksi, SQL, dsb.) tidak dibocorkan;
	// klien hanya menerima kode INTERNAL_ERROR yang stabil.
	return http.StatusInternalServerError, codeInternal, "galat internal server"
}

// petaGalatValidasi mengumpulkan seluruh ValidationError dari err,
// termasuk yang digabung lewat errors.Join, menjadi peta field→pesan.
func petaGalatValidasi(err error) map[string]string {
	var hasil map[string]string
	kumpulkanValidasi(err, &hasil)
	return hasil
}

// kumpulkanValidasi menelusuri err secara rekursif. errors.Join
// menghasilkan error dengan method Unwrap() []error, sehingga setiap
// anggota gabungan diperiksa satu per satu.
func kumpulkanValidasi(err error, hasil *map[string]string) {
	if err == nil {
		return
	}
	if joined, ok := err.(interface{ Unwrap() []error }); ok {
		for _, e := range joined.Unwrap() {
			kumpulkanValidasi(e, hasil)
		}
		return
	}
	var ve ValidationError
	if errors.As(err, &ve) {
		if *hasil == nil {
			*hasil = make(map[string]string)
		}
		(*hasil)[ve.Field] = ve.Message
	}
}

// tulisGalatBody menuliskan galat transport (galatBody) atau galat
// domain sebagai respons JSON. Handler cukup memanggil fungsi ini
// untuk semua jenis galat.
func tulisGalatBody(w http.ResponseWriter, err error) {
	var gb *galatBody
	if errors.As(err, &gb) {
		writeJSON(w, gb.status, errorEnvelope{Error: errorBody{
			Code:    gb.code,
			Message: gb.message,
		}})
		return
	}
	writeError(w, err)
}

handlers.go

Unduh berkas
go
package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"mime"
	"net/http"
	"strconv"
)

// maxBodyBytes membatasi ukuran body JSON supaya satu permintaan tidak
// bisa menghabiskan memori server.
const maxBodyBytes = 1 << 20 // 1 MiB

// dataEnvelope membungkus data sukses dalam field "data" sehingga
// bentuk respons konsisten dengan konvensi kursus ini.
type dataEnvelope struct {
	Data any `json:"data"`
}

// listEnvelope membungkus hasil List dengan meta jumlah data.
type listEnvelope struct {
	Data []Item   `json:"data"`
	Meta listMeta `json:"meta"`
}

type listMeta struct {
	Count int `json:"count"`
}

// ItemHandler menerjemahkan permintaan HTTP menjadi pemanggilan
// ItemService, lalu menerjemahkan hasilnya kembali menjadi respons.
// Handler tidak memuat aturan bisnis apa pun.
type ItemHandler struct {
	service *ItemService
}

// Healthz melaporkan bahwa proses API hidup dan siap menerima
// permintaan; dipakai monitoring dan compose healthcheck.
func (h *ItemHandler) Healthz(w http.ResponseWriter, r *http.Request) {
	writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}

// List menangani GET /items dan mengembalikan seluruh item.
func (h *ItemHandler) List(w http.ResponseWriter, r *http.Request) {
	items, err := h.service.List(r.Context())
	if err != nil {
		writeError(w, err)
		return
	}
	writeJSON(w, http.StatusOK, listEnvelope{Data: items, Meta: listMeta{Count: len(items)}})
}

// Create menangani POST /items. Item baru dibalas 201 Created dengan
// header Location yang menunjuk ke URL item.
func (h *ItemHandler) Create(w http.ResponseWriter, r *http.Request) {
	var input ItemInput
	if err := bacaInput(w, r, &input); err != nil {
		tulisGalatBody(w, err)
		return
	}
	item, err := h.service.Create(r.Context(), input)
	if err != nil {
		writeError(w, err)
		return
	}
	w.Header().Set("Location", fmt.Sprintf("/items/%d", item.ID))
	writeJSON(w, http.StatusCreated, dataEnvelope{Data: item})
}

// Get menangani GET /items/{id}.
func (h *ItemHandler) Get(w http.ResponseWriter, r *http.Request) {
	id, err := bacaID(r)
	if err != nil {
		tulisGalatBody(w, err)
		return
	}
	item, err := h.service.GetByID(r.Context(), id)
	if err != nil {
		writeError(w, err)
		return
	}
	writeJSON(w, http.StatusOK, dataEnvelope{Data: item})
}

// Update menangani PUT /items/{id} dengan semantik penggantian penuh.
func (h *ItemHandler) Update(w http.ResponseWriter, r *http.Request) {
	id, err := bacaID(r)
	if err != nil {
		tulisGalatBody(w, err)
		return
	}
	var input ItemInput
	if err := bacaInput(w, r, &input); err != nil {
		tulisGalatBody(w, err)
		return
	}
	item, err := h.service.Update(r.Context(), id, input)
	if err != nil {
		writeError(w, err)
		return
	}
	writeJSON(w, http.StatusOK, dataEnvelope{Data: item})
}

// Delete menangani DELETE /items/{id} dan membalas 204 No Content
// tanpa body.
func (h *ItemHandler) Delete(w http.ResponseWriter, r *http.Request) {
	id, err := bacaID(r)
	if err != nil {
		tulisGalatBody(w, err)
		return
	}
	if err := h.service.Delete(r.Context(), id); err != nil {
		writeError(w, err)
		return
	}
	w.WriteHeader(http.StatusNoContent)
}

// bacaID membaca segmen {id} dari path lalu mengubahnya ke int64.
// Segmen bukan bilangan bulat dibalas galat 400 INVALID_ARGUMENT.
func bacaID(r *http.Request) (int64, error) {
	id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
	if err != nil {
		return 0, &galatBody{
			status:  http.StatusBadRequest,
			code:    codeArgumenInvalid,
			message: "id harus bilangan bulat",
		}
	}
	return id, nil
}

// bacaInput memeriksa Content-Type, membatasi ukuran body, lalu
// mendekode JSON ke input sambil menolak field yang tidak dikenal.
// Galat transport dibungkus galatBody agar handler tinggal
// meneruskannya ke tulisGalatBody.
func bacaInput(w http.ResponseWriter, r *http.Request, input *ItemInput) error {
	ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
	if err != nil || ct != "application/json" {
		return &galatBody{
			status:  http.StatusUnsupportedMediaType,
			code:    codeMediaType,
			message: "Content-Type harus application/json",
		}
	}
	r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
	dec := json.NewDecoder(r.Body)
	dec.DisallowUnknownFields()
	if err := dec.Decode(input); err != nil {
		var maxErr *http.MaxBytesError
		switch {
		case errors.As(err, &maxErr):
			return &galatBody{
				status:  http.StatusRequestEntityTooLarge,
				code:    codeBodyKebesaran,
				message: fmt.Sprintf("body melebihi batas %d byte", maxErr.Limit),
			}
		case errors.Is(err, io.EOF):
			return &galatBody{
				status:  http.StatusBadRequest,
				code:    codeJSONInvalid,
				message: "body kosong; kirim JSON seperti {\"name\":\"Buku Go\",\"price\":75000}",
			}
		default:
			return &galatBody{
				status:  http.StatusBadRequest,
				code:    codeJSONInvalid,
				message: "body bukan JSON valid: " + err.Error(),
			}
		}
	}
	return nil
}

integration_test.go

Unduh berkas
go
package main

import (
	"context"
	"net/http"
	"net/http/httptest"
	"os"
	"strings"
	"testing"
)

// TestIntegrationCRUD menjalankan alur buat-baca-ubah-hapus melawan
// PostgreSQL sungguhan. Tanpa TEST_DATABASE_URL, test ini dilewati
// (skip) supaya `go test ./...` tetap lulus di lingkungan offline:
//
//	TEST_DATABASE_URL=postgres://user:pass@localhost:5432/items?sslmode=disable \
//	  go test -v -run Integration ./...
func TestIntegrationCRUD(t *testing.T) {
	dsn := os.Getenv("TEST_DATABASE_URL")
	if dsn == "" {
		t.Skip("TEST_DATABASE_URL kosong: integration test membutuhkan PostgreSQL aktif")
	}

	ctx := context.Background()
	db, err := OpenDatabase(ctx, dsn)
	if err != nil {
		t.Fatalf("membuka database: %v", err)
	}
	defer db.Close()

	if err := RunMigrations(ctx, db); err != nil {
		t.Fatalf("menjalankan migration: %v", err)
	}

	// Bersihkan data test sebelumnya supaya test dapat diulang.
	if _, err := db.ExecContext(ctx, `TRUNCATE items RESTART IDENTITY`); err != nil {
		t.Fatalf("membersihkan tabel items: %v", err)
	}

	svc := NewItemService(NewPostgresItemRepository(db), RealClock{})
	ts := httptest.NewServer(NewRouter(svc))
	defer ts.Close()

	resp, err := http.Post(ts.URL+"/items", "application/json",
		strings.NewReader(`{"name":"Item Integrasi","price":125000}`))
	if err != nil {
		t.Fatalf("POST /items: %v", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusCreated {
		t.Fatalf("POST /items status = %d, ingin 201", resp.StatusCode)
	}
	item := bacaEnvelope[Item](t, resp)
	if item.ID != 1 {
		t.Errorf("id = %d, ingin 1 (RESTART IDENTITY)", item.ID)
	}

	resp2, err := http.Get(ts.URL + "/items/1")
	if err != nil {
		t.Fatalf("GET /items/1: %v", err)
	}
	defer resp2.Body.Close()
	if resp2.StatusCode != http.StatusOK {
		t.Fatalf("GET /items/1 status = %d, ingin 200", resp2.StatusCode)
	}
	terbaca := bacaEnvelope[Item](t, resp2)
	if terbaca.Name != "Item Integrasi" || terbaca.Price != 125000 {
		t.Errorf("item terbaca = %+v, tidak sesuai data yang disimpan", terbaca)
	}

	req, err := http.NewRequest(http.MethodDelete, ts.URL+"/items/1", nil)
	if err != nil {
		t.Fatalf("membuat permintaan DELETE: %v", err)
	}
	resp3, err := http.DefaultClient.Do(req)
	if err != nil {
		t.Fatalf("DELETE /items/1: %v", err)
	}
	defer resp3.Body.Close()
	if resp3.StatusCode != http.StatusNoContent {
		t.Fatalf("DELETE /items/1 status = %d, ingin 204", resp3.StatusCode)
	}

	resp4, err := http.Get(ts.URL + "/items/1")
	if err != nil {
		t.Fatalf("GET setelah DELETE: %v", err)
	}
	defer resp4.Body.Close()
	if resp4.StatusCode != http.StatusNotFound {
		t.Fatalf("GET setelah DELETE status = %d, ingin 404", resp4.StatusCode)
	}
}

main.go

Unduh berkas
go
package main

import (
	"context"
	"errors"
	"fmt"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"
)

func main() {
	if len(os.Args) > 1 {
		if os.Args[1] == "demo" {
			demo()
			return
		}
		fmt.Fprintln(os.Stderr, "perintah tidak dikenal:", os.Args[1])
		fmt.Fprintln(os.Stderr, "pemakaian: api [demo]")
		os.Exit(2)
	}
	if err := run(); err != nil {
		// Pesan ditulis tanpa stempel waktu supaya output tetap
		// deterministik dan mudah dibandingkan.
		fmt.Fprintln(os.Stderr, "galat:", err)
		os.Exit(1)
	}
}

// run merangkai seluruh dependensi: konfigurasi dibaca dan divalidasi,
// pool koneksi dibuka, migration dijalankan, lalu repository, service,
// handler, dan server disusun berurutan.
func run() error {
	cfg, err := Load()
	if err != nil {
		return fmt.Errorf("memuat konfigurasi: %w", err)
	}

	ctx := context.Background()
	db, err := OpenDatabase(ctx, cfg.DatabaseURL)
	if err != nil {
		return fmt.Errorf("membuka database: %w", err)
	}
	defer db.Close()

	if err := RunMigrations(ctx, db); err != nil {
		return fmt.Errorf("menjalankan migration: %w", err)
	}

	repo := NewPostgresItemRepository(db)
	svc := NewItemService(repo, RealClock{})
	srv := NewServer(cfg, svc)

	log.Printf("server mendengarkan di :%s", cfg.Port)
	return ListenAndServe(srv)
}

// NewServer membangun http.Server tanpa memulai mendengarkan port.
// Pemisahan ini membuat server dapat diuji dengan httptest dan
// diganti-ganti pemanggilnya tanpa menyentuh logika shutdown.
func NewServer(cfg Config, svc *ItemService) *http.Server {
	return &http.Server{
		Addr:              ":" + cfg.Port,
		Handler:           NewRouter(svc),
		ReadHeaderTimeout: 5 * time.Second,
	}
}

// ListenAndServe menjalankan server di goroutine, menunggu sinyal
// SIGINT atau SIGTERM, lalu mematikan server secara graceful dengan
// tenggat 10 detik agar permintaan yang sedang diproses selesai.
func ListenAndServe(srv *http.Server) error {
	errCh := make(chan error, 1)
	go func() {
		if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			errCh <- err
		}
	}()

	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	select {
	case err := <-errCh:
		return fmt.Errorf("server gagal berjalan: %w", err)
	case <-ctx.Done():
		log.Println("sinyal berhenti diterima; mematikan server")
	}

	shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	if err := srv.Shutdown(shutdownCtx); err != nil {
		return fmt.Errorf("graceful shutdown: %w", err)
	}
	return nil
}

migrations/001_create_items.sql

Unduh berkas
sql
-- 001_create_items.sql membuat tabel items beserta batasan integritasnya.
CREATE TABLE IF NOT EXISTS items (
    id         BIGSERIAL PRIMARY KEY,
    name       TEXT NOT NULL,
    price      BIGINT NOT NULL CHECK (price >= 0),
    created_at TIMESTAMPTZ NOT NULL
);

models.go

Unduh berkas
go
package main

import (
	"errors"
	"strings"
	"time"
	"unicode/utf8"
)

// Item adalah entitas domain: barang yang dijual beserta harganya dalam
// satuan rupiah utuh (tanpa pecahan) agar bebas galat pembulatan.
type Item struct {
	ID        int64     `json:"id"`
	Name      string    `json:"name"`
	Price     int64     `json:"price"`
	CreatedAt time.Time `json:"created_at"`
}

// ItemInput adalah data yang dikirim klien untuk membuat atau mengganti
// item. Struct ini dipisah dari Item supaya ID dan CreatedAt tidak bisa
// dipalsukan dari luar.
type ItemInput struct {
	Name  string `json:"name"`
	Price int64  `json:"price"`
}

// Batas aturan domain untuk masukan item.
const (
	namaMaksRune = 100
	hargaMaks    = 1_000_000_000_000
)

// Validasi memeriksa seluruh aturan domain pada ItemInput dan
// mengembalikan gabungan ValidationError lewat errors.Join. Nilai nil
// berarti masukan lolos validasi.
func (in ItemInput) Validasi() error {
	var galat []error
	name := strings.TrimSpace(in.Name)

	switch {
	case name == "":
		galat = append(galat, ValidationError{Field: "name", Message: "wajib diisi"})
	case utf8.RuneCountInString(name) > namaMaksRune:
		galat = append(galat, ValidationError{Field: "name", Message: "maksimal 100 karakter"})
	}
	if in.Price < 0 {
		galat = append(galat, ValidationError{Field: "price", Message: "tidak boleh negatif"})
	}
	if in.Price > hargaMaks {
		galat = append(galat, ValidationError{Field: "price", Message: "maksimal 1000000000000"})
	}
	return errors.Join(galat...)
}

repository.go

Unduh berkas
go
package main

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"slices"
	"sync"
)

// ItemRepository adalah kontrak penyimpanan item. PostgresItemRepository
// mengimplementasikannya di atas PostgreSQL, sedangkan
// MemoryItemRepository menyimpan data di map untuk pengujian tanpa
// database. Handler dan service hanya mengenal interface ini.
type ItemRepository interface {
	Create(ctx context.Context, item Item) (Item, error)
	GetByID(ctx context.Context, id int64) (Item, error)
	List(ctx context.Context) ([]Item, error)
	Update(ctx context.Context, item Item) (Item, error)
	Delete(ctx context.Context, id int64) error
}

// PostgresItemRepository menyimpan item di PostgreSQL melalui
// database/sql dengan driver pgx. Seluruh nilai dikirim sebagai
// parameter $1, $2, ... sehingga bebas SQL injection.
type PostgresItemRepository struct {
	db *sql.DB
}

// NewPostgresItemRepository membangun repository di atas pool *sql.DB
// yang sudah terbuka.
func NewPostgresItemRepository(db *sql.DB) *PostgresItemRepository {
	return &PostgresItemRepository{db: db}
}

// Create menyimpan item baru; id dibuat oleh sequence PostgreSQL lalu
// dikembalikan lewat RETURNING tanpa query kedua.
func (r *PostgresItemRepository) Create(ctx context.Context, item Item) (Item, error) {
	err := r.db.QueryRowContext(ctx,
		`INSERT INTO items (name, price, created_at) VALUES ($1, $2, $3) RETURNING id`,
		item.Name, item.Price, item.CreatedAt,
	).Scan(&item.ID)
	if err != nil {
		return Item{}, fmt.Errorf("menyimpan item: %w", err)
	}
	return item, nil
}

// GetByID membaca satu item; sql.ErrNoRows diterjemahkan menjadi
// ErrNotFound supaya handler cukup memeriksa sentinel error.
func (r *PostgresItemRepository) GetByID(ctx context.Context, id int64) (Item, error) {
	var item Item
	err := r.db.QueryRowContext(ctx,
		`SELECT id, name, price, created_at FROM items WHERE id = $1`, id,
	).Scan(&item.ID, &item.Name, &item.Price, &item.CreatedAt)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return Item{}, fmt.Errorf("item %d: %w", id, ErrNotFound)
		}
		return Item{}, fmt.Errorf("membaca item %d: %w", id, err)
	}
	return item, nil
}

// List membaca seluruh item terurut naik berdasarkan id. rows ditutup
// dengan defer agar koneksi kembali ke pool meski terjadi galat.
func (r *PostgresItemRepository) List(ctx context.Context) ([]Item, error) {
	rows, err := r.db.QueryContext(ctx,
		`SELECT id, name, price, created_at FROM items ORDER BY id`)
	if err != nil {
		return nil, fmt.Errorf("membaca daftar item: %w", err)
	}
	defer rows.Close()

	items := []Item{}
	for rows.Next() {
		var item Item
		if err := rows.Scan(&item.ID, &item.Name, &item.Price, &item.CreatedAt); err != nil {
			return nil, fmt.Errorf("memindai baris item: %w", err)
		}
		items = append(items, item)
	}
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("iterasi baris item: %w", err)
	}
	return items, nil
}

// Update mengganti name dan price item. Jumlah baris yang berubah
// diperiksa untuk membedakan "tidak ada baris" dari keberhasilan.
func (r *PostgresItemRepository) Update(ctx context.Context, item Item) (Item, error) {
	res, err := r.db.ExecContext(ctx,
		`UPDATE items SET name = $1, price = $2 WHERE id = $3`,
		item.Name, item.Price, item.ID,
	)
	if err != nil {
		return Item{}, fmt.Errorf("mengubah item %d: %w", item.ID, err)
	}
	n, err := res.RowsAffected()
	if err != nil {
		return Item{}, fmt.Errorf("menghitung baris item %d: %w", item.ID, err)
	}
	if n == 0 {
		return Item{}, fmt.Errorf("item %d: %w", item.ID, ErrNotFound)
	}
	return item, nil
}

// Delete menghapus satu item dan memetakan "tidak ada baris terhapus"
// menjadi ErrNotFound.
func (r *PostgresItemRepository) Delete(ctx context.Context, id int64) error {
	res, err := r.db.ExecContext(ctx, `DELETE FROM items WHERE id = $1`, id)
	if err != nil {
		return fmt.Errorf("menghapus item %d: %w", id, err)
	}
	n, err := res.RowsAffected()
	if err != nil {
		return fmt.Errorf("menghitung baris item %d: %w", id, err)
	}
	if n == 0 {
		return fmt.Errorf("item %d: %w", id, ErrNotFound)
	}
	return nil
}

// MemoryItemRepository adalah penyimpanan palsu (fake) untuk unit test:
// map dilindungi mutex dan ID bertambah dari penghitung internal.
// Perilakunya meniru PostgresItemRepository, termasuk pemetaan
// ErrNotFound, sehingga service bisa diuji tanpa database.
type MemoryItemRepository struct {
	mu     sync.Mutex
	items  map[int64]Item
	nextID int64
}

// NewMemoryItemRepository membuat penyimpanan kosong dengan penghitung
// ID mulai dari 1, meniru sequence PostgreSQL.
func NewMemoryItemRepository() *MemoryItemRepository {
	return &MemoryItemRepository{items: make(map[int64]Item), nextID: 1}
}

func (r *MemoryItemRepository) Create(ctx context.Context, item Item) (Item, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	item.ID = r.nextID
	r.nextID++
	r.items[item.ID] = item
	return item, nil
}

func (r *MemoryItemRepository) GetByID(ctx context.Context, id int64) (Item, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	item, ada := r.items[id]
	if !ada {
		return Item{}, fmt.Errorf("item %d: %w", id, ErrNotFound)
	}
	return item, nil
}

// List mengembalikan salinan item terurut naik berdasarkan ID agar
// urutannya sama dengan ORDER BY id di PostgreSQL.
func (r *MemoryItemRepository) List(ctx context.Context) ([]Item, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	ids := make([]int64, 0, len(r.items))
	for id := range r.items {
		ids = append(ids, id)
	}
	slices.Sort(ids)
	items := make([]Item, 0, len(ids))
	for _, id := range ids {
		items = append(items, r.items[id])
	}
	return items, nil
}

func (r *MemoryItemRepository) Update(ctx context.Context, item Item) (Item, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	if _, ada := r.items[item.ID]; !ada {
		return Item{}, fmt.Errorf("item %d: %w", item.ID, ErrNotFound)
	}
	r.items[item.ID] = item
	return item, nil
}

func (r *MemoryItemRepository) Delete(ctx context.Context, id int64) error {
	r.mu.Lock()
	defer r.mu.Unlock()
	if _, ada := r.items[id]; !ada {
		return fmt.Errorf("item %d: %w", id, ErrNotFound)
	}
	delete(r.items, id)
	return nil
}

routes.go

Unduh berkas
go
package main

import "net/http"

// NewRouter membangun ServeMux dengan seluruh pattern REST API item.
// Method dan path dideklarasikan dalam satu pattern seperti
// "GET /items/{id}" sehingga mux otomatis menolak method yang tidak
// terdaftar dengan 405 Method Not Allowed.
func NewRouter(svc *ItemService) http.Handler {
	h := &ItemHandler{service: svc}
	mux := http.NewServeMux()
	mux.HandleFunc("GET /healthz", h.Healthz)
	mux.HandleFunc("GET /items", h.List)
	mux.HandleFunc("POST /items", h.Create)
	mux.HandleFunc("GET /items/{id}", h.Get)
	mux.HandleFunc("PUT /items/{id}", h.Update)
	mux.HandleFunc("DELETE /items/{id}", h.Delete)
	return mux
}

service.go

Unduh berkas
go
package main

import (
	"context"
	"strings"
	"time"
)

// Clock memisahkan sumber waktu dari logika bisnis. Produksi memakai
// RealClock; demo dan unit test memakai FixedClock sehingga nilai
// created_at selalu deterministik.
type Clock interface {
	Now() time.Time
}

// RealClock mengembalikan waktu sistem dalam zona UTC.
type RealClock struct{}

// Now memenuhi interface Clock.
func (RealClock) Now() time.Time { return time.Now().UTC() }

// FixedClock selalu mengembalikan Waktu yang sama.
type FixedClock struct {
	Waktu time.Time
}

// Now memenuhi interface Clock.
func (c FixedClock) Now() time.Time { return c.Waktu }

// ItemService menampung aturan bisnis item: validasi masukan,
// normalisasi nama, penanda waktu, dan pengambilan item lama. Semua
// akses penyimpanan didelegasikan ke ItemRepository yang diinjeksi.
type ItemService struct {
	repo  ItemRepository
	clock Clock
}

// NewItemService membangun service dengan repository dan clock yang
// diinjeksi pemanggil (dependency injection).
func NewItemService(repo ItemRepository, clock Clock) *ItemService {
	return &ItemService{repo: repo, clock: clock}
}

// Create memvalidasi masukan, menormalkan nama, mengisi created_at
// dari clock, lalu menyimpan item baru.
func (s *ItemService) Create(ctx context.Context, input ItemInput) (Item, error) {
	if err := input.Validasi(); err != nil {
		return Item{}, err
	}
	item := Item{
		Name:      strings.TrimSpace(input.Name),
		Price:     input.Price,
		CreatedAt: s.clock.Now(),
	}
	return s.repo.Create(ctx, item)
}

// GetByID mengambil satu item berdasarkan ID-nya.
func (s *ItemService) GetByID(ctx context.Context, id int64) (Item, error) {
	return s.repo.GetByID(ctx, id)
}

// List mengembalikan seluruh item terurut naik berdasarkan ID.
func (s *ItemService) List(ctx context.Context) ([]Item, error) {
	return s.repo.List(ctx)
}

// Update mengganti seluruh field item yang sudah ada. Item lama dibaca
// lebih dulu supaya created_at tidak berubah ketika name dan price
// diganti.
func (s *ItemService) Update(ctx context.Context, id int64, input ItemInput) (Item, error) {
	if err := input.Validasi(); err != nil {
		return Item{}, err
	}
	lama, err := s.repo.GetByID(ctx, id)
	if err != nil {
		return Item{}, err
	}
	item := Item{
		ID:        lama.ID,
		Name:      strings.TrimSpace(input.Name),
		Price:     input.Price,
		CreatedAt: lama.CreatedAt,
	}
	return s.repo.Update(ctx, item)
}

// Delete menghapus item berdasarkan ID-nya.
func (s *ItemService) Delete(ctx context.Context, id int64) error {
	return s.repo.Delete(ctx, id)
}