Go Financial Core System - Handling Race Condition

Di dunia *Fintech*, kesalahan 1 Rupiah pun bisa berakibat fatal. Masalah paling umum adalah Race Condition (dua transaksi masuk bersamaan dan saldo jadi kacau) serta Deadlock (transaksi macet karena saling tunggu).
Hari ini kita akan bedah cara membuat sistem transfer yang *bulletproof* menggunakan Go dan Gorm.
๐ฏ Goal
- โ Bisa membuat wallet
โ Bisa transfer saldo
โ Aman dari :
- race condition
- double spending
- deadlock
๐งฑ 1. Flow Transfer (WAJIB PAHAM DULU)
Alur transfer:
1. Validasi input
2. Lock wallet(urut kecil โ besar)
3. Cek saldo cukup
4. Insert ledger(debit & credit)
5. Simpan transaction
6. Commit DB๐ Semua dalam 1 DB Transaction
๐งพ 2. Repository Layer (DB Logic)
internal/repository/wallet_repo.go
package repository
import (
"github.com/fardannozami/fincore/internal/domain"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type WalletRepository struct {
db *gorm.DB
}
func NewWalletRepository(db *gorm.DB) *WalletRepository {
return &WalletRepository{db: db}
}
func (r *WalletRepository) FindByIDForUpdate(tx *gorm.DB, id string) (*domain.Wallet, error) {
var wallet domain.Wallet
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
First(&wallet, "id = ?", id).Error
if err != nil {
return nil, err
}
return &wallet, nil
}
func (r *WalletRepository) Create(tx *gorm.DB, wallet *domain.Wallet) error {
return tx.Create(wallet).Error
}
func (r *WalletRepository) UpdateBalanceAtomic(
tx *gorm.DB,
id string,
amount int64,
) (*domain.Wallet, error) {
var wallet domain.Wallet
result := tx.Raw(`
UPDATE wallets
SET balance = balance + ?
WHERE id = ? AND(balance + ? >= 0)
RETURNING id, balance
`, amount, id, amount).Scan(&wallet)
// ๐ด error dari DB
if result.Error != nil {
return nil, result.Error
}
// ๐ด tidak ada row ter-update = saldo tidak cukup / wallet tidak ada
if result.RowsAffected == 0 {
return nil, ErrInsufficientBalance
}
return &wallet, nil
}buat unit testnya pertama kita buat ulits untuk koneksi db testnya yang nanti akan dipanggil di seluruh unit test
internal/testutil/db.go
package testutil
import (
"fmt"
"testing"
infraDB "github.com/fardannozami/fincore/internal/infrastructure/db"
"github.com/stretchr/testify/assert"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
// SetupDB creates a test database and migrates the provided models.
// It uses PostgreSQL to support advanced features like atomic updates with RETURNING.
func SetupDB(t *testing.T, models ...interface{}) *gorm.DB {
user := "postgres"
password := ""
host := "localhost"
port := "5432"
dbname := "fincore_test"
sslmode := "disable"
// Ensure the test database exists
infraDB.EnsureDBExists(host, port, user, password, dbname, sslmode)
dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=%s", host, user, password, dbname, port, sslmode)
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatalf("failed to connect to test database: %v", err)
}
// Migrate provided models
if len(models) > 0 {
err = db.AutoMigrate(models...)
assert.NoError(t, err)
}
return db
}internal/repository/wallet_repo_test.go
package repository
import (
"testing"
"github.com/fardannozami/fincore/internal/domain"
"github.com/fardannozami/fincore/internal/testutil"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
)
func TestWalletRepository_Create(t *testing.T) {
db := testutil.SetupDB(t, &domain.Wallet{})
repo := NewWalletRepository(db)
tx := db.Begin()
id := uuid.NewString()
wallet := &domain.Wallet{
ID: id,
UserID: "user-1",
Balance: 1000,
}
err := repo.Create(tx, wallet)
assert.NoError(t, err)
tx.Commit()
// verify
var result domain.Wallet
err = db.First(&result, "id = ?", id).Error
assert.NoError(t, err)
assert.Equal(t, int64(1000), result.Balance)
}
func TestWalletRepository_FindByIDForUpdate(t *testing.T) {
db := testutil.SetupDB(t, &domain.Wallet{})
repo := NewWalletRepository(db)
id := uuid.NewString()
// seed data
db.Create(&domain.Wallet{
ID: id,
UserID: "user-1",
Balance: 500,
})
tx := db.Begin()
wallet, err := repo.FindByIDForUpdate(tx, id)
assert.NoError(t, err)
assert.NotNil(t, wallet)
assert.Equal(t, int64(500), wallet.Balance)
tx.Commit()
}
func TestWalletRepository_UpdateBalanceAtomic(t *testing.T) {
db := testutil.SetupDB(t, &domain.Wallet{})
repo := NewWalletRepository(db)
walletID := uuid.NewString()
db.Create(&domain.Wallet{
ID: walletID,
UserID: "user-1",
Balance: 1000,
})
t.Run("Success Increment", func(t *testing.T) {
tx := db.Begin()
w, err := repo.UpdateBalanceAtomic(tx, walletID, 500)
assert.NoError(t, err)
if assert.NotNil(t, w) {
assert.Equal(t, int64(1500), w.Balance)
}
tx.Commit()
})
t.Run("Success Decrement", func(t *testing.T) {
tx := db.Begin()
w, err := repo.UpdateBalanceAtomic(tx, walletID, -200)
assert.NoError(t, err)
if assert.NotNil(t, w) {
assert.Equal(t, int64(1300), w.Balance)
}
tx.Commit()
})
t.Run("Fail Decrement Insufficient Balance", func(t *testing.T) {
tx := db.Begin()
w, err := repo.UpdateBalanceAtomic(tx, walletID, -2000)
assert.Error(t, err)
assert.Nil(t, w)
assert.Equal(t, ErrInsufficientBalance, err)
tx.Commit()
})
}๐ฆ Install dependency test
go get github.com/stretchr/testifyinternal/repository/ledger_repo.go
package repository
import (
"github.com/fardannozami/fincore/internal/domain"
"gorm.io/gorm"
)
type LedgerRepository struct {
db *gorm.DB
}
func NewLedgerRepository(db *gorm.DB) *LedgerRepository {
return &LedgerRepository{db: db}
}
func (r *LedgerRepository) Create(tx *gorm.DB, ledger *domain.Ledger) error {
return tx.Create(ledger).Error
}buat unit testnya
internal/repository/ledger_repo_test.go
package repository
import (
"testing"
"github.com/fardannozami/fincore/internal/domain"
"github.com/fardannozami/fincore/internal/testutil"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
)
func TestLedgerRepository_Create(t *testing.T) {
db := testutil.SetupDB(t, &domain.Ledger{})
repo := NewLedgerRepository(db)
tx := db.Begin()
id := uuid.NewString()
walletID := uuid.NewString()
refID := uuid.NewString()
ledger := &domain.Ledger{
ID: id,
WalletID: walletID,
Amount: 1000,
Type: "CREDIT",
RefID: refID,
}
err := repo.Create(tx, ledger)
assert.NoError(t, err)
tx.Commit()
// verify ke DB
var result domain.Ledger
err = db.First(&result, "id = ?", id).Error
assert.NoError(t, err)
assert.Equal(t, int64(1000), result.Amount)
assert.Equal(t, "CREDIT", result.Type)
assert.Equal(t, walletID, result.WalletID)
}internal/repository/transaction_repo.go
package repository
import (
"github.com/fardannozami/fincore/internal/domain"
"gorm.io/gorm"
)
type TransactionRepository struct {
db *gorm.DB
}
func NewTransactionRepository(db *gorm.DB) *TransactionRepository {
return &TransactionRepository{db: db}
}
func (r *TransactionRepository) Create(tx *gorm.DB, trx *domain.Transaction) error {
return tx.Create(trx).Error
}buat unit testnya
internal/repository/transaction_repo_test.go
package repository
import (
"testing"
"github.com/fardannozami/fincore/internal/domain"
"github.com/fardannozami/fincore/internal/testutil"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
)
func TestTransactionRepository_Create(t *testing.T) {
db := testutil.SetupDB(t, &domain.Transaction{})
repo := NewTransactionRepository(db)
tx := db.Begin()
id := uuid.NewString()
fromID := uuid.NewString()
toID := uuid.NewString()
trx := &domain.Transaction{
ID: id,
FromID: fromID,
ToID: toID,
Amount: 1000,
Status: "SUCCESS",
}
err := repo.Create(tx, trx)
assert.NoError(t, err)
tx.Commit()
// verify ke DB
var result domain.Transaction
err = db.First(&result, "id = ?", id).Error
assert.NoError(t, err)
assert.Equal(t, id, result.ID)
assert.Equal(t, fromID, result.FromID)
assert.Equal(t, toID, result.ToID)
assert.Equal(t, int64(1000), result.Amount)
assert.Equal(t, "SUCCESS", result.Status)
}๐ง 3. Usecase (๐ฅ CORE LOGIC)
internal/usecase/transfer_usecase.go
package usecase
import (
"errors"
"sort"
"github.com/fardannozami/fincore/internal/domain"
"github.com/fardannozami/fincore/internal/repository"
"github.com/google/uuid"
"gorm.io/gorm"
)
type TransferUsecase struct {
db *gorm.DB
walletRepo *repository.WalletRepository
ledgerRepo *repository.LedgerRepository
transactionRepo *repository.TransactionRepository
}
func NewTransferUsecase(
db *gorm.DB,
w *repository.WalletRepository,
l *repository.LedgerRepository,
t *repository.TransactionRepository,
) *TransferUsecase {
return &TransferUsecase{db, w, l, t}
}
func (u *TransferUsecase) Transfer(fromID, toID string, amount int64, refID string) error {
if fromID == toID {
return errors.New("cannot transfer to same wallet")
}
if amount <= 0 {
return errors.New("invalid amount")
}
// ๐ ๏ธ Optimasi: Generasi UUID di luar transaksi untuk menghemat waktu lock
debitID := uuid.NewString()
creditID := uuid.NewString()
return u.db.Transaction(func(tx *gorm.DB) error {
// ๐ Anti-Deadlock: Selalu urutkan dari ID terkecil
ids := []string{fromID, toID}
sort.Strings(ids)
for _, id := range ids {
updateAmount := amount
if id == fromID {
updateAmount = -amount
}
// Atomic update: Cek saldo + Update saldo dalam 1 perintah SQL
wallet, err := u.walletRepo.UpdateBalanceAtomic(tx, id, updateAmount)
if err != nil {
return err
}
if wallet.ID == "" {
if id == fromID {
return errors.New("insufficient balance or wallet not found")
}
return errors.New("wallet not found")
}
}
// ๐งพ ledger debit & credit
if err := u.ledgerRepo.Create(tx, &domain.Ledger{
ID: debitID,
WalletID: fromID,
Amount: -amount,
Type: "DEBIT",
RefID: refID,
}); err != nil {
return err
}
if err := u.ledgerRepo.Create(tx, &domain.Ledger{
ID: creditID,
WalletID: toID,
Amount: amount,
Type: "CREDIT",
RefID: refID,
}); err != nil {
return err
}
// save transaction
return u.transactionRepo.Create(tx, &domain.Transaction{
ID: refID,
FromID: fromID,
ToID: toID,
Amount: amount,
Status: "SUCCESS",
})
})
}dan jangan lupa install uuid
go get github.com/google/uuidbuat unit testnya
internal/usecase/transfer_usecase_test.go
package usecase
import (
"testing"
"github.com/fardannozami/fincore/internal/domain"
"github.com/fardannozami/fincore/internal/repository"
"github.com/fardannozami/fincore/internal/testutil"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"gorm.io/gorm"
)
func setupTest(t *testing.T) (*TransferUsecase, *gorm.DB) {
// Use testutil to setup PostgreSQL test database
db := testutil.SetupDB(t,
&domain.Wallet{},
&domain.Ledger{},
&domain.Transaction{},
)
// repo
walletRepo := repository.NewWalletRepository(db)
ledgerRepo := repository.NewLedgerRepository(db)
transactionRepo := repository.NewTransactionRepository(db)
// usecase
uc := NewTransferUsecase(db, walletRepo, ledgerRepo, transactionRepo)
return uc, db
}
func TestTransfer_Success(t *testing.T) {
uc, db := setupTest(t)
// Use unique IDs to avoid conflicts in persistent test DB
idA := uuid.NewString()
idB := uuid.NewString()
trxID := uuid.NewString()
// seed wallet
db.Create(&domain.Wallet{ID: idA, UserID: "User-A", Balance: 1000})
db.Create(&domain.Wallet{ID: idB, UserID: "User-B", Balance: 500})
err := uc.Transfer(idA, idB, 300, trxID)
assert.NoError(t, err)
// cek saldo
var a, b domain.Wallet
db.First(&a, "id = ?", idA)
db.First(&b, "id = ?", idB)
assert.Equal(t, int64(700), a.Balance)
assert.Equal(t, int64(800), b.Balance)
// cek ledgers for these specific IDs
var ledgers []domain.Ledger
db.Find(&ledgers, "ref_id = ?", trxID)
assert.Len(t, ledgers, 2)
// cek transaction
var trx domain.Transaction
err = db.First(&trx, "id = ?", trxID).Error
assert.NoError(t, err)
assert.Equal(t, "SUCCESS", trx.Status)
}
func TestTransfer_InsufficientBalance(t *testing.T) {
uc, db := setupTest(t)
idA := uuid.NewString()
idB := uuid.NewString()
trxID := uuid.NewString()
db.Create(&domain.Wallet{ID: idA, UserID: "User-A", Balance: 100})
db.Create(&domain.Wallet{ID: idB, UserID: "User-B", Balance: 0})
err := uc.Transfer(idA, idB, 200, trxID)
assert.Error(t, err)
// saldo tidak berubah
var a, b domain.Wallet
db.First(&a, "id = ?", idA)
db.First(&b, "id = ?", idB)
assert.Equal(t, int64(100), a.Balance)
assert.Equal(t, int64(0), b.Balance)
}๐ 4. HTTP Handler
Pertama kita buat Data Transfer Object (DTO)nya untuk handling request dan responsenya.
internal/delivery/http/dto/wallet_dto.go
package dto
type CreateWalletResponse struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Balance int64 `json:"balance"`
}internal/delivery/http/dto/transfer_dto.go
package dto
type TransferRequest struct {
FromID string `json:"from_id"`
ToID string `json:"to_id"`
Amount int64 `json:"amount"`
}
type TransferResponse struct {
Message string `json:"message"`
}internal/delivery/http/handler/wallet_handler.go
package handler
import (
"encoding/json"
"net/http"
"github.com/fardannozami/fincore/internal/delivery/http/dto"
"github.com/fardannozami/fincore/internal/domain"
"github.com/fardannozami/fincore/internal/repository"
"github.com/google/uuid"
"gorm.io/gorm"
)
type WalletHandler struct {
db *gorm.DB
repo *repository.WalletRepository
}
func NewWalletHandler(db *gorm.DB, repo *repository.WalletRepository) *WalletHandler {
return &WalletHandler{db, repo}
}
func (h *WalletHandler) CreateWallet(w http.ResponseWriter, r *http.Request) {
wallet := domain.Wallet{
ID: uuid.NewString(),
UserID: "user-1", // nanti bisa dari auth
Balance: 0,
}
if err := h.repo.Create(h.db, &wallet); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
res := dto.CreateWalletResponse{
ID: wallet.ID,
UserID: wallet.UserID,
Balance: wallet.Balance,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(res)
}lalu buat unit testnya
internal/delivery/http/handler/wallet_handler_test.go
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"gorm.io/gorm"
"github.com/fardannozami/fincore/internal/domain"
"github.com/fardannozami/fincore/internal/repository"
"github.com/fardannozami/fincore/internal/testutil"
)
// setup DB test
func setupWalletHandlerTest(t *testing.T) (*WalletHandler, *gorm.DB) {
db := testutil.SetupDB(t, &domain.Wallet{})
repo := repository.NewWalletRepository(db)
handler := NewWalletHandler(db, repo)
return handler, db
}
func TestWalletHandler_CreateWallet(t *testing.T) {
handler, db := setupWalletHandlerTest(t)
// buat request HTTP
req := httptest.NewRequest(http.MethodPost, "/wallet", nil)
w := httptest.NewRecorder()
// call handler
handler.CreateWallet(w, req)
res := w.Result()
defer res.Body.Close()
// cek status code
assert.Equal(t, http.StatusOK, res.StatusCode)
// decode response
var response map[string]interface{}
err := json.NewDecoder(res.Body).Decode(&response)
assert.NoError(t, err)
// cek response field
assert.NotEmpty(t, response["id"])
assert.Equal(t, "user-1", response["user_id"])
assert.Equal(t, float64(0), response["balance"]) // JSON number = float64
// cek benar-benar masuk DB
var wallet domain.Wallet
err = db.First(&wallet, "id = ?", response["id"]).Error
assert.NoError(t, err)
assert.Equal(t, "user-1", wallet.UserID)
assert.Equal(t, int64(0), wallet.Balance)
}internal/delivery/http/handler/transfer_handler.go
package handler
import (
"encoding/json"
"net/http"
"github.com/fardannozami/fincore/internal/delivery/http/dto"
"github.com/google/uuid"
)
type TransferUsecase interface {
Transfer(fromID, toID string, amount int64, refID string) error
}
type TransferHandler struct {
usecase TransferUsecase
}
func NewTransferHandler(u TransferUsecase) *TransferHandler {
return &TransferHandler{u}
}
func (h *TransferHandler) Transfer(w http.ResponseWriter, r *http.Request) {
var req dto.TransferRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
if req.FromID == "" || req.ToID == "" || req.Amount <= 0 {
http.Error(w, "invalid input", http.StatusBadRequest)
return
}
err := h.usecase.Transfer(
req.FromID,
req.ToID,
req.Amount,
uuid.NewString(),
)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
json.NewEncoder(w).Encode(dto.TransferResponse{
Message: "success",
})
}lalu buat unit testnya
internal/delivery/http/handler/transfer_handler_test.go
package handler
import (
"bytes"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/fardannozami/fincore/internal/delivery/http/dto"
)
// ===== MOCK USECASE =====
type mockUsecase struct {
err error
}
func (m *mockUsecase) Transfer(fromID, toID string, amount int64, refID string) error {
return m.err
}
// ===== TEST SUCCESS =====
func TestTransferHandler_Success(t *testing.T) {
mock := &mockUsecase{err: nil}
handler := NewTransferHandler(mock)
reqBody := dto.TransferRequest{
FromID: "A",
ToID: "B",
Amount: 100,
}
body, _ := json.Marshal(reqBody)
req := httptest.NewRequest(http.MethodPost, "/transfer", bytes.NewBuffer(body))
w := httptest.NewRecorder()
handler.Transfer(w, req)
res := w.Result()
defer res.Body.Close()
assert.Equal(t, http.StatusOK, res.StatusCode)
var resp dto.TransferResponse
err := json.NewDecoder(res.Body).Decode(&resp)
assert.NoError(t, err)
assert.Equal(t, "success", resp.Message)
}
// ===== TEST INVALID JSON =====
func TestTransferHandler_InvalidBody(t *testing.T) {
mock := &mockUsecase{}
handler := NewTransferHandler(mock)
req := httptest.NewRequest(http.MethodPost, "/transfer", bytes.NewBuffer([]byte("invalid")))
w := httptest.NewRecorder()
handler.Transfer(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
// ===== TEST INVALID INPUT =====
func TestTransferHandler_InvalidInput(t *testing.T) {
mock := &mockUsecase{}
handler := NewTransferHandler(mock)
reqBody := dto.TransferRequest{
FromID: "",
ToID: "B",
Amount: 0,
}
body, _ := json.Marshal(reqBody)
req := httptest.NewRequest(http.MethodPost, "/transfer", bytes.NewBuffer(body))
w := httptest.NewRecorder()
handler.Transfer(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}
// ===== TEST USECASE ERROR =====
func TestTransferHandler_UsecaseError(t *testing.T) {
mock := &mockUsecase{
err: errors.New("insufficient balance"),
}
handler := NewTransferHandler(mock)
reqBody := dto.TransferRequest{
FromID: "A",
ToID: "B",
Amount: 1000,
}
body, _ := json.Marshal(reqBody)
req := httptest.NewRequest(http.MethodPost, "/transfer", bytes.NewBuffer(body))
w := httptest.NewRecorder()
handler.Transfer(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
}๐ 5. Register Route
internal/delivery/http/router.go
package http
import (
"net/http"
"github.com/fardannozami/fincore/internal/delivery/http/handler"
)
func RegisterRoutes(
walletHandler *handler.WalletHandler,
transferHandler *handler.TransferHandler,
) {
http.HandleFunc("/wallet", walletHandler.CreateWallet)
http.HandleFunc("/transfer", transferHandler.Transfer)
}๐งช 6. Update config dan Main.go
configs/config.go
package configs
import (
"log"
"os"
"strconv"
"github.com/joho/godotenv"
)
type Config struct {
DBUrl string
AutoMigrate bool
Port string
}
func getEnv(key, fallback string) string {
val := os.Getenv(key)
if val == "" {
return fallback
}
return val
}
func mustGetEnv(key string) string {
val := os.Getenv(key)
if val == "" {
log.Fatalf("Missing required env: %s", key)
}
return val
}
func LoadConfig() *Config {
err := godotenv.Load()
if err != nil {
log.Println("Running without .env (production mode)")
}
dbUrl := "host=" + mustGetEnv("DB_HOST") +
" port=" + mustGetEnv("DB_PORT") +
" user=" + mustGetEnv("DB_USER") +
" password=" + mustGetEnv("DB_PASSWORD") +
" dbname=" + mustGetEnv("DB_NAME") +
" sslmode=" + getEnv("DB_SSLMODE", "disable")
autoMigrate, err := strconv.ParseBool(getEnv("AUTO_MIGRATE", "false"))
if err != nil {
autoMigrate = false
}
port := getEnv("PORT", "8080")
return &Config{
DBUrl: dbUrl,
AutoMigrate: autoMigrate,
Port: port,
}
}cmd/app/main.go
package main
import (
"log"
"net/http"
"github.com/fardannozami/fincore/configs"
httpDelivery "github.com/fardannozami/fincore/internal/delivery/http"
"github.com/fardannozami/fincore/internal/delivery/http/handler"
"github.com/fardannozami/fincore/internal/infrastructure/db"
"github.com/fardannozami/fincore/internal/repository"
"github.com/fardannozami/fincore/internal/usecase"
)
func main() {
cfg := configs.LoadConfig()
// ๐ฅ ensure db exists
db.EnsureDatabase(cfg)
// ๐ connect db
database := db.NewPostgres(cfg.DBUrl)
// ๐งฑ auto migrate
if cfg.AutoMigrate {
db.AutoMigrate(database)
}
// ==============================
// ๐งฑ INIT REPOSITORY
// ==============================
walletRepo := repository.NewWalletRepository(database)
ledgerRepo := repository.NewLedgerRepository(database)
transactionRepo := repository.NewTransactionRepository(database)
// ==============================
// ๐ง INIT USECASE
// ==============================
transferUsecase := usecase.NewTransferUsecase(
database,
walletRepo,
ledgerRepo,
transactionRepo,
)
// ==============================
// ๐ INIT HANDLER
// ==============================
walletHandler := handler.NewWalletHandler(database, walletRepo)
transferHandler := handler.NewTransferHandler(transferUsecase)
// ==============================
// ๐ REGISTER ROUTES
// ==============================
httpDelivery.RegisterRoutes(walletHandler, transferHandler)
// ==============================
// ๐ START SERVER
// ==============================
port := cfg.Port
addr := ":" + port
log.Printf("๐ Server running on http://localhost:%s\n", port)
log.Fatal(http.ListenAndServe(addr, nil))
}๐ง 7. API TEST (Integration Test)
๐ฏ Tujuan
Kita mau test:
โ Endpoint /wallet
โ Endpoint /transfer
โ Test concurrency via HTTP
โ Pastikan saldo tetap benar
๐ง 1. Kenapa Perlu API Test?
Sebelumnya kita test langsung ke function:
usecase.Transfer(...)
๐ Tapi di dunia nyata:
- user sakses via HTTP
- ada JSON
- ada handler
Jadi kita harus test dari luar (seperti user)
๐งฑ 2. Setup Test Server
Kita pakai:
- httptest (built-in Go)
- server dummy
Helper setup
test/test_helper.go
package test
import (
"encoding/json"
"fmt"
"log"
"net/http"
"net/http/httptest"
"os"
"time"
"github.com/fardannozami/fincore/internal/delivery/http/handler"
"github.com/fardannozami/fincore/internal/domain"
infraDB "github.com/fardannozami/fincore/internal/infrastructure/db"
"github.com/fardannozami/fincore/internal/repository"
"github.com/fardannozami/fincore/internal/usecase"
"github.com/google/uuid"
_ "github.com/lib/pq"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func SetupTestServer() (*httptest.Server, *gorm.DB) {
database := setupTestDB()
// repo
walletRepo := repository.NewWalletRepository(database)
ledgerRepo := repository.NewLedgerRepository(database)
transactionRepo := repository.NewTransactionRepository(database)
// usecase
transferUsecase := usecase.NewTransferUsecase(
database,
walletRepo,
ledgerRepo,
transactionRepo,
)
// handler
walletHandler := handler.NewWalletHandler(database, walletRepo)
transferHandler := handler.NewTransferHandler(transferUsecase)
// router
mux := http.NewServeMux()
mux.HandleFunc("/wallet", walletHandler.CreateWallet)
mux.HandleFunc("/transfer", transferHandler.Transfer)
return httptest.NewServer(mux), database
}
func setupTestDB() *gorm.DB {
user := "postgres"
password := ""
host := "localhost"
port := "5432"
dbname := "fincore_test"
sslmode := "disable"
infraDB.EnsureDBExists(host, port, user, password, dbname, sslmode)
dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=%s", host, user, password, dbname, port, sslmode)
// Buat logger khusus untuk mengatur SlowThreshold
newLogger := logger.New(
log.New(os.Stdout, "\r\n", log.LstdFlags),
logger.Config{
SlowThreshold: 200 * time.Millisecond, // Reset ke 200ms untuk verifikasi optimasi
LogLevel: logger.Warn,
IgnoreRecordNotFoundError: true,
Colorful: true,
},
)
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{
Logger: newLogger, // Gunakan logger yang baru
})
if err != nil {
log.Fatal("failed connect db:", err)
}
sqlDB, _ := db.DB()
// ๐ฅ penting untuk concurrency test
sqlDB.SetMaxOpenConns(50)
sqlDB.SetMaxIdleConns(10)
// migrate
err = db.AutoMigrate(
&domain.Wallet{},
&domain.Ledger{},
&domain.Transaction{},
)
if err != nil {
log.Fatal("failed migrate:", err)
}
return db
}
func topUp(dbConn *gorm.DB, walletID string, amount int64) {
err := dbConn.Create(&domain.Ledger{
ID: uuid.NewString(), // Generasi ID agar tidak duplicate key
WalletID: walletID,
Amount: amount,
Type: "CREDIT",
}).Error
if err != nil {
log.Fatalf("failed topUp: %v", err)
}
err = dbConn.Model(&domain.Wallet{}).
Where("id = ?", walletID).
Update("balance", gorm.Expr("balance + ?", amount)).Error
if err != nil {
log.Fatalf("failed update balance in topUp: %v", err)
}
}
func createWallet(baseURL string) string {
resp, err := http.Post(baseURL+"/wallet", "application/json", nil)
if err != nil {
panic(err)
}
defer resp.Body.Close()
var res map[string]interface{}
json.NewDecoder(resp.Body).Decode(&res)
return res["id"].(string)
}
func getBalance(db *gorm.DB, walletID string) int64 {
var wallet domain.Wallet
err := db.First(&wallet, "id = ?", walletID).Error
if err != nil {
panic(err)
}
return wallet.Balance
}๐งช 3. Test Create Wallet API
test/wallet_api_test.go
package test
import (
"encoding/json"
"net/http"
"testing"
)
func TestCreateWalletAPI(t *testing.T) {
server, _ := SetupTestServer()
defer server.Close()
resp, err := http.Post(server.URL+"/wallet", "application/json", nil)
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != 200 {
t.Fatalf("expected 200 got %d", resp.StatusCode)
}
var res map[string]interface{}
json.NewDecoder(resp.Body).Decode(&res)
if res["id"] == "" {
t.Error("wallet id empty")
}
}๐ฅ 4. Test Transfer API
test/transfer_api_test.go
package test
import (
"bytes"
"encoding/json"
"net/http"
"testing"
)
func TestTransferAPI(t *testing.T) {
server, db := SetupTestServer()
defer server.Close()
// create 2 wallet
w1 := createWallet(server.URL)
w2 := createWallet(server.URL)
// topup manual (langsung DB / helper)
topUp(db, w1, 100)
body := map[string]interface{}{
"from_id": w1,
"to_id": w2,
"amount": 10,
}
jsonBody, _ := json.Marshal(body)
resp, err := http.Post(server.URL+"/transfer", "application/json", bytes.NewBuffer(jsonBody))
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != 200 {
t.Fatal("transfer failed")
}
}๐ฅ 4. Test Concurrency API
test/transfer_concurrent_api_test.go
package test
import (
"bytes"
"encoding/json"
"net/http"
"sync"
"testing"
)
func TestConcurrentTransferAPIs(t *testing.T) {
server, db := SetupTestServer()
defer server.Close()
w1 := createWallet(server.URL)
w2 := createWallet(server.URL)
topUp(db, w1, 100)
var wg sync.WaitGroup
total := 50
for i := 0; i < total; i++ {
wg.Add(1)
go func() {
defer wg.Done()
body := map[string]interface{}{
"from_id": w1,
"to_id": w2,
"amount": 1,
}
jsonBody, _ := json.Marshal(body)
resp, err := http.Post(server.URL+"/transfer", "application/json", bytes.NewBuffer(jsonBody))
if err != nil {
t.Error(err)
return
}
if resp.StatusCode != 200 {
t.Errorf("status not OK: %d", resp.StatusCode)
return
}
}()
}
wg.Wait()
// cek saldo akhir
balanceA := getBalance(db, w1)
balanceB := getBalance(db, w2)
if balanceA != int64(100-total) {
t.Errorf("wallet A wrong: %d", balanceA)
}
if balanceB != int64(total) {
t.Errorf("wallet B wrong: %d", balanceB)
}
}๐ฏ Hasil Step 3
Sekarang kamu punya:
โ API create wallet
โ API transfer uang
โ Anti race condition beneran
โ Anti deadlock
โ Unit test concurrency
Article Series
Go Financial Core System
Lanjutkan membaca seri ini untuk melihat perjalanan lengkapnya.
- 1Go Financial Core System - System Design29 Mar 20264 min read
- 2Go Financial Core System - Setup Database29 Mar 20263 min read
- 3Go Financial Core System - Handling Race Condition30 Mar 202616 min readCurrent article