Back to Blog

Go Financial Core System - Handling Race Condition

30 Maret 202616 min read
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:

plaintext
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

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

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

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

bash
go get github.com/stretchr/testify

internal/repository/ledger_repo.go

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

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

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

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

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

bash
go get github.com/google/uuid

buat unit testnya

internal/usecase/transfer_usecase_test.go

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

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

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

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

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

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

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

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

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

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

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

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

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

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.

  1. 1
  2. 2
  3. 3
    Go Financial Core System - Handling Race Condition
    30 Mar 202616 min readCurrent article