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:
| Lapisan | Berkas | Tanggung jawab | Dilarang menyentuh |
|---|---|---|---|
| Routing | routes.go | Mendaftarkan pattern method + path | Logika apa pun |
| Handler | handlers.go | Decode body, panggil service, encode respons, status code | SQL dan aturan bisnis |
| Service | service.go | Validasi, normalisasi, penanda waktu, urutan operasi | net/http, SQL |
| Repository | repository.go | Menyimpan dan membaca data | JSON, 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 + Path | Status sukses | Body sukses | Galat khas |
|---|---|---|---|
GET /healthz | 200 | {"status":"ok"} | — |
GET /items | 200 | {"data":[...],"meta":{"count":n}} | 500 |
POST /items | 201 + 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:
// 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:
// 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:
// 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 #
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 lokalSeluruh 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
ctxyang mengalir sampai repository.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
api_test.go
Unduh berkaspackage 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 berkasservices:
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 berkaspackage 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 berkaspackage 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 berkaspackage 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 berkaspackage 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 berkaspackage 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 berkaspackage 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 berkaspackage 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 berkaspackage 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-- 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 berkaspackage 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 berkaspackage 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 berkaspackage 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 berkaspackage 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)
}