Files
cardverse-be/CONVENTION.md
2026-07-30 09:51:25 +07:00

479 lines
18 KiB
Markdown

# CONVENTION.md — Cardverse API
Dokumen ini adalah panduan resmi penulisan kode di project ini. Tujuannya supaya
semua kontributor (termasuk diri sendiri 6 bulan dari sekarang) nulis kode dengan
gaya yang konsisten, gampang dibaca, dan gampang di-maintain.
**Prinsip utama:** kode dibaca jauh lebih sering daripada ditulis. Optimasi untuk
yang baca, bukan cuma untuk yang nulis.
---
## 1. Struktur Folder
Project ini pakai konsep **per-module** (feature-based / modular monolith).
Struktur lengkap & alasan tiap folder ada di [`README.md`](./README.md#struktur-folder) —
dokumen ini tidak mengulang, cukup aturan tambahannya:
```
cardverse/
├── cmd/ # entry point (main.go per binary: api, seed, dst)
├── config/ # load environment variable
├── internal/
│ ├── database/ # koneksi DB, migration, seeder
│ ├── middleware/ # auth, cors, logger, rate limiter
│ ├── modules/ # 1 folder = 1 domain/fitur (model, dto, repository, service, handler, routes)
│ ├── router/ # gabungkan semua route module
│ └── pkg/ # helper generik lintas module (response, logger, utils, validator)
├── public/ # data assets statis (lihat aturan di bawah)
│ ├── images/
│ └── uploads/
└── tests/ # HANYA untuk integration/e2e test, BUKAN unit test (lihat bagian 7)
```
### Aturan wajib
- **Module baru = folder baru** di `internal/modules/`. Jangan taruh logic satu fitur
nyebar di banyak tempat di luar folder module-nya sendiri.
- **Jangan bikin folder generik** kayak `utils/`, `helpers/`, `common/` di level module.
Kalau kode itu generik lintas module, taruh di `internal/pkg/`. Kalau spesifik ke 1
domain, dia bukan "helper" — dia bagian dari `service.go` module itu.
- **`public/`** khusus untuk data assets statis yang bisa diakses langsung (gambar
hasil upload, file yang di-generate, dsb) — **bukan** untuk kode maupun file konfigurasi.
- `public/images/` — gambar statis (logo, avatar default, dsb)
- `public/uploads/` — file hasil upload user (foto profil, dokumen, dsb)
- Isi folder ini **jangan pernah di-commit** kalau berupa file hasil upload runtime —
pastikan sudah ada di `.gitignore` (cukup commit `.gitkeep` biar foldernya tetap ada di git)
- Kalau nanti butuh serve folder ini lewat HTTP, daftarkan lewat `r.Static("/public", "./public")`
di `internal/router/router.go`, jangan bikin route manual per file
---
## 2. Penamaan (Naming)
**Aturan paling penting di seluruh dokumen ini: nama harus mendeskripsikan APA
isinya / APA yang dilakukan, bukan singkatan yang cuma dipahami penulisnya sendiri dan pakai bahasa inggris penamaannya**
### 2.1 Variable
```go
// ❌ HINDARI — nama tidak jelas, harus baca konteks buat ngerti
d := time.Now().Sub(start)
u, _ := repo.FindByID(id)
n := len(users)
// ✅ BENAR — jelas dari nama variabelnya sendiri
duration := time.Now().Sub(start)
existingUser, _ := repo.FindByID(id)
totalUsers := len(users)
```
- Variable **boolean** harus dimulai kata tanya: `isActive`, `hasPermission`, `canDelete`, `shouldRetry`
- Variable **jamak/slice** pakai bentuk jamak: `users` (bukan `userList` atau `userArr`)
- Variable **short-lived** di scope kecil (misal index loop `for i := range x`) boleh singkat (`i`, `err`), tapi begitu scope-nya lebih dari beberapa baris, kasih nama jelas
- **Jangan** pakai singkatan yang ambigu: `usr`, `pwd`, `req` OK kalau konvensi umum (`req`/`resp` untuk HTTP dipahami luas), tapi `tmp`, `data`, `val`, `obj` generik itu **hindari** — ganti dengan nama yang jelasin isinya apa
### 2.2 Function & Method
Pola: **KataKerja + Objek**, jelasin APA yang dilakukan dan KE APA.
```go
// ❌ HINDARI
func Process(u User) error
func Handle(c *gin.Context)
func Check(email string) bool
// ✅ BENAR
func HashPassword(plain string) (string, error)
func ValidateEmailFormat(email string) bool
func (h *Handler) CreateUser(c *gin.Context)
func (s *service) DeleteUserByID(id uint) error
```
- Function yang balikin `bool` namanya harus mulai kata tanya: `IsValid()`, `HasAccess()`
- Function private/internal (huruf kecil di awal) boleh lebih pendek karena scope-nya
udah jelas dari package-nya, tapi tetap harus jelas apa fungsinya
- Satu function idealnya **cuma ngerjain 1 hal** (Single Responsibility). Kalau nama
function-nya butuh kata "And" (`CreateUserAndSendEmail`), itu tanda harus dipecah
jadi 2 function
### 2.3 Constant & Package-level Variable
```go
// ✅ constant pakai PascalCase kalau exported, camelCase kalau private
const MaxLoginAttempts = 5
const defaultPageSize = 10
// ✅ untuk grup constant terkait, pakai type + iota biar type-safe
type OrderStatus string
const (
OrderStatusPending OrderStatus = "pending"
OrderStatusPaid OrderStatus = "paid"
OrderStatusCancelled OrderStatus = "cancelled"
)
```
### 2.4 File
- Nama file **snake_case**, deskriptif: `rate_limiter.go`, `response_error_test.go`
- 1 file = 1 tanggung jawab. Kalau `service.go` sudah >300 baris dan ngerjain banyak
hal berbeda, pecah jadi beberapa file (`service.go`, `service_validation.go`, dst) —
tetap 1 package, cuma dipisah fisik biar gampang di-navigate
### 2.5 Package
- Nama package dan Folder **huruf kecil semua**, singular (bukan jamak): `user`, bukan `users`
- Hindari nama generik: `util`, `common`, `helper`, `base` sebagai nama package —
lihat aturan folder di bagian 1
---
## 3. Database & Query
### 3.1 JANGAN query di dalam loop (hindari N+1 problem)
Ini aturan yang paling sering dilanggar dan paling mahal dampaknya ke performa.
**Kumpulkan dulu data yang mau diproses, baru eksekusi 1 query bulk** — jangan
query satu-satu di dalam `for`.
```go
// ❌ SANGAT DIHINDARI — 1 query per iterasi, kalau ada 1000 data = 1000x round-trip ke DB
func (r *repository) CreateMany(users []User) error {
for _, u := range users {
if err := r.db.Create(&u).Error; err != nil {
return err
}
}
return nil
}
// ❌ SANGAT DIHINDARI — sama, tapi buat UPDATE
func (s *service) MarkAllAsRead(notifIDs []uint) error {
for _, id := range notifIDs {
s.db.Model(&Notification{}).Where("id = ?", id).Update("is_read", true)
}
return nil
}
```
```go
// ✅ BENAR — kumpulkan dulu ke slice, baru 1x bulk insert
func (r *repository) CreateMany(users []User) error {
if len(users) == 0 {
return nil
}
// GORM otomatis generate 1 statement INSERT dengan banyak VALUES sekaligus
return r.db.Create(&users).Error
}
// ✅ BENAR — kalau datanya sangat banyak (ribuan), pecah per batch biar
// nggak kena limit jumlah parameter di 1 query, TAPI tetap bukan query per-item
func (r *repository) CreateManyBatched(users []User) error {
if len(users) == 0 {
return nil
}
const batchSize = 100
return r.db.CreateInBatches(users, batchSize).Error
}
// ✅ BENAR — update banyak baris sekaligus pakai 1 query WHERE ... IN (...)
func (s *service) MarkAllAsRead(notifIDs []uint) error {
if len(notifIDs) == 0 {
return nil
}
return s.db.Model(&Notification{}).
Where("id IN ?", notifIDs).
Update("is_read", true).Error
}
```
**Pola umumnya:**
1. Kumpulkan semua data yang mau diproses ke **slice** dulu (di memory, bukan hit DB)
2. Baru eksekusi **1 query** (atau beberapa batch kalau datanya sangat besar) buat semua data itu sekaligus
3. Kalau butuh data referensi dari tabel lain buat banyak baris (misal ambil detail
product buat 50 order item), jangan `SELECT` 1 per item di loop — kumpulkan
semua ID-nya dulu, `SELECT ... WHERE id IN (...)` sekali, baru mapping di memory
```go
// ❌ N+1 query problem: 1 query ambil orders + N query ambil product tiap order item
for _, item := range orderItems {
var product Product
db.First(&product, item.ProductID)
// ...
}
// ✅ kumpulkan semua productID dulu, 1x query ambil semuanya, baru mapping di memory
productIDs := make([]uint, 0, len(orderItems))
for _, item := range orderItems {
productIDs = append(productIDs, item.ProductID)
}
var products []Product
db.Where("id IN ?", productIDs).Find(&products)
productMap := make(map[uint]Product, len(products))
for _, p := range products {
productMap[p.ID] = p
}
// sekarang tinggal productMap[item.ProductID] buat akses tiap item, tanpa query tambahan
```
### 3.2 Repository tetap satu-satunya lapisan yang bicara ke database
Business logic (di `service.go`) tidak boleh langsung import `gorm.io/gorm` atau
nulis query — semua akses data lewat `Repository` interface, sesuai pola yang sudah
ada. Ini memudahkan mocking di unit test dan menjaga tanggung jawab tetap terpisah.
### 3.3 Transaction untuk operasi yang harus atomik
Kalau 1 aksi bisnis butuh beberapa perubahan tabel yang harus **semua berhasil atau
semua gagal** (misal: kurangi stok + buat order), bungkus dengan `db.Transaction()`
di level repository/service, jangan biarkan tiap query jalan sendiri-sendiri.
```go
func (r *repository) CreateOrderWithStockDeduction(order *Order, productID uint, qty int) error {
return r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(order).Error; err != nil {
return err // otomatis rollback semua perubahan di transaction ini
}
if err := tx.Model(&Product{}).Where("id = ?", productID).
Update("stock", gorm.Expr("stock - ?", qty)).Error; err != nil {
return err
}
return nil // otomatis commit kalau sampai sini tanpa error
})
}
```
### 3.4 Hindari `SELECT *` (Spesifikkan Kolom yang Dibutuhkan)
- **Hindari penggunaan `SELECT *`** dalam query database.
- Tuliskan kolom yang ingin diambil **satu per satu secara spesifik** (misal `db.Select("id", "name", "email")`) sesuai kebutuhan data yang akan digunakan.
- Memilih kolom secara spesifik membuat eksekusi query jauh lebih cepat dan efisien, serta menghemat memori dan I/O jaringan database terutama ketika mengambil data dalam jumlah banyak.
---
## 4. Error Handling & Logging
### 4.1 Error Handling
- Selalu **cek error langsung setelah pemanggilan function** yang mengembalikannya —
jangan tunda atau abaikan (`_ = err` cuma boleh kalau memang sengaja & ada alasan jelas)
- Error domain (business logic) didefinisikan sebagai **package-level `var`** pakai
`errors.New(...)`, dicek pakai `errors.Is()` — sudah dipakai konsisten di
`service.go` tiap module (`ErrUserNotFound`, `ErrEmailTaken`, dst). Ikuti pola ini
untuk error baru
- **Jangan** expose detail error asli (pesan driver database, stack trace) ke response
API — sudah ditangani otomatis oleh `response.Error()` (lihat `internal/pkg/response`),
tinggal pakai, jangan bikin cara custom kirim error langsung ke client
- Pesan error (yang dicatat ke log, bukan yang dikirim ke client) harus dalam Bahasa
Indonesia yang jelas, bukan singkatan teknis semata: `"email sudah terdaftar"`,
bukan `"dup key"`
### 4.2 Logging & Request Debugging
- **Log Request untuk Debugging**: Selalu catat log untuk request HTTP yang masuk (seperti method, URL, query params, dan payload/body request) untuk memudahkan pencarian masalah (debugging).
- **Sensor Data Kredensial & Sensitif**: **Dilarang keras** menampilkan data kredensial atau informasi sensitif di dalam log (seperti `password`, `token`, `secret`, `credit_card`, `pin`, `otp`, atau header `Authorization`).
- Pastikan field kredensial di-masking (misal menjadi `"***"`) atau dibersihkan sebelum payload request dicatat ke log.
---
## 5. Response API
- Semua response lewat `response.Success()` / `response.SuccessWithPagination()` /
`response.Error()` di `internal/pkg/response` — jangan panggil `c.JSON()` langsung
di handler, supaya format response konsisten di semua endpoint
- Field JSON pakai `snake_case`: `total_data`, `created_at` — bukan `totalData`/`camelCase`
- Untuk field opsional (misal `Meta`, `Error`), pastikan pakai `json:"...,omitempty"`
supaya tidak muncul di response kalau kosong
---
## 6. Validasi Input (DTO)
- Semua request body/query **wajib** lewat DTO struct dengan tag `binding`
jangan validasi manual pakai banyak `if` di handler
- Nama field DTO pakai tag `json` yang jelas, dan pesan error otomatis mengikuti
nama itu (lihat `internal/pkg/validator`) — jangan sampai nama field DTO ambigu
(`Val`, `Data`, `Input`)
- Tag `binding` selalu eksplisit soal wajib/opsional: pakai `required` untuk field
wajib, `omitempty` untuk field opsional — jangan biarkan ambigu
```go
// ✅ jelas mana wajib, mana opsional, dan aturan validasinya apa
type CreateUserRequest struct {
Name string `json:"name" binding:"required,min=2,max=100"`
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=6"`
}
```
---
## 7. Testing
- Unit test **wajib** ada di folder yang sama dengan kode yang di-test (keterbatasan
tooling Go, lihat `README.md`) — jangan coba pindahkan
- Nama function test deskriptif, format `Test<Subjek>_<Skenario>`:
```go
func TestService_Create_EmailSudahDipakai(t *testing.T)
func TestRateLimiter_MenolakSetelahBurstHabis(t *testing.T)
```
- Business logic (`service.go`) di-test pakai **mock repository**, bukan koneksi
database sungguhan — ikuti pola `repository_mock_test.go` yang sudah ada
- Folder `tests/` di root (kalau nanti dibuat) khusus untuk **integration/e2e test**
yang benar-benar hit endpoint HTTP + database sungguhan — ini kategori berbeda
dari unit test, dan memang boleh terpisah dari kode karena cuma manggil API
dari luar (black-box), bukan akses internal package
---
## 8. Function & Kompleksitas
- Idealnya 1 function **muat dalam 1 layar** tanpa scroll (~40-50 baris). Kalau lebih,
kemungkinan besar dia ngerjain lebih dari 1 tanggung jawab — pecah jadi beberapa function
- Hindari **nested if lebih dari 2-3 level**. Pakai **early return** (guard clause):
```go
// ❌ nested dalam-dalam, susah dibaca
func (s *service) Update(id uint, req UpdateUserRequest) (*User, error) {
u, err := s.repo.FindByID(id)
if err == nil {
if req.Name != "" {
u.Name = req.Name
if updateErr := s.repo.Update(u); updateErr == nil {
return u, nil
} else {
return nil, updateErr
}
}
return u, nil
} else {
return nil, ErrUserNotFound
}
}
// ✅ early return, alur baca dari atas ke bawah, tanpa nested
func (s *service) Update(id uint, req UpdateUserRequest) (*User, error) {
u, err := s.repo.FindByID(id)
if err != nil {
return nil, ErrUserNotFound
}
if req.Name != "" {
u.Name = req.Name
}
if err := s.repo.Update(u); err != nil {
return nil, err
}
return u, nil
}
```
- **Magic number/string dihindari** — pakai constant yang dikasih nama:
```go
// ❌
if len(password) < 6 { ... }
// ✅
const minPasswordLength = 6
if len(password) < minPasswordLength { ... }
```
---
## 9. Comment & Dokumentasi
- **Jangan beri komentar di setiap baris atau fungsi internal** jika nama fungsi dan variabelnya sudah mendeskripsikan secara jelas (_self-descriptive_).
- Cukup berikan **overview fungsi secara keseluruhan** (doc comment di atas fungsi) untuk menjelaskan gambaran umum apa yang dikerjakan fungsi tersebut dan jangan berlebihan.
- Comment di dalam baris kode (_inline comment_) hanya dipakai untuk menjelaskan **KENAPA** (konteks bisnis/alasan keputusan teknis yang tidak terlihat langsung dari kode), bukan **APA** yang sedang dilakukan baris tersebut.
```go
// ❌ HINDARI — komentar di setiap baris yang kodenya sendiri sudah jelas
func (s *userService) CreateUser(req CreateUserRequest) error {
// hash password user
hashedPassword, _ := HashPassword(req.Password)
// simpan user ke database
return s.repo.Create(user)
}
// ✅ BENAR — cukup overview ringkas di atas fungsi, tanpa komentar per baris di dalam body
// CreateUser menangani proses registrasi dan penyimpanan data pengguna baru.
func (s *userService) CreateUser(req CreateUserRequest) error {
hashedPassword, err := HashPassword(req.Password)
if err != nil {
return err
}
return s.repo.Create(user)
}
```
- Semua **function/type exported** (huruf besar di awal) wajib punya doc comment ringkas (overview) yang diawali nama function/type itu sendiri (konvensi standar Go).
- Doc comment cukup Bahasa Indonesia, tidak perlu Bahasa Inggris kecuali project ini nantinya open source untuk audiens internasional.
---
## 10. Formatting & Linting
- Jalankan `gofmt` (atau `goimports`) sebelum commit — **wajib**, bukan opsional.
Kebanyakan editor (VS Code + ekstensi Go) sudah auto-format on save
- Disarankan pasang [`golangci-lint`](https://golangci-lint.run/) untuk nangkep
isu umum (unused variable, ineffective assignment, dst) sebelum push:
```bash
golangci-lint run ./...
```
- Import selalu dikelompokkan: stdlib dulu, baris kosong, lalu internal (`cardverse/...`),
baris kosong, lalu third-party — `goimports` otomatis ngerjain ini
---
## 11. Checklist Sebelum Pull Request
- [ ] `gofmt` sudah dijalankan, tidak ada perbedaan format
- [ ] Nama variable/function sudah deskriptif, tidak ada singkatan ambigu
- [ ] Tidak ada query database di dalam loop — sudah dikumpulkan & di-bulk
- [ ] Semua error dicek, tidak ada yang diabaikan diam-diam
- [ ] Tidak ada data sensitif (password, token) yang ikut ter-log atau ter-expose ke response
- [ ] Unit test ditambahkan/diupdate untuk logic baru, ditaruh di folder yang sama
- [ ] `go test ./...` lolos semua sebelum push
- [ ] Struktur folder module diikuti (model, dto, repository, service, handler, routes)
- [ ] Format pesan commit mengikuti konvensi `git commit -m "[commit-type]([feature]-[code]) : [message in english]"`
---
## 12. Konvensi Git (Commit & Push)
### 12.1 Commit Convention
Struktur Commit:
```bash
git commit -m "[commit-type]([feature]-[code]) : [message in english]"
```
**Contoh:**
```bash
git commit -m "feat(login-001) : add new form login"
```
Daftar `commit-type`:
- **`feat`**: Digunakan saat menambahkan fitur baru.
- **`fix`**: Digunakan saat memperbaiki bug.
- **`refactor`**: Digunakan saat mengatur ulang atau merestrukturisasi kode yang ada.
- **`docs`**: Digunakan saat membuat perubahan terkait dokumentasi atau komentar.
- **`style`**: Digunakan untuk perubahan dalam format kode, spasi, tanda baca, dll.
- **`test`**: Digunakan saat menambahkan atau memperbarui kode pengujian atau skenario pengujian.
- **`chore`**: Digunakan untuk perubahan yang terkait dengan alat bantu, berkas konfigurasi, atau organisasi proyek.
### 12.2 Push Convention & Aturan Izin
- **Wajib Izin Sebelum Push**: Aksi `git push` **harus selalu meminta izin terlebih dahulu** kepada pengguna / pemilik repositori sebelum dieksekusi.
- **Dilarang Direct Push Tanpa Konfirmasi**: Jangan pernah melakukan `git push` secara otomatis atau tanpa konfirmasi eksplisit.