Sebelum menulis satu baris handler, kita perlu memutuskan bagaimana kode disusun, karena keputusan arsitektur inilah yang menentukan apakah proyek bisa diuji tanpa PostgreSQL dan dikembangkan tanpa saling menginjak antar bagian. REST API kecil sering ditulis sebagai satu berkas panjang: handler sekaligus query SQL sekaligun validasi. Pendekatan itu cepat di awal, tetapi setiap perubahan skema memaksa kita menyentuh kode HTTP, dan setiap pengujian memaksa kita menyalakan database. Pada bagian ini kita merancang kebalikannya: empat lapisan dengan tanggung jawab tunggal, dijahit oleh dua interface kecil.

Empat Lapisan dan Batasnya #

Alur satu permintaan POST /items dimulai dari http.ServeMux, diteruskan ke ItemHandler, diolah oleh ItemService, lalu disimpan melalui ItemRepository. Setiap lapisan hanya mengenal lapisan di bawahnya melalui tipe Go biasa — bukan melalui global variable — sehingga hubungan antar bagian terlihat jelas di signature fungsi:

LapisanBerkasTanggung jawabDilarang menyentuh
Routingroutes.goMendaftarkan pattern method + pathLogika apa pun
Handlerhandlers.goDecode body, panggil service, encode respons, status codeSQL dan aturan bisnis
Serviceservice.goValidasi, normalisasi, penanda waktu, urutan operasinet/http, SQL
Repositoryrepository.goMenyimpan dan membaca dataJSON, status code

Pemisahan ini adalah penerapan langsung chapter Pemisahan Tanggung Jawab . Manfaat praktisnya konkret: ketika pengujian berjalan tanpa database, service tetap diuji penuh karena repository bisa diganti; ketika klien pindah dari JSON ke bentuk lain, hanya handler yang berubah.

Kontrak API #

Seluruh perilaku layanan diringkas dalam enam endpoint berikut, dan kontrak ini menjadi acuan implementasi maupun pengujian pada bagian-bagian selanjutnya:

Method + PathStatus suksesBody suksesGalat khas
GET /healthz200{"status":"ok"}—
GET /items200{"data":[...],"meta":{"count":n}}500
POST /items201 + Location{"data":{...}}400 validasi, 415, 413
GET /items/{id}200{"data":{...}}400 id, 404
PUT /items/{id}200{"data":{...}}400, 404
DELETE /items/{id}204 tanpa body—400, 404

Konvensi bentuk body mengikuti chapter Response Format : sukses dibungkus field data, galat dibungkus field error dengan kode galat stabil. Harga disimpan sebagai int64 rupiah utuh, bukan float64, agar bebas galat pembulatan — keputusan domain yang dibahas di chapter Domain Modeling .

Dua Interface Kunci #

Seluruh ketertukaran lapisan lahir dari dua deklarasi kecil. Yang pertama adalah kontrak penyimpanan:

go
// 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
}

Baris 16–22 mendeklarasikan method yang dibutuhkan service: buat, baca satu, baca semua, ubah, dan hapus. Perhatikan dua hal. Pertama, setiap method menerima context.Context sebagai parameter pertama sehingga timeout dan pembatalan permintaan mengalir sampai ke query PostgreSQL — materi chapter Context dan Query Timeout . Kedua, interface ini tidak menyebut SQL maupun HTTP, sehingga dua implementasi berbeda bisa hidup berdampingan: PostgresItemRepository untuk produksi dan MemoryItemRepository untuk unit test.

Entitas yang mengalir lewat kontrak tersebut berbentuk struct domain murni:

go
// 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"`
}

Struct Item memakai tag JSON agar handler bisa langsung meng-encode-nya, tetapi struct ini tetap tidak mengenal net/http. Field ID dan CreatedAt sengaja tidak diterima dari klien — keduanya diisi oleh sistem — itulah alasan masukan klien memakai struct terpisah ItemInput.

Interface kedua adalah sumber waktu:

go
// 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 }

Clock terlihat sederhana, tetapi perannya vital untuk pengujian: service memanggil s.clock.Now() ketika membuat item, sehingga test cukup menginjeksi FixedClock untuk membekukan created_at. Tanpa interface ini, output test akan berbeda setiap detiknya dan tidak bisa dibandingkan. Pola yang sama — dependensi kecil diinjeksi lewat constructor — dibahas tuntas di chapter Dependency Injection .

Struktur Berkas Proyek #

text
rest-api-postgresql/
├── main.go              # wiring: config → db → migration → repo → service → server
├── config.go            # Load() dari environment + validasi
├── database.go          # sql.Open pgx, ping, RunMigrations
├── models.go            # Item, ItemInput, Validasi()
├── repository.go        # ItemRepository + Postgres + Memory
├── service.go           # ItemService, Clock, aturan bisnis
├── handlers.go          # ItemHandler, decode, envelope respons
├── routes.go            # NewRouter: pattern ServeMux
├── errors.go            # ErrNotFound, ValidationError, body galat
├── demo.go              # skenario demo offline
├── migrations/
│   └── 001_create_items.sql
├── api_test.go          # unit test handler lewat httptest
├── config_test.go       # unit test Load()
├── integration_test.go  # skip tanpa TEST_DATABASE_URL
├── Dockerfile           # multi-stage golang → distroless
├── compose.yaml         # postgres:17 + api
└── .env.example         # contoh DSN lokal

Seluruh kode hidup di satu package main — wajar untuk proyek sebesar ini. Ketika berkas mulai bertambah puluhan, pemisahan package mengikuti batas lapisan yang sama: handler, service, repository. Material mengenai kapan memecah package dibahas di chapter Layered Architecture .

Dengan kontrak dan struktur yang tetap, bagian berikutnya mulai mengisi kode dari lapisan paling luar ke dalam: konfigurasi yang tervalidasi, pool koneksi pgx, dan migration yang tertanam di binary.

Referensi #

  • Effective Go — prinsip komposisi dan interface idiomatik.
  • http.ServeMux — pattern routing yang dipakai routes.go.
  • Context — kontrak ctx yang mengalir sampai repository.

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