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

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

// ❌ 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.

// ❌ 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

// ✅ 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.

// ❌ 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:

  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
// ❌ 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 (_ = 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
// ✅ 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 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):
// ❌ 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 (atau goimports) sebelum commit — wajib, bukan opsional. Kebanyakan editor (VS Code + ekstensi Go) sudah auto-format on save
  • Disarankan pasang golangci-lint untuk 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 — 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:

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 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.