Aplikasi yang sehat harus menolak bekerja dengan konfigurasi setengah jadi, bukan membiarkan galat database muncul saat query pertama tiba. Pada bagian ini kita menulis dua fondasinya: config.go yang membaca environment variable dan berhenti lebih awal (fail-fast) ketika nilainya tidak layak, lalu database.go yang membuka pool koneksi pgx, memverifikasinya dengan ping, dan menjalankan migration SQL yang tertanam di dalam binary. Setiap keputusan di sini bisa diuji tanpa PostgreSQL aktif.

Konfigurasi dengan Validasi Fail-Fast #

Seluruh berkas config.go hanya bergantung pada standard library — os, strconv, errors, dan fmt — sehingga pengujian konfigurasi tidak memerlukan jaringan maupun database:

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
}

Mari kita telaah bagian-bagian pentingnya:

  1. Baris 12–18 (struct Config): dua nilai siap pakai disimpan sebagai string — Port tetap string karena hanya ditempel ke Addr server, bukan dihitung; DatabaseURL berupa DSN PostgreSQL.
  2. Baris 27–31 (isi Load): envOr mengambil APP_PORT dengan default 8080, sedangkan firstNonEmpty mengutamakan TEST_DATABASE_URL sebelum DATABASE_URL. Urutan ini membuat integration test bisa mengarah ke database khusus tanpa menimpa variabel produksi.
  3. Baris 33–35 (galat DSN kosong): aplikasi berhenti dengan pesan yang menyebut contoh nilai yang benar. Pesan galat yang bisa ditiru menghemat waktu orang yang melakukan deploy.
  4. Baris 36–38 (validasi port): cekPort menolak huruf dan angka di luar 1–65535 sebelum server sempat dibangun.

Fail-fast terlihat nyata saat aplikasi dijalankan tanpa DATABASE_URL — proses keluar dengan kode 1 sebelum server dibangun:

bash
go run .

Output yang Dihasilkan:

text
galat: memuat konfigurasi: DATABASE_URL kosong: setel misalnya DATABASE_URL=postgres://user:pass@localhost:5432/items?sslmode=disable
exit status 1

Baris exit status 1 berasal dari go run yang meneruskan kode keluar program. Unit test untuk Load membuktikan keempat jalurnya: nilai default, DSN kosong, prioritas TEST_DATABASE_URL, dan port tidak valid:

bash
go test -v -run TestLoad ./...

Output yang Dihasilkan:

text
=== RUN   TestLoadNilaiDefault
--- PASS: TestLoadNilaiDefault (0.00s)
=== RUN   TestLoadDatabaseURLKosong
--- PASS: TestLoadDatabaseURLKosong (0.00s)
=== RUN   TestLoadUtamakanTestDatabaseURL
--- PASS: TestLoadUtamakanTestDatabaseURL (0.00s)
=== RUN   TestLoadPortTidakValid
--- PASS: TestLoadPortTidakValid (0.00s)
PASS
ok  	example.com/rest-api-postgresql	0.004s

Membuka Pool Koneksi pgx #

Driver PostgreSQL yang dipakai proyek ini adalah pgx lewat adapter database/sql. Pendaftarannya cukup satu blank import, dan seluruh berkas SQL ikut terkompilasi ke dalam binary lewat //go:embed:

go
	// 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
}
  1. Baris 13–14 (blank import): stdlib dari pgx memanggil sql.Register("pgx", ...) di init(), sehingga nama driver siap dipakai sql.Open tanpa variabel tambahan. Tanpa import ini, sql.Open mengembalikan galat unknown driver "pgx" (forgotten import?).
  2. Baris 20–21 (//go:embed migrations/*.sql): direktif ini menyalin seluruh SQL ke dalam embed.FS pada saat kompilasi. Image Docker akhir tidak perlu membawa folder migrations.
  3. Baris 26–30 (sql.Open): sql.Open tidak membuka koneksi — ia hanya menyiapkan pool. Kegagalan DSN baru terlihat saat pemakaian pertama.
  4. Baris 32–37 (PingContext dengan tenggat): ping dengan timeout lima detik memaksa kegagalan jaringan muncul saat startup, bukan saat permintaan pertama. db.Close() dipanggil sebelum mengembalikan galat agar tidak ada pool yatim.

Migration Terkunci di Transaksi #

Skema tabel items dideklarasikan dalam satu berkas SQL dengan batasan integritas di level database:

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
);

BIGSERIAL membuat kolom id bertambah otomatis, CHECK (price >= 0) menjaga data tetap masuk akal meski validasi aplikasi bocor, dan TIMESTAMPTZ menyimpan waktu absolut. Runner migration-nya punya tiga sifat penting:

go
// 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
}
  1. Baris 45–50 (tabel schema_migrations): daftar versi yang sudah diterapkan disimpan di database itu sendiri, mengikuti pola chapter Migration .
  2. Baris 52–55 (fs.Glob): hasil glob sudah terurut nama berkas, sehingga 001, 002, 003 dijalankan berurutan tanpa pengurutan tambahan.
  3. Baris 60–70 (periksa versi): migration yang sudah tercatat dilewati — menjalankan binary dua kali aman (idempoten).
  4. Baris 80–96 (transaksi): DDL dan pencatatan versi di-commit bersama. Jika proses mati di tengah, rollback memastikan skema dan daftar versi tidak saling bertentangan.
  5. Baris 85 dan 91 (tx.Rollback eksplisit): pemanggilan sebelum return membuang transaksi gagal; rollback pada transaksi yang sudah di-commit hanya mengembalikan ErrTxDone yang diabaikan.

Dengan konfigurasi tervalidasi, pool terbuka, dan skema siap, lapisan berikutnya mengisi data: domain Item, repository dua implementasi, service, dan handler CRUD yang membentuk API lengkap.

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)
}