18 KiB
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 —
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 diinternal/pkg/. Kalau spesifik ke 1 domain, dia bukan "helper" — dia bagian dariservice.gomodule 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.gitkeepbiar foldernya tetap ada di git) - Kalau nanti butuh serve folder ini lewat HTTP, daftarkan lewat
r.Static("/public", "./public")diinternal/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
// ❌ 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(bukanuserListatauuserArr) - 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,reqOK kalau konvensi umum (req/respuntuk HTTP dipahami luas), tapitmp,data,val,objgenerik itu hindari — ganti dengan nama yang jelasin isinya apa
2.2 Function & Method
Pola: KataKerja + Objek, jelasin APA yang dilakukan dan KE APA.
// ❌ 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
boolnamanya 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
// ✅ 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.gosudah >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, bukanusers - Hindari nama generik:
util,common,helper,basesebagai 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.
// ❌ 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
}
// ✅ 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:
- Kumpulkan semua data yang mau diproses ke slice dulu (di memory, bukan hit DB)
- Baru eksekusi 1 query (atau beberapa batch kalau datanya sangat besar) buat semua data itu sekaligus
- Kalau butuh data referensi dari tabel lain buat banyak baris (misal ambil detail
product buat 50 order item), jangan
SELECT1 per item di loop — kumpulkan semua ID-nya dulu,SELECT ... WHERE id IN (...)sekali, baru mapping di memory
// ❌ 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.
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 (
_ = errcuma boleh kalau memang sengaja & ada alasan jelas) - Error domain (business logic) didefinisikan sebagai package-level
varpakaierrors.New(...), dicek pakaierrors.Is()— sudah dipakai konsisten diservice.gotiap 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()(lihatinternal/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 headerAuthorization). - 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()diinternal/pkg/response— jangan panggilc.JSON()langsung di handler, supaya format response konsisten di semua endpoint - Field JSON pakai
snake_case:total_data,created_at— bukantotalData/camelCase - Untuk field opsional (misal
Meta,Error), pastikan pakaijson:"...,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 banyakifdi handler - Nama field DTO pakai tag
jsonyang jelas, dan pesan error otomatis mengikuti nama itu (lihatinternal/pkg/validator) — jangan sampai nama field DTO ambigu (Val,Data,Input) - Tag
bindingselalu eksplisit soal wajib/opsional: pakairequireduntuk field wajib,omitemptyuntuk field opsional — jangan biarkan ambigu
// ✅ 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>: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 polarepository_mock_test.goyang 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):
// ❌ 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:
// ❌
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.
// ❌ 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(ataugoimports) sebelum commit — wajib, bukan opsional. Kebanyakan editor (VS Code + ekstensi Go) sudah auto-format on save - Disarankan pasang
golangci-lintuntuk nangkep isu umum (unused variable, ineffective assignment, dst) sebelum push:golangci-lint run ./... - Import selalu dikelompokkan: stdlib dulu, baris kosong, lalu internal (
cardverse/...), baris kosong, lalu third-party —goimportsotomatis ngerjain ini
11. Checklist Sebelum Pull Request
gofmtsudah 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:
git commit -m "[commit-type]([feature]-[code]) : [message in english]"
Contoh:
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 pushharus selalu meminta izin terlebih dahulu kepada pengguna / pemilik repositori sebelum dieksekusi. - Dilarang Direct Push Tanpa Konfirmasi: Jangan pernah melakukan
git pushsecara otomatis atau tanpa konfirmasi eksplisit.