Proyek ini menggabungkan materi routing, validasi, database, dan testing ke dalam satu REST API yang utuh dan siap dipakai: layanan manajemen item dengan penyimpanan PostgreSQL, migration otomatis, penanganan galat yang konsisten, container Docker multi-stage, serta integration test yang hanya berjalan ketika database tersedia. Seluruh berkas sumber proyek tersedia di bagian bawah halaman ini dan dapat dijalankan langsung tanpa menyalin ulang. Kita akan menyusunnya dalam lima tahap, dari arsitektur sampai pengujian end-to-end.

Kebutuhan runtime sengaja dipisah menjadi dua mode. Mode demo menjalankan seluruh endpoint di atas penyimpanan memori sehingga bisa dicoba tanpa PostgreSQL — berguna untuk belajar dan memverifikasi wiring. Mode server membuka pool koneksi PostgreSQL, menjalankan migration, lalu melayani permintaan sungguhan dengan graceful shutdown.

Arsitektur Proyek #

Permintaan HTTP mengalir melalui empat lapisan yang masing-masing memikul satu tanggung jawab:

Handler tidak menyentuh SQL, service tidak menyentuh net/http, dan repository tidak mengenal JSON — pemisahan inilah yang membuat setiap lapisan bisa diuji terpisah dan repository PostgreSQL bisa ditukar dengan penyimpanan memori tanpa mengubah satu baris handler. Pembahasan mendalam pola ini tersedia di chapter Pemisahan Tanggung Jawab dan Repository Pattern .

Prasyarat #

Sebelum mulai, pastikan konsep berikut sudah dikenal karena proyek ini memakainya secara langsung:

MateriDipakai untuk
Routing dan HandlerPattern GET /items/{id} dan handler tipis
Request ValidationPipeline decode, batas body, dan validasi
Response FormatEnvelope {"data":...} dan body galat
Koneksi DatabaseDSN, sql.Open, dan ping
MigrationSkema versi dan tabel schema_migrations
Repository PatternInterface penyimpanan dan fake in-memory
Unit Test dan httptestTable-driven test tanpa port manual

Menjalankan Proyek #

bash
cd content/go/proyek/rest-api-postgresql

# 1. Mode demo: seluruh endpoint di memori, tanpa PostgreSQL.
go run . demo

# 2. Unit test offline; integration test otomatis di-skip.
go test ./...

# 3. Mode server: butuh PostgreSQL aktif (lihat .env.example).
DATABASE_URL=postgres://user:pass@localhost:5432/items?sslmode=disable go run .

# 4. Atau bungkus aplikasi + database dalam satu perintah.
docker compose up --build

Peta Berkas #

BerkasPeran
config.goBaca dan validasi environment variable (fail-fast)
database.goBuka pool pgx, ping, dan jalankan migration dari embed
migrations/001_create_items.sqlDDL tabel items
models.goStruct domain Item dan ItemInput beserta validasinya
repository.goInterface ItemRepository, implementasi PostgreSQL dan memori
service.goItemService, Clock, dan aturan bisnis
handlers.goHandler HTTP, decode JSON, dan envelope respons
routes.goRegistrasi pattern ServeMux
errors.goSentinel galat dan pemetaan ke body {"error":{...}}
main.goWiring dependensi, server, dan graceful shutdown
demo.goSkenario demo offline dengan httptest
api_test.go, config_test.goUnit test dengan memory repository
integration_test.goIntegration test; skip bila TEST_DATABASE_URL kosong

Apa yang Dibangun #

Hasil akhir proyek adalah API dengan kontrak berikut: GET /healthz untuk liveness, GET /items untuk daftar, POST /items untuk membuat (201 + Location), GET /items/{id} untuk membaca, PUT /items/{id} untuk mengganti, dan DELETE /items/{id} untuk menghapus (204). Semua body sukses dibungkus field data, semua body galat memakai field error dengan kode stabil seperti VALIDATION_ERROR dan NOT_FOUND. Harga disimpan sebagai bilangan bulat rupiah, dan created_at diisi sistem — bukan klien.

Setiap keputusan teknis proyek ini bisa ditelusuri ke chapter asalnya: pattern routing dari Routing dan Handler , pipeline validasi dari Request Validation , migration dari Migration , graceful shutdown dari Graceful Shutdown Production , dan pengujian repository palsu dari Repository Pattern . Jika sebuah bagian terasa asing, tautan tersebut adalah tempat berhenti sebelum melanjutkan.

Lima bagian berikut membahas setiap tahap secara berurutan — mulai dari keputusan arsitektur, konfigurasi dan database, implementasi CRUD di seluruh lapisan, validasi dan galat, sampai integration test dan pengemasan Docker. Kode yang sama dibedah bertahap, sehingga tidak ada berkas yang berubah antar bagian.

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