Progress belajar
Modul 26 dari 73
0% 0/73 modul selesai
Setelah selesai, tandai modul ini agar progres kursus tetap rapi.
Progress disimpan lokal di browser ini.
Membaca Data
dengan pgx
Dari query SELECT ke struct Go yang siap dipakai handler, service, dan response API skincare, termasuk order bersarang tanpa jebakan N+1.
Dari SELECT ke struct Go
Pool yang sudah ada, sekarang dipakai membaca katalog
Di chapter sebelumnya kita sudah membuat pgxpool.Pool dan memverifikasinya dengan Ping. Sekarang pool itu mulai bekerja: membaca produk, varian, dan order dari PostgreSQL ke struct Go.
Di React atau Laravel, membaca data sering terasa seperti memanggil satu fungsi yang langsung mengembalikan object. Di Go dengan pgxpool, prosesnya lebih eksplisit dan punya tiga langkah yang jelas: kirim SQL, ambil baris, lalu Scan kolom ke field struct. Tidak ada lapisan sihir di tengah yang menebak relasi atau nama kolom untukmu.
Itu terasa lebih “manual”, tetapi justru di situ kekuatannya. Setiap query yang dieksekusi backend skincare terlihat apa adanya, bisa di-EXPLAIN, bisa di-index, dan bisa dites. Tidak ada query tersembunyi yang muncul karena kamu mengakses sebuah properti relasi tanpa sengaja.
PostgreSQL mendefinisikan SELECT sebagai perintah mengambil baris dari satu atau lebih tabel, dengan filter WHERE, urutan ORDER BY, dan batas LIMIT. Tiga klausa itulah yang akan kita pakai berulang kali untuk endpoint katalog, detail produk, dan riwayat order.
/v1/products Membaca banyak produk untuk katalog, dengan filter dan pagination /v1/products/{id} Membaca satu produk untuk halaman detail /v1/orders/{id} Membaca satu order beserta item-nya untuk halaman riwayat Tiga endpoint inilah target konkret chapter ini. Di akhir modul, repository product punya GetProductByID dan ListProducts, dan repository order punya GetOrderByID yang mengembalikan order beserta seluruh order_items-nya dalam satu struct bersarang, tanpa membombardir database dengan puluhan query kecil.
- internal/
- product/
- model.go struct Product, ProductVariant, filter query
- repository.go implementasi SELECT dengan pgxpool
- service.go aturan bisnis dan not found error
- order/
- model.go struct Order dan OrderItem (bersarang)
- repository.go GetOrderByID dengan join, anti N+1
- database/
- postgres.go setup pgxpool dari chapter sebelumnya
Semua nama tabel dan kolom (products, product_variants, price_rupiah, order_items.unit_price_rupiah) mengikuti skema kanonik proyek. Uang selalu bigint rupiah (price_rupiah), PK selalu id bigint, dan tidak ada kolom _cents atau float untuk uang di mana pun.
Mental Model: ORM vs pgx
pgx bukan ORM, dan itu disengaja
pgx bukan ORM. Ia tidak menebak relasi, tidak otomatis meng-hydrate struct, dan tidak menyembunyikan SQL. Kamu menulis SQL, kamu menentukan mapping.
Buat developer yang datang dari Eloquent atau Prisma, ini perubahan cara berpikir yang paling besar di Roadmap 3. Di ORM, satu pemanggilan finder bisa diam-diam menjalankan beberapa query (satu untuk model utama, beberapa lagi untuk relasi yang diakses). Di pgx, satu pemanggilan Query adalah tepat satu round-trip ke database, dan kamu yang menentukan SQL-nya.
Product::with('variants')->find($id)menggabungkan query, mapping, dan model behavior dalam satu baris.- Kolom dan relasi dipetakan otomatis dari konvensi penamaan.
- Query yang benar-benar jalan sering tersembunyi (lazy load, eager load).
Product::find($id)mengembalikan model ataunull.
pool.QueryRow(ctx, sql, id).Scan(...)membuat SQL dan mapping terlihat jelas.- Kamu menulis daftar kolom, urutan
Scan, dan tipe Go-nya sendiri. - Satu pemanggilan = satu round-trip; tidak ada query mengejutkan.
- Tidak ditemukan adalah
pgx.ErrNoRowsyang harus kamu tangani eksplisit.
prisma.product.findMany({ where, take, skip }) menghasilkan SQL di belakang layar lalu mengembalikan array object yang sudah jadi. Di pgx, kamu menulis SELECT ... WHERE ... LIMIT ... OFFSET ... sendiri, lalu memetakan tiap baris ke struct. Lebih verbose, tetapi SQL-nya milikmu sepenuhnya: bisa diaudit, dioptimalkan, dan tidak pernah mengejutkanmu dengan query tersembunyi.
Saat fetch().then(r => r.json()), JavaScript mem-parsing JSON jadi object secara otomatis berdasarkan nama key. Scan di pgx adalah versi manual dan positional dari itu: kamu menyodorkan pointer ke field tujuan, satu per satu, sesuai urutan kolom SELECT. Tidak ada pencocokan nama. Yang menentukan kemana sebuah nilai mendarat adalah posisi, bukan label.
Proses memindahkan nilai kolom hasil query ke field struct Go. Pada Scan manual, mapping bersifat positional: urutan argumen Scan harus sama persis dengan urutan kolom di SELECT. Pada helper RowToStructByName, mapping bersifat by-name lewat tag db:"...".
Ada tiga primitif pgx yang akan kita pakai sepanjang chapter. Memahami kapan memakai yang mana adalah separuh dari pekerjaan.
QueryRow
Untuk query yang diharapkan mengembalikan maksimal satu baris, seperti detail produk by ID. Error baru muncul saat Scan.
Query
Untuk query yang mengembalikan banyak baris, seperti katalog dengan pagination. Mengembalikan pgx.Rows yang wajib ditutup.
Scan
Memindahkan kolom PostgreSQL ke variable atau field struct Go secara positional. Dipanggil pada pgx.Row maupun pgx.Rows.
Setiap method pgx (QueryRow, Query, Exec) menerima context.Context sebagai argumen pertama. Di handler, oper r.Context() agar query ikut dibatalkan ketika client memutus koneksi. Ini idiom Go yang sama dengan yang sudah kamu pakai di middleware Roadmap 2.
Membaca Satu Baris dengan QueryRow
Satu resource, satu object
Gunakan pool.QueryRow(ctx, sql, args...) saat endpoint hanya butuh satu resource, misalnya detail produk dari ID.
QueryRow mengembalikan pgx.Row. Yang sering bikin kaget developer baru: error query tidak langsung keluar dari QueryRow, melainkan ditunda sampai Scan dipanggil. Jadi pola error handling-nya selalu berada di sebelah Scan, bukan di sebelah QueryRow.
internal/product/repository.gofunc (r *PostgresRepository) GetProductByID(ctx context.Context, id int64) (*Product, error) { const query = ` SELECT p.id, p.brand_id, p.slug, p.name, p.description, p.status, p.created_at, p.updated_at FROM products p WHERE p.id = $1 AND p.deleted_at IS NULL LIMIT 1 ` var product Product err := r.pool.QueryRow(ctx, query, id).Scan( &product.ID, &product.BrandID, &product.Slug, &product.Name, &product.Description, &product.Status, &product.CreatedAt, &product.UpdatedAt, ) if err != nil { return nil, err } return &product, nil }
Perhatikan pemakaian placeholder $1. PostgreSQL dan pgx memakai placeholder posisi $1, $2, $3, bukan ? ala MySQL. Nilai id tidak pernah digabung ke string SQL; ia dikirim terpisah lewat argumen Query, sehingga aman dari SQL injection secara default.
Urutan field di Scan harus sama dengan urutan kolom di SELECT. pgx tidak membaca nama field struct untuk mapping manual ini, ia hanya memindahkan kolom ke-1 ke argumen ke-1, dan seterusnya. Tukar satu baris saja, dan slug bisa mendarat di name.
SELECT * membuat urutan kolom rapuh: begitu sebuah migration menambah atau menyusun ulang kolom, urutan Scan ikut bergeser dan mapping diam-diam jadi salah. Selalu sebut kolom secara eksplisit agar kontrak Scan stabil terhadap perubahan skema.
Diagram berikut menunjukkan empat tahap yang terjadi saat satu detail produk dibaca, dari handler sampai database dan kembali lagi.
sequenceDiagram participant Handler as HTTP Handler participant Service as Product Service participant Repo as Product Repository participant DB as PostgreSQL Handler->>Service: GetProductByID(ctx, id) Service->>Repo: GetProductByID(ctx, id) Repo->>DB: QueryRow(ctx, SELECT ... WHERE id = $1, id) DB-->>Repo: satu row (atau ErrNoRows) Repo->>Repo: Scan kolom -> field Product Repo-->>Service: *Product atau error Service-->>Handler: hasil siap jadi response DTO
Gambar 1. QueryRow cocok untuk jalur detail produk karena hasilnya satu object, bukan list. Error baru terdeteksi saat Scan, bukan saat QueryRow.
Menangani Data Tidak Ditemukan
ErrNoRows bukan kegagalan database
Saat query satu baris tidak menemukan data, pgx mengembalikan pgx.ErrNoRows dari Scan. Ini sinyal “tidak ada”, bukan sinyal “database rusak”.
Di Laravel, find mengembalikan null, sedangkan findOrFail melempar ModelNotFoundException yang otomatis jadi HTTP 404. Di Go tidak ada otomatisasi itu: kamu memutuskan sendiri bagaimana repository melaporkan “tidak ditemukan”, lalu service menerjemahkannya jadi error domain yang dipahami handler.
find($id)mengembalikannullsaat tidak ada.findOrFail($id)melempar exception yang jadi 404 otomatis.- Perilaku not-found ditentukan method yang kamu pilih.
Scanmengembalikanpgx.ErrNoRowssaat tidak ada baris.- Repository menerjemahkannya jadi
nil, nilatau error domain. - Tidak ada panic; semua jalur kembali lewat nilai error.
Ada dua pola yang sama-sama idiomatik. Pertama, repository mengembalikan (nil, nil) untuk “tidak ada”, lalu service mengubahnya jadi ErrProductNotFound. Kedua, repository langsung mengembalikan error domain. Kita pakai pola pertama agar repository tetap netral terhadap kebijakan HTTP.
internal/product/repository.gofunc (r *PostgresRepository) GetProductByID(ctx context.Context, id int64) (*Product, error) { var product Product err := r.pool.QueryRow(ctx, getProductByIDSQL, id).Scan( &product.ID, &product.BrandID, &product.Slug, &product.Name, &product.Description, &product.Status, &product.CreatedAt, &product.UpdatedAt, ) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, nil // tidak ditemukan, bukan error database } return nil, fmt.Errorf("get product by id: %w", err) } return &product, nil }
Service menerima (nil, nil) itu lalu memutuskan bahwa “produk tidak ada” adalah kondisi domain yang punya error sendiri. Handler nanti memetakan ErrProductNotFound ke HTTP 404 dengan kode product_not_found, persis seperti yang sudah dirancang di kontrak API Roadmap 2.
internal/product/service.govar ErrProductNotFound = errors.New("product not found") type Service struct { repo Repository } func (s *Service) Detail(ctx context.Context, id int64) (*Product, error) { product, err := s.repo.GetProductByID(ctx, id) if err != nil { return nil, err } if product == nil { return nil, ErrProductNotFound } return product, nil }
nil, nil cocok saat “tidak ditemukan” bukan kegagalan database, dan kamu ingin layer di atasnya yang menentukan artinya. Repository melapor secara netral, service yang memutuskan apakah itu 404, daftar kosong, atau langkah berikutnya. Pemisahan ini menjaga repository bebas dari kebijakan HTTP.
Pakai errors.Is(err, pgx.ErrNoRows), bukan err.Error() == "no rows in result set". Perbandingan string rapuh terhadap wrapping dengan %w, dan pesannya bisa berubah antar versi. errors.Is menelusuri rantai wrap dan tetap cocok meski error sudah dibungkus berlapis.
pgx.ErrNoRows adalah proxy dari database/sql.ErrNoRows. Artinya errors.Is(err, pgx.ErrNoRows) dan errors.Is(err, sql.ErrNoRows) sama-sama cocok. Kalau kode lamamu masih memeriksa sql.ErrNoRows, ia tetap jalan saat pindah ke pgx.
Membaca Banyak Baris dengan Query
Lifecycle Rows yang wajib dijaga
Gunakan pool.Query(ctx, sql, args...) untuk daftar produk, riwayat order, review, dan hasil pencarian. Ia mengembalikan pgx.Rows yang punya lifecycle ketat.
Inilah bagian yang paling sering jadi sumber bug awal. pgx.Rows memegang sebuah koneksi dari pool selama belum ditutup. Selama Rows masih terbuka, koneksi itu tidak bisa dikembalikan dan dipakai query lain. Lupa menutupnya, dan saat traffic naik pool terasa “habis” padahal database baik-baik saja.
Pola amannya selalu sama: cek error dari Query dulu, baru defer rows.Close(). Urutan ini penting, karena kalau Query gagal, rows bisa nil dan defer pada nil aman, tetapi cek error duluan membuat alurnya jelas.
internal/product/repository.gofunc (r *PostgresRepository) listVariants(ctx context.Context, productID int64) ([]ProductVariant, error) { const query = ` SELECT pv.id, pv.product_id, pv.sku, pv.variant_name, pv.size_label, pv.shade, pv.price_rupiah, pv.compare_at_price_rupiah, pv.weight_grams, pv.is_active, pv.created_at, pv.updated_at FROM product_variants pv WHERE pv.product_id = $1 AND pv.is_active = true ORDER BY pv.price_rupiah ASC ` rows, err := r.pool.Query(ctx, query, productID) if err != nil { return nil, fmt.Errorf("query variants: %w", err) } defer rows.Close() variants := make([]ProductVariant, 0, 4) for rows.Next() { var v ProductVariant if err := rows.Scan( &v.ID, &v.ProductID, &v.SKU, &v.VariantName, &v.SizeLabel, &v.Shade, &v.PriceRupiah, &v.CompareAtPriceRupiah, &v.WeightGrams, &v.IsActive, &v.CreatedAt, &v.UpdatedAt, ); err != nil { return nil, fmt.Errorf("scan variant: %w", err) } variants = append(variants, v) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate variants: %w", err) } return variants, nil }
Dua hal di sini wajib jadi refleks. Pertama, defer rows.Close() segera setelah error Query aman. Kedua, rows.Err() setelah loop selesai. Loop for rows.Next() berhenti baik karena baris habis maupun karena error di tengah jalan, dan keduanya membuat Next() mengembalikan false. Hanya rows.Err() yang bisa membedakan keduanya.
Di JS kamu menulis rows.map(r => toProduct(r)) di atas array yang sudah lengkap di memori. Di pgx, rows.Next() adalah cursor yang berjalan maju satu baris setiap iterasi, dan baris berikutnya bisa saja belum tiba dari database. Karena streaming inilah error bisa muncul di tengah, dan karena itulah rows.Err() setelah loop tidak boleh dilewati.
Flowchart berikut memperjelas alur loop, termasuk kenapa cek rows.Err() setelah loop adalah cabang yang mudah terlupa tetapi krusial.
flowchart TD
START["pool.Query(ctx, sql, args)"] --> QERR{"err != nil?"}
QERR -->|ya| FAIL["return error query"]
QERR -->|tidak| DEFER["defer rows.Close()"]
DEFER --> NEXT{"rows.Next() true?"}
NEXT -->|ya| SCAN["rows.Scan(&dst...)"]
SCAN --> SERR{"scan error?"}
SERR -->|ya| FAIL2["return error scan"]
SERR -->|tidak| APPEND["append ke slice"]
APPEND --> NEXT
NEXT -->|tidak| RERR{"rows.Err() != nil?"}
RERR -->|ya| FAIL3["return error iterasi"]
RERR -->|tidak| OK["return slice hasil"]Gambar 2. Loop rows.Next punya tiga gerbang error: error dari Query, error per baris dari Scan, dan error iterasi dari rows.Err. Loop berhenti tanpa keluhan saat error iterasi, jadi rows.Err() adalah satu-satunya cara mengetahuinya.
QueryKirim SQL dan parameter ke PostgreSQL lewat pool. Cek error-nya lebih dulu.
defer rows.Close()Pastikan koneksi kembali ke pool apa pun yang terjadi setelahnya.
rows.NextSetiap iterasi mewakili satu baris yang siap di-Scan ke struct.
rows.ErrSetelah loop selesai, pastikan tidak ada error iterasi yang menyamar sebagai “baris habis”.
Pemetaan Tipe PostgreSQL ke Go
Apa yang ditampung tiap field Scan
Scan hanya berhasil kalau tipe Go di sisi penerima cocok dengan tipe kolom PostgreSQL. Mengetahui pemetaan ini menghemat banyak waktu debugging.
pgx mengubah representasi biner PostgreSQL menjadi tipe Go yang masuk akal. Sebagian besar pemetaan intuitif (bigint jadi int64, text jadi string), tetapi beberapa punya kejutan, terutama kolom yang bisa NULL dan tipe khas PostgreSQL seperti array dan jsonb yang kita pakai di tabel products.
| Kolom PostgreSQL | Tipe Go (NOT NULL) | Tipe Go (nullable) | Contoh di skema skincare |
|---|---|---|---|
bigint | int64 | *int64 / pgtype.Int8 | products.id, price_rupiah |
integer | int32 / int | *int32 / pgtype.Int4 | inventories.quantity_available |
text / varchar | string | *string / pgtype.Text | products.name, slug |
boolean | bool | *bool / pgtype.Bool | product_variants.is_active |
timestamptz | time.Time | *time.Time / pgtype.Timestamptz | created_at, deleted_at |
text[] | []string | []string (NULL jadi nil) | products.skin_types, concerns |
jsonb | []byte / struct via json | []byte | products.ingredients, orders.shipping_address |
numeric | pgtype.Numeric | pgtype.Numeric | tidak dipakai untuk uang di proyek ini |
Di proyek ini uang selalu bigint rupiah penuh: price_rupiah, unit_price_rupiah, total_rupiah. Maka di Go selalu int64, tidak pernah float64 apalagi pgtype.Numeric. Memetakan rupiah ke float64 membuka pintu galat pembulatan yang fatal untuk hitungan uang.
Untuk kolom array text[] seperti products.skin_types (berisi nilai seperti oily, dry, combination), pgx memetakannya langsung ke []string. Kamu tidak perlu mem-parsing string {oily,dry} secara manual; pgx yang menangani format array PostgreSQL.
internal/product/repository.go (membaca kolom array)func (r *PostgresRepository) getProductWithArrays(ctx context.Context, id int64) (*Product, error) { const query = ` SELECT p.id, p.slug, p.name, p.skin_types, p.concerns, p.status FROM products p WHERE p.id = $1 AND p.deleted_at IS NULL ` var product Product err := r.pool.QueryRow(ctx, query, id).Scan( &product.ID, &product.Slug, &product.Name, &product.SkinTypes, // []string menerima text[] langsung &product.Concerns, // []string &product.Status, ) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, nil } return nil, fmt.Errorf("get product with arrays: %w", err) } return &product, nil }
Di TypeScript kamu cukup menulis skinTypes: string[] di interface dan berharap parser JSON mengisinya. Di Go, kamu memilih tipe field ([]string) dan pgx memvalidasi bahwa kolom text[] memang cocok. Kalau kamu salah pasang (misalnya string untuk kolom text[]), Scan langsung error, bukan diam-diam menghasilkan data rusak.
Menangani NULL: Pointer vs pgtype
Kolom yang boleh kosong butuh penampung yang tahu kosong
Scan NULL ke string atau int64 biasa akan error. Kolom yang bisa NULL butuh penampung yang bisa membedakan “nol” dari “tidak ada nilai”.
Ini perbedaan halus yang penting. Go tidak punya null di tipe nilai biasa: string zero value-nya "", int64 zero value-nya 0. Padahal di database, NULL berbeda dari "" dan dari 0. Kolom product_variants.size_label bisa NULL (varian tanpa label ukuran), dan compare_at_price_rupiah bisa NULL (produk tanpa harga coret).
Ada dua cara idiomatik menampung NULL. Pakai pointer Go (*string, *int64), di mana nil berarti NULL. Atau pakai wrapper pgtype (pgtype.Text, pgtype.Int8) yang membawa flag Valid bool.
nilberarti NULL, non-nil berarti ada nilai.- Ringan dan langsung jadi JSON
nullsaat di-marshal. - Sempurna untuk struct domain dan response API.
- Punya field
Valid boolplus nilainya (Text.String,Int8.Int64). - Lebih eksplisit, tidak perlu dereference pointer.
- Cocok saat butuh kontrol penuh, mis. menulis NULL balik ke DB.
Di proyek skincare kita memilih pointer untuk struct domain, karena pointer otomatis jadi null di JSON response, sejalan dengan tag seperti json:"size_label,omitempty".
internal/product/model.gopackage product import "time" type Product struct { ID int64 `json:"id"` BrandID int64 `json:"brand_id"` Slug string `json:"slug"` Name string `json:"name"` Description string `json:"description"` SkinTypes []string `json:"skin_types"` Concerns []string `json:"concerns"` Status string `json:"status"` // draft, active, archived CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` DeletedAt *time.Time `json:"deleted_at,omitempty"` } type ProductVariant struct { ID int64 `json:"id"` ProductID int64 `json:"product_id"` SKU string `json:"sku"` VariantName string `json:"variant_name"` SizeLabel *string `json:"size_label,omitempty"` // text NULL Shade *string `json:"shade,omitempty"` // text NULL PriceRupiah int64 `json:"price"` // bigint NOT NULL CompareAtPriceRupiah *int64 `json:"compare_at_price,omitempty"` // bigint NULL WeightGrams *int `json:"weight_grams,omitempty"` // integer NULL IsActive bool `json:"is_active"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` }
Kalau size_label di-Scan ke string biasa dan baris itu NULL, pgx mengembalikan error “cannot scan NULL into *string”. Penampung untuk kolom nullable harus pointer (*string) atau pgtype. Jangan tergoda memberi setiap kolom tipe pointer, hanya kolom yang benar-benar bisa NULL.
Kalau suatu saat kamu lebih suka gaya pgtype (misalnya ingin field Valid eksplisit tanpa dereference pointer), pola Scan-nya seperti ini.
internal/product/repository.go (varian pgtype)import "github.com/jackc/pgx/v5/pgtype" func (r *PostgresRepository) variantPrice(ctx context.Context, id int64) (int64, *int64, error) { var price int64 var compareAt pgtype.Int8 // bigint nullable, punya Valid bool err := r.pool.QueryRow(ctx, `SELECT price_rupiah, compare_at_price_rupiah FROM product_variants WHERE id = $1`, id, ).Scan(&price, &compareAt) if err != nil { return 0, nil, fmt.Errorf("variant price: %w", err) } if compareAt.Valid { v := compareAt.Int64 return price, &v, nil } return price, nil, nil }
Di JavaScript, price ?? null membedakan nilai yang ada dari yang absen, karena null dan 0 itu berbeda. Go tidak punya null di int64, jadi 0 ambigu: bisa berarti harga nol atau “tidak diisi”. Pointer *int64 (atau pgtype.Int8) mengembalikan kemampuan membedakan keduanya, sama seperti null di JS.
Helper Modern: CollectRows dan RowToStructByName
pgx v5 memangkas boilerplate Scan
Menulis Scan positional untuk tiap struct itu eksplisit, tetapi melelahkan untuk struct lebar. pgx v5 punya helper generik yang memetakan baris ke struct by-name lewat tag db.
Sejak pgx v5, ada keluarga fungsi generik yang menggabungkan loop rows.Next + Scan + append menjadi satu pemanggilan. Yang paling sering dipakai adalah pgx.CollectRows (untuk slice) dan pgx.CollectOneRow (untuk satu baris), dikombinasikan dengan pgx.RowToStructByName[T] yang mencocokkan kolom ke field struct lewat tag db:"...".
internal/product/model.go (tambah tag db)type Product struct { ID int64 `json:"id" db:"id"` BrandID int64 `json:"brand_id" db:"brand_id"` Slug string `json:"slug" db:"slug"` Name string `json:"name" db:"name"` Description string `json:"description" db:"description"` Status string `json:"status" db:"status"` CreatedAt time.Time `json:"created_at" db:"created_at"` UpdatedAt time.Time `json:"updated_at" db:"updated_at"` }
Dengan tag db terpasang, mapping jadi by-name. Urutan kolom SELECT tidak lagi harus sinkron dengan urutan field, asalkan nama kolom hasil query cocok dengan tag db.
internal/product/repository.go (gaya helper)func (r *PostgresRepository) ListProductsModern(ctx context.Context, limit, offset int32) ([]Product, error) { const query = ` SELECT p.id, p.brand_id, p.slug, p.name, p.description, p.status, p.created_at, p.updated_at FROM products p WHERE p.deleted_at IS NULL AND p.status = 'active' ORDER BY p.created_at DESC LIMIT $1 OFFSET $2 ` rows, err := r.pool.Query(ctx, query, limit, offset) if err != nil { return nil, fmt.Errorf("list products: %w", err) } products, err := pgx.CollectRows(rows, pgx.RowToStructByName[Product]) if err != nil { return nil, fmt.Errorf("collect products: %w", err) } return products, nil }
Perhatikan: CollectRows menangani loop, Scan, rows.Err(), dan rows.Close() di dalamnya. Untuk satu baris, pgx.CollectOneRow mengembalikan pgx.ErrNoRows saat kosong, jadi penanganan not-found tetap sama persis.
internal/product/repository.go (satu baris dengan helper)func (r *PostgresRepository) GetProductByIDModern(ctx context.Context, id int64) (*Product, error) { rows, err := r.pool.Query(ctx, getProductByIDSQL, id) if err != nil { return nil, fmt.Errorf("query product: %w", err) } product, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[Product]) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, nil } return nil, fmt.Errorf("collect product: %w", err) } return &product, nil }
- Urutan
Scanwajib sinkron dengan urutan kolom. - Kontrol penuh, cocok untuk join kompleks atau kolom hasil agregasi.
- Lebih banyak baris kode untuk struct lebar.
- Mapping by-name lewat tag
db, tahan terhadap urutan kolom. - Boilerplate loop hilang, lebih ringkas dan sulit salah urutan.
- Butuh tag
dbdan kolom hasil query harus cocok namanya.
Untuk SELECT lurus dari satu tabel, helper RowToStructByName paling nyaman. Untuk query dengan join, alias kolom, dan kolom agregasi seperti MIN(pv.price_rupiah), Scan manual sering lebih jujur karena kolom hasilnya bukan cerminan satu tabel. Tidak ada keharusan memilih satu untuk seluruh repository.
RowToStructByName adalah versi terkontrol dari auto-hydration ORM: ia mencocokkan kolom ke field berdasarkan nama, sama seperti Eloquent mengisi atribut model. Bedanya, kamu tetap menulis SQL-nya sendiri dan tag db membuat pencocokan eksplisit, bukan tebakan konvensi.
Filter, Pagination, dan SQL Aman
Optional filter tanpa membuka celah injection
Katalog butuh filter opsional (slug, kategori) dan pagination. Tantangannya: menyusun SQL dinamis yang tetap memakai placeholder, bukan menggabung nilai user ke string.
Aturan emasnya: yang boleh disusun dinamis adalah potongan SQL tetap (menambah klausa AND c.slug = $N), sedangkan nilai user selalu masuk lewat args. PostgreSQL menerima parameter terpisah dari teks query, jadi nilai user tidak pernah jadi bagian SQL yang dieksekusi.
Karena katalog menampilkan harga mulai dari (starting price) sementara harga ada di tabel product_variants, query list menggabungkan products, brands, dan product_variants, lalu mengambil MIN(price_rupiah) per produk. Kategori dihubungkan lewat join table product_categories (relasi many-to-many).
query-list-products.sqlSELECT p.id, p.slug, p.name, p.description, b.name AS brand_name, COALESCE(MIN(pv.price_rupiah), 0)::bigint AS starting_price_rupiah, p.status, p.created_at, p.updated_at FROM products p JOIN brands b ON b.id = p.brand_id JOIN product_categories pc ON pc.product_id = p.id JOIN categories c ON c.id = pc.category_id LEFT JOIN product_variants pv ON pv.product_id = p.id AND pv.is_active = true WHERE p.deleted_at IS NULL AND p.status = 'active' AND c.slug = $1 GROUP BY p.id, p.slug, p.name, p.description, b.name, p.status, p.created_at, p.updated_at ORDER BY p.created_at DESC LIMIT $2 OFFSET $3;
Pagination di sini memakai LIMIT dan OFFSET, pasangan paling sederhana dan paling mudah dipahami. LIMIT $2 membatasi jumlah baris, OFFSET $3 melompati baris awal. Untuk halaman 1 dengan 20 item, LIMIT 20 OFFSET 0; halaman 2, LIMIT 20 OFFSET 20.
prisma.product.findMany({ take: 20, skip: 20 }) menghasilkan tepat LIMIT 20 OFFSET 20. take adalah LIMIT, skip adalah OFFSET. Konsepnya identik; di pgx kamu hanya menulisnya langsung di SQL alih-alih lewat object opsi.
OFFSET 100000 tetap memindai dan membuang 100.000 baris sebelum mengembalikan 20. Untuk katalog yang dalam, ini boros. Solusi yang lebih cepat adalah keyset pagination (WHERE (created_at, id) < ($1, $2) ORDER BY created_at DESC, id DESC LIMIT 20), yang akan kita bahas tuntas di chapter indexing. Untuk sekarang, LIMIT/OFFSET sudah memadai dan kita batasi per_page agar tidak disalahgunakan.
Untuk filter opsional, kita bangun query bertahap. Yang ditambah ke string hanya klausa dengan nomor placeholder; nilainya selalu masuk ke slice args.
internal/product/repository.go (filter dinamis aman)func buildListProductsQuery(filter ListProductsFilter) (string, []any) { var sql strings.Builder args := make([]any, 0, 4) sql.WriteString(productSelectSQL) sql.WriteString("\nWHERE p.deleted_at IS NULL\n\tAND p.status = 'active'\n") if filter.Slug != "" { args = append(args, filter.Slug) fmt.Fprintf(&sql, "\tAND p.slug = $%d\n", len(args)) } if filter.CategorySlug != "" { args = append(args, filter.CategorySlug) fmt.Fprintf(&sql, "\tAND c.slug = $%d\n", len(args)) } sql.WriteString(productGroupBySQL) sql.WriteString("ORDER BY p.created_at DESC\n") args = append(args, clampLimit(filter.Limit)) fmt.Fprintf(&sql, "LIMIT $%d\n", len(args)) args = append(args, clampOffset(filter.Offset)) fmt.Fprintf(&sql, "OFFSET $%d\n", len(args)) return sql.String(), args }
Yang diformat hanya nomor placeholder ($1, $2), bukan nilai user. Nilai filter.Slug dan filter.CategorySlug masuk lewat append(args, ...), lalu dikirim terpisah ke PostgreSQL via pool.Query(ctx, sql, args...). Teks SQL tidak pernah memuat data user, jadi tidak ada celah injection.
fmt.Sprintf("... WHERE slug = '%s'", userSlug) adalah pintu masuk klasik SQL injection. Sekali kamu menyisipkan nilai user ke teks SQL, semua jaminan keamanan placeholder hilang. Nilai user wajib lewat args, titik.
Order Bersarang dan Masalah N+1
Satu order, banyak item, tetap dua query saja
Order detail butuh data dari dua tabel: orders (satu baris) dan order_items (banyak baris). Pertanyaannya: bagaimana memuat keduanya tanpa terjebak N+1 query?
Masalah N+1 adalah jebakan klasik yang dibawa banyak orang dari ORM. Pola buruknya: ambil 1 order, lalu untuk setiap item jalankan 1 query terpisah mengambil detail produk. Untuk order berisi 30 item, itu 1 + 30 = 31 query. Untuk halaman riwayat berisi 20 order, ledakannya makin parah.
Di Laravel, mengakses $order->items di dalam loop tanpa with('items') memicu satu query per order. Di pgx tidak ada lazy loading yang memicu query diam-diam, jadi N+1 hanya terjadi kalau kamu menulisnya sendiri. Justru karena eksplisit, lebih mudah dihindari: kamu sadar setiap query yang kamu tulis.
Pendekatan kita: ambil order dengan 1 QueryRow, lalu ambil semua item-nya dengan 1 Query yang difilter order_id. Dua query, berapa pun jumlah item. Karena order_items sudah menyimpan snapshot (product_name, sku, unit_price_rupiah), kita bahkan tidak perlu join ke products untuk menampilkan order. Snapshot itulah yang membuat invoice lama tidak berubah saat katalog diperbarui.
flowchart LR
REQ["GET /v1/orders/{id}"] --> Q1["QueryRow orders WHERE id = $1"]
Q1 --> ORD["Order (1 baris)"]
ORD --> Q2["Query order_items WHERE order_id = $1"]
Q2 --> ITEMS["[]OrderItem (N baris, 1 query)"]
ITEMS --> NEST["Rakit Order.Items = items"]
NEST --> RESP["Order bersarang -> JSON response"]Gambar 3. Order detail dimuat dengan dua query saja: satu untuk order, satu untuk semua item-nya. Jumlah item tidak menambah jumlah query, jadi tidak ada N+1.
Struct order bersarang mengikuti skema kanonik: order_items membawa snapshot nama dan harga, unit_price_rupiah adalah harga beku saat checkout, dan line_total_rupiah adalah kolom generated yang kita baca apa adanya.
internal/order/model.gopackage order import "time" type Order struct { ID int64 `json:"id"` UserID int64 `json:"user_id"` OrderNumber string `json:"order_number"` Status string `json:"status"` // pending, paid, processing, shipped, completed, cancelled, refunded PaymentStatus string `json:"payment_status"` // unpaid, pending, paid, failed, refunded Currency string `json:"currency"` SubtotalRupiah int64 `json:"subtotal"` DiscountRupiah int64 `json:"discount"` ShippingRupiah int64 `json:"shipping"` TotalRupiah int64 `json:"total"` Items []OrderItem `json:"items"` PlacedAt time.Time `json:"placed_at"` CreatedAt time.Time `json:"created_at"` } type OrderItem struct { ID int64 `json:"id"` OrderID int64 `json:"order_id"` VariantID int64 `json:"variant_id"` ProductName string `json:"name"` // snapshot saat checkout VariantName string `json:"variant_name"` SKU string `json:"sku"` // snapshot saat checkout UnitPriceRupiah int64 `json:"price"` // snapshot saat checkout Quantity int `json:"quantity"` ItemDiscountRupiah int64 `json:"item_discount"` LineTotalRupiah int64 `json:"subtotal"` // kolom generated }
Sekarang repository order. GetOrderByID memuat header order, lalu memanggil listOrderItems untuk seluruh item dalam satu query.
internal/order/repository.gopackage order import ( "context" "errors" "fmt" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) type PostgresRepository struct { pool *pgxpool.Pool } func NewPostgresRepository(pool *pgxpool.Pool) *PostgresRepository { return &PostgresRepository{pool: pool} } func (r *PostgresRepository) GetOrderByID(ctx context.Context, id int64) (*Order, error) { const orderSQL = ` SELECT o.id, o.user_id, o.order_number, o.status, o.payment_status, o.currency, o.subtotal_rupiah, o.discount_rupiah, o.shipping_rupiah, o.total_rupiah, o.placed_at, o.created_at FROM orders o WHERE o.id = $1 ` var ord Order err := r.pool.QueryRow(ctx, orderSQL, id).Scan( &ord.ID, &ord.UserID, &ord.OrderNumber, &ord.Status, &ord.PaymentStatus, &ord.Currency, &ord.SubtotalRupiah, &ord.DiscountRupiah, &ord.ShippingRupiah, &ord.TotalRupiah, &ord.PlacedAt, &ord.CreatedAt, ) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, nil } return nil, fmt.Errorf("get order by id: %w", err) } items, err := r.listOrderItems(ctx, ord.ID) if err != nil { return nil, err } ord.Items = items return &ord, nil } func (r *PostgresRepository) listOrderItems(ctx context.Context, orderID int64) ([]OrderItem, error) { const itemsSQL = ` SELECT oi.id, oi.order_id, oi.variant_id, oi.product_name, oi.variant_name, oi.sku, oi.unit_price_rupiah, oi.quantity, oi.item_discount_rupiah, oi.line_total_rupiah FROM order_items oi WHERE oi.order_id = $1 ORDER BY oi.id ASC ` rows, err := r.pool.Query(ctx, itemsSQL, orderID) if err != nil { return nil, fmt.Errorf("query order items: %w", err) } defer rows.Close() items := make([]OrderItem, 0, 8) for rows.Next() { var it OrderItem if err := rows.Scan( &it.ID, &it.OrderID, &it.VariantID, &it.ProductName, &it.VariantName, &it.SKU, &it.UnitPriceRupiah, &it.Quantity, &it.ItemDiscountRupiah, &it.LineTotalRupiah, ); err != nil { return nil, fmt.Errorf("scan order item: %w", err) } items = append(items, it) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate order items: %w", err) } return items, nil }
Bisa saja kita memuat order plus item dalam satu query join, tetapi hasilnya baris duplikat header order (satu baris per item) yang harus di-deduplikasi di Go. Untuk satu order, pola dua query (1 untuk header, 1 untuk item) lebih bersih dan tetap hanya dua round-trip. Join besar lebih berguna saat memuat banyak order sekaligus.
Kalau kamu perlu memuat banyak order sekaligus (misalnya riwayat 20 order beserta itemnya) tanpa N+1, polanya: ambil 20 order, kumpulkan semua id-nya, lalu satu query WHERE order_id = ANY($1) mengambil semua item dalam sekali jalan, baru kelompokkan item ke order-nya di Go.
internal/order/repository.go (memuat item banyak order tanpa N+1)func (r *PostgresRepository) itemsByOrderIDs(ctx context.Context, orderIDs []int64) (map[int64][]OrderItem, error) { const query = ` SELECT oi.order_id, oi.id, oi.product_name, oi.sku, oi.unit_price_rupiah, oi.quantity, oi.line_total_rupiah FROM order_items oi WHERE oi.order_id = ANY($1) ORDER BY oi.order_id, oi.id ` rows, err := r.pool.Query(ctx, query, orderIDs) if err != nil { return nil, fmt.Errorf("query items by order ids: %w", err) } defer rows.Close() grouped := make(map[int64][]OrderItem, len(orderIDs)) for rows.Next() { var it OrderItem if err := rows.Scan( &it.OrderID, &it.ID, &it.ProductName, &it.SKU, &it.UnitPriceRupiah, &it.Quantity, &it.LineTotalRupiah, ); err != nil { return nil, fmt.Errorf("scan grouped item: %w", err) } grouped[it.OrderID] = append(grouped[it.OrderID], it) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate grouped items: %w", err) } return grouped, nil }
Item::whereIn('order_id', $ids)->get() di Laravel adalah cara eager load menghindari N+1. WHERE order_id = ANY($1) di pgx adalah padanan langsungnya, dengan $1 berupa []int64 yang pgx ubah jadi array PostgreSQL. Setelah datanya datang, kamu kelompokkan ke map[int64][]OrderItem persis seperti collection grouping di Eloquent.
Repository Produk Lengkap
Semua potongan dirakit jadi satu file siap pakai
Sekarang kita satukan: model, SQL konstan, scan helper, builder filter, dan dua method publik menjadi satu repository produk yang siap dipakai service layer.
Kita pakai satu interface kecil rowScanner agar fungsi scanListItem bisa menerima baik pgx.Row (dari QueryRow) maupun pgx.Rows (dari Query), karena keduanya punya method Scan. Ini menghemat duplikasi antara jalur satu-baris dan banyak-baris.
internal/product/repository.gopackage product import ( "context" "errors" "fmt" "strings" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) type Repository interface { GetProductByID(ctx context.Context, id int64) (*Product, error) ListProducts(ctx context.Context, filter ListProductsFilter) ([]ProductListItem, error) } type PostgresRepository struct { pool *pgxpool.Pool } func NewPostgresRepository(pool *pgxpool.Pool) *PostgresRepository { return &PostgresRepository{pool: pool} } type ListProductsFilter struct { Slug string CategorySlug string Limit int32 Offset int32 } const productSelectSQL = ` SELECT p.id, p.slug, p.name, p.description, b.name AS brand_name, COALESCE(MIN(pv.price_rupiah), 0)::bigint AS starting_price_rupiah, p.status, p.created_at, p.updated_at FROM products p JOIN brands b ON b.id = p.brand_id LEFT JOIN product_categories pc ON pc.product_id = p.id LEFT JOIN categories c ON c.id = pc.category_id LEFT JOIN product_variants pv ON pv.product_id = p.id AND pv.is_active = true ` const productGroupBySQL = ` GROUP BY p.id, p.slug, p.name, p.description, b.name, p.status, p.created_at, p.updated_at ` type rowScanner interface { Scan(dest ...any) error } // ProductListItem adalah bentuk baris katalog (produk + brand + harga mulai). type ProductListItem struct { ID int64 Slug string Name string Description string BrandName string StartingPriceRupiah int64 Status string CreatedAt time.Time UpdatedAt time.Time } func scanListItem(row rowScanner) (ProductListItem, error) { var item ProductListItem err := row.Scan( &item.ID, &item.Slug, &item.Name, &item.Description, &item.BrandName, &item.StartingPriceRupiah, &item.Status, &item.CreatedAt, &item.UpdatedAt, ) return item, err } func (r *PostgresRepository) GetProductByID(ctx context.Context, id int64) (*Product, error) { const query = ` SELECT p.id, p.brand_id, p.slug, p.name, p.description, p.skin_types, p.concerns, p.status, p.created_at, p.updated_at FROM products p WHERE p.id = $1 AND p.deleted_at IS NULL LIMIT 1 ` var product Product err := r.pool.QueryRow(ctx, query, id).Scan( &product.ID, &product.BrandID, &product.Slug, &product.Name, &product.Description, &product.SkinTypes, &product.Concerns, &product.Status, &product.CreatedAt, &product.UpdatedAt, ) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, nil } return nil, fmt.Errorf("get product by id: %w", err) } return &product, nil } func (r *PostgresRepository) ListProducts(ctx context.Context, filter ListProductsFilter) ([]ProductListItem, error) { query, args := buildListProductsQuery(filter) rows, err := r.pool.Query(ctx, query, args...) if err != nil { return nil, fmt.Errorf("list products: %w", err) } defer rows.Close() items := make([]ProductListItem, 0, int(clampLimit(filter.Limit))) for rows.Next() { item, err := scanListItem(rows) if err != nil { return nil, fmt.Errorf("scan product list item: %w", err) } items = append(items, item) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate products: %w", err) } return items, nil } func buildListProductsQuery(filter ListProductsFilter) (string, []any) { var sql strings.Builder args := make([]any, 0, 4) sql.WriteString(productSelectSQL) sql.WriteString("WHERE p.deleted_at IS NULL\n\tAND p.status = 'active'\n") if filter.Slug != "" { args = append(args, filter.Slug) fmt.Fprintf(&sql, "\tAND p.slug = $%d\n", len(args)) } if filter.CategorySlug != "" { args = append(args, filter.CategorySlug) fmt.Fprintf(&sql, "\tAND c.slug = $%d\n", len(args)) } sql.WriteString(productGroupBySQL) sql.WriteString("ORDER BY p.created_at DESC\n") args = append(args, clampLimit(filter.Limit)) fmt.Fprintf(&sql, "LIMIT $%d\n", len(args)) args = append(args, clampOffset(filter.Offset)) fmt.Fprintf(&sql, "OFFSET $%d\n", len(args)) return sql.String(), args } func clampLimit(limit int32) int32 { switch { case limit <= 0: return 20 case limit > 100: return 100 default: return limit } } func clampOffset(offset int32) int32 { if offset < 0 { return 0 } return offset }
Karena ProductListItem memakai time.Time untuk CreatedAt dan UpdatedAt, tambahkan "time" ke blok import file ini. Di repo nyata gofmt dan goimports akan merapikannya otomatis saat kamu simpan.
Baris katalog (ProductListItem) berbeda dari detail produk (Product): ia membawa brand_name dan starting_price_rupiah hasil agregasi, tetapi tidak membawa skin_types atau ingredients. Memisahkan tipe list dari tipe detail menjaga setiap query tetap fokus dan response-nya pas dengan kebutuhan layar.
Hands-on: Rakit Query Pertama
Sambungkan repository ke pool dari chapter sebelumnya
Latihan ini menyambungkan repository ke pgxpool.Pool yang sudah kamu buat di chapter koneksi pgx, lalu menjalankan query pertama dari sebuah command kecil.
Buat internal/product/model.go berisi Product, ProductVariant, ProductListItem, dan ListProductsFilter. Pastikan uang memakai int64 dan kolom nullable memakai pointer.
Buat internal/product/repository.go lalu inject *pgxpool.Pool lewat NewPostgresRepository. Mulai dari GetProductByID, baru ListProducts.
Service menerima interface Repository, bukan *pgxpool.Pool langsung. Inilah yang membuat service mudah dites dengan repository palsu nanti di Roadmap 6.
Panggil repository dari cmd/api/main.go sementara, cetak hasilnya, dan amati SQL yang jalan. Lebih mudah men-debug query dari command kecil daripada lewat handler penuh.
cmd/api/main.gopackage main import ( "context" "log" "os" "github.com/kamu/skincare-backend/internal/database" "github.com/kamu/skincare-backend/internal/product" ) func main() { ctx := context.Background() pool, err := database.NewPostgresPool(ctx, os.Getenv("DATABASE_URL")) if err != nil { log.Fatalf("connect database: %v", err) } defer pool.Close() repo := product.NewPostgresRepository(pool) // Smoke test 1: detail produk by ID. p, err := repo.GetProductByID(ctx, 1) if err != nil { log.Fatalf("get product: %v", err) } if p == nil { log.Println("produk id=1 tidak ditemukan") } else { log.Printf("produk: %s (%s), status=%s", p.Name, p.Slug, p.Status) } // Smoke test 2: katalog dengan filter kategori dan pagination. items, err := repo.ListProducts(ctx, product.ListProductsFilter{ CategorySlug: "serum", Limit: 10, Offset: 0, }) if err != nil { log.Fatalf("list products: %v", err) } log.Printf("ditemukan %d produk di kategori serum", len(items)) for _, it := range items { log.Printf("- %s | %s | mulai dari Rp%d", it.Name, it.BrandName, it.StartingPriceRupiah) } }
Terminalexport DATABASE_URL="postgres://postgres:postgres@localhost:5432/skincare?sslmode=disable" go run ./cmd/api go test ./...
Sebelum membungkus repository dengan handler dan service penuh, panggil ia dari command kecil atau test integrasi. Error SQL (kolom salah ketik, tipe tidak cocok, urutan Scan geser) jauh lebih mudah dibaca tanpa lapisan HTTP di atasnya.
Smoke test ini mengasumsikan tabel sudah terisi. Kalau database masih kosong, jalankan migration dari chapter migration lalu seed beberapa brand, produk, dan varian. Query yang benar pada tabel kosong akan mengembalikan slice kosong (len == 0), bukan error, jadi pastikan datanya ada sebelum mengira ada bug.
Jebakan Umum
Bug pgx awal jarang soal SQL rumit
Sebagian besar bug awal saat memakai pgx bukan di SQL yang kompleks, melainkan di lifecycle Rows, urutan mapping, dan penanganan NULL. Kenali polanya, dan kamu menghemat berjam-jam.
Lupa rows.Close
Koneksi tertahan dan pool terasa habis saat traffic naik. Pasang defer rows.Close() segera setelah error Query aman.
Lupa rows.Err
Query bisa gagal di tengah iterasi, tetapi Next() cuma mengembalikan false. Tanpa rows.Err(), error itu hilang diam-diam.
Urutan Scan geser
slug bisa mendarat di name, atau tipe tidak cocok dan query gagal. Sebut kolom eksplisit, jangan SELECT *.
NULL ke non-pointer
Scan kolom nullable ke string atau int64 biasa langsung error. Pakai pointer (*string) atau pgtype hanya untuk kolom yang bisa NULL.
ErrNoRows dianggap 500
Tidak ditemukan harus jadi 404 di layer HTTP, bukan internal server error. Pisahkan pgx.ErrNoRows dari error database sungguhan.
Placeholder ?
PostgreSQL memakai $1, $2, bukan ?. Memakai ? langsung gagal. Ini beda dari MySQL/PDO yang mungkin kamu hafal dari PHP.
N+1 buatan sendiri
Loop yang menjalankan satu query per item meledakkan jumlah round-trip. Pakai satu query WHERE order_id = ANY($1) lalu kelompokkan di Go.
Uang jadi float64
Memetakan price_rupiah ke float64 membuka galat pembulatan. Selalu int64 untuk rupiah, tanpa kecuali.
- Mengandalkan auto-hydration ORM untuk relasi.
- Not-found berupa exception atau
nullotomatis. - Filter sering ditulis bebas, dipercayakan ke query builder.
- Menulis kolom
SELECTdan urutanScan(atau tagdb) secara eksplisit. - Memilah
pgx.ErrNoRows, error query, dan error scan dengan sengaja. - Nilai user selalu lewat placeholder
$Ndanargs, tidak pernah digabung ke SQL.
Kalau client memutus koneksi, ctx dibatalkan dan query mengembalikan error yang membungkus context.Canceled. Itu bukan kegagalan database dan tidak perlu jadi 500 yang menakutkan di log. Saat menyusun logging, pertimbangkan memeriksa errors.Is(err, context.Canceled) agar pembatalan normal tidak terlihat seperti insiden.
Ringkasan & Poin Penting
Sekarang backend skincare bisa membaca data dari PostgreSQL dengan pola repository yang eksplisit, aman, dan testable, dari detail produk sampai order bersarang.
Yang Wajib Menempel
pool.QueryRow(ctx, sql, args...)untuk satu baris; error baru keluar saatScan, dan not-found ditandaipgx.ErrNoRows.pool.Query(ctx, sql, args...)untuk banyak baris; wajibdefer rows.Close(), looprows.Next()+rows.Scan(), lalurows.Err()setelah loop.errors.Is(err, pgx.ErrNoRows)adalah cara benar mendeteksi data tidak ditemukan, biasanya diterjemahkan jadinil, nildi repository atau error domain di service.- Mapping manual bersifat positional (urutan
Scan= urutan kolom), sedangkanpgx.CollectRowsdenganpgx.RowToStructByName[T]memetakan by-name lewat tagdbdan memangkas boilerplate. - Kolom nullable butuh pointer (
*string,*int64) ataupgtype;text[]jadi[]string; uang selalubigintkeint64, tidak pernahfloat64. - Filter dinamis aman selama nilai user masuk lewat placeholder
$Ndanargs, bukan digabung ke string SQL. - Order bersarang dimuat dengan dua query (header + semua item), dan riwayat banyak order memakai
WHERE order_id = ANY($1)agar tidak terjebak N+1.
Dengan modul ini, repository product punya GetProductByID untuk halaman detail dan ListProducts untuk katalog, sementara repository order punya GetOrderByID yang mengembalikan order beserta seluruh item-nya. Snapshot di order_items (product_name, sku, unit_price_rupiah) memastikan invoice lama tetap stabil walau katalog berubah.
Di chapter selanjutnya, pola yang sama diperluas ke operasi tulis: INSERT ... RETURNING id, UPDATE, dan DELETE lewat pool.Exec dan CommandTag.RowsAffected(). Setelah itu kita masuk ke transaksi checkout, di mana validasi cart, reserve stok, dan insert order dibungkus satu pgx.Tx yang Commit atau Rollback sebagai satu kesatuan.
Progress disimpan lokal di browser ini.