Database transaction adalah aspek krusial dalam pengembangan aplikasi, terutama pada proyek yang menuntut konsistensi data yang tinggi. Artikel ini akan membahas bagaimana cara melakukan transaksi-database pada service layer (logic), dengan tetap mempertahankan prinsip-prinsip clean architecture dan separation of concerns.
Arsitektur terhadap Database Transaction
Dalam arsitektur populer seperti Clean Architecture, Hexagonal Architecture, maupun pendekatan Domain-Driven Design (DDD), pemisahan tanggung jawab menjadi kunci utama. Kita umumnya membagi kode menjadi beberapa lapisan, misalnya Handler -> Service -> Repository. Lapisan service idealnya berisi logika bisnis murni tanpa bergantung pada library eksternal, sementara repository bertanggung jawab atas interaksi dengan database.
Namun, ketika mengimplementasikan operasi database yang memenuhi prinsip ACID (Atomicity, Consistency, Isolation, Durability), muncul pertanyaan: di mana sebaiknya logika database-transaction ditempatkan?
Di lapisan logika atau di lapisan repository? Hal ini seringkali menjadi dilema para programmer, terutama karena tantangan yang muncul dari prinsip arsitektur yang mendesak pemecahan akses ke datastore melalui berbagai repository yang kecil-kecil dan termodularisasi.
note : Atomicity artinya Menjamin bahwa serangkaian operasi dalam satu transaksi harus sepenuhnya berhasil atau sepenuhnya gagal.
Sebagai ilustrasi, mari kita tinjau kasus transfer uang antar rekening: “Transfer uang dari rekening A ke rekening B, perbarui semua data terkait, dan jika gagal, batalkan seluruh proses.” Terdapat dua pendekatan umum:
Pendekatan A : Logika Transaksi di Repository
Pendekatan ini sederhana karena transaksi dimulai dan dikelola langsung di lapisan repository. Namun, pendekatan ini memiliki kelemahan: logika bisnis (transfer uang) tercampur dengan logika akses data. Bayangkan jika ada kebutuhan tambahan, seperti mengirim event saldo ke pihak ketiga sebagai bagian dari atomicity transaksi. Apakah repository harus memiliki dependensi ke layanan eksternal juga? Hal ini jelas melanggar prinsip separation of concerns. Selain itu, service layer menjadi sangat tipis, sehingga menghilangkan manfaat unit test pada layer tersebut.
Pendekatan B : Logika Transaksi di Service
Pendekatan ini menempatkan logika transaksi di service layer, sesuai dengan prinsip separation of concerns. Namun, implementasinya lebih menantang. Bagaimana caranya agar service layer tetap independen dari library database, seperti GORM, sambil tetap bisa mengelola transaksi?
Jadi, Di mana sebaiknya logika transaksi ditempatkan? Di lapisan logika atau di lapisan repository?
Jawabannya adalah di lapisan logika. Hal ini berlaku baik ketika proses mutasi yang melibatkan interaksi dengan beberapa sumber data, maupun ketika melakukan pengumpulan data (agregasi). Alasannya adalah karena logika bisnislah yang menentukan keadaan valid dari suatu kumpulan data pada waktu tertentu. Dengan kata lain, jika sebuah agregat tidak disimpan dalam keadaan yang utuh dan valid, maka operasi bisnis yang dilakukan akan dianggap tidak sesuai dengan aturan bisnis yang berlaku.
Hal diatas juga sejalan dengan penuturan pada buku DDD yang pernah saya baca. Domain Driven Design
Tantangan dan Solusi
Menjaga agar lapisan service tetap murni dari ketergantungan pihak ketiga sambil mengelola transaksi database yang kompleks memang sulit. Namun, beberapa teknik dapat diterapkan untuk mengatasi masalah ini, seperti menggunakan abstraksi transaksi di service tanpa harus berurusan langsung dengan implementasi transaksi dari library database.
Untuk menjaga kemurnian service layer dan tetap mengelola transaksi database dengan efektif, kita akan menggunakan pendekatan berlapis dengan beberapa komponen kunci:
1. DBTX interface
Mendefinisikan interface yang mengabstraksi operasi database, baik operasi biasa maupun operasi dalam transaksi. Ini memungkinkan service layer untuk berinteraksi dengan database tanpa bergantung pada implementasi spesifik. Interface ini akan mencakup method-method seperti Exec, Query, QueryRow, Begin, Commit, Rollback, dan lainnya yang dibutuhkan. Kabar baiknya, jika kamu menggunakan gorm, hal ini tidak perlu dilakukan karena gorm sudah melakukannya (menggabungkan kedua method tersebut menjadi 1). Disini saya membuat contoh dengan menggunakan pgx.
package dbtx
import (
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
type DBTX interface {
// method ini digunakan pgx untuk operasi biasa
Prepare(ctx context.Context, name, sql string) (*pgconn.StatementDescription, error)
Exec(ctx context.Context, sql string, arguments ...interface{}) (commandTag pgconn.CommandTag, err error)
Query(ctx context.Context, sql string, args ...interface{}) (pgx.Rows, error)
QueryRow(ctx context.Context, sql string, args ...interface{}) pgx.Row
// method ini digunakan pgx untuk operasi transaction
Begin(ctx context.Context) (pgx.Tx, error)
Commit(ctx context.Context) error
Rollback(ctx context.Context) error
// DBTX menggabungkan keduanya...
}
2. PGStore
Menyediakan implementasi konkret dari interface DBTX untuk library pgx. Struktur ini akan menangani pemilihan antara koneksi database biasa atau koneksi transaksi. PGStore akan memeriksa apakah context berisi transaksi yang aktif (pgx.Tx). Jika ada, operasi database akan dilakukan menggunakan transaksi tersebut. Jika tidak, operasi akan dilakukan menggunakan koneksi pool pgxpool.
NewPGStore berfungsi untuk membuat instance PGStore. Fungsi ini menerima koneksi pool pgxpool dan (opsional) objek transaksi pgx.Tx. Hal ini akan memudahkan pembuatan instance PGStore dengan cara yang konsisten dan terkontrol.
type PGStore struct {
NonTX *pgxpool.Pool
Tx pgx.Tx
}
// NewPGStore return interface can execute TX and pgx.Pool
func NewPGStore(pool *pgxpool.Pool, tx pgx.Tx) DBTX {
var pgstore PGStore
if tx != nil {
pgstore.Tx = tx
return &pgstore
}
pgstore.NonTX = pool
return &pgstore
}
// Begin implements DBTX
func (p *PGStore) Begin(ctx context.Context) (pgx.Tx, error) {
if p.Tx != nil {
return nil, errors.New("cannot begin inside running transaction")
}
return p.NonTX.Begin(ctx)
}
// Commit implements DBTX
func (p *PGStore) Commit(ctx context.Context) error {
if p.Tx != nil {
return p.Tx.Commit(ctx)
}
return errors.New("cannot commit: nil tx value")
}
// Rollback implements DBTX
func (p *PGStore) Rollback(ctx context.Context) error {
if p.Tx != nil {
return p.Tx.Rollback(ctx)
}
return errors.New("cannot roleback: nil tx value")
}
// Exec implements DBTX
func (p *PGStore) Exec(ctx context.Context, sql string, arguments ...interface{}) (commandTag pgconn.CommandTag, err error) {
if p.Tx != nil {
return p.Tx.Exec(ctx, sql, arguments...)
}
return p.NonTX.Exec(ctx, sql, arguments...)
}
// Prepare implements DBTX
func (p *PGStore) Prepare(ctx context.Context, name string, sql string) (*pgconn.StatementDescription, error) {
if p.Tx != nil {
return p.Tx.Prepare(ctx, name, sql)
}
return nil, errors.New("cannot prefare: pool does not have prefare method")
}
// Query implements DBTX
func (p *PGStore) Query(ctx context.Context, sql string, args ...interface{}) (pgx.Rows, error) {
if p.Tx != nil {
return p.Tx.Query(ctx, sql, args...)
}
return p.NonTX.Query(ctx, sql, args...)
}
// QueryRow implements DBTX
func (p *PGStore) QueryRow(ctx context.Context, sql string, args ...interface{}) pgx.Row {
if p.Tx != nil {
return p.Tx.QueryRow(ctx, sql, args...)
}
return p.NonTX.QueryRow(ctx, sql, args...)
}
3. Fungsi ExtractTx dan injectTx
Selanjutnya kita buat helper yang mengotomasi penggunaan NewPGStore
ini.
ExtractTx
digunakan untuk mengekstraksi koneksi database transaction
yang disimpan pada context
injectTx
digunakan untuk hal yang sebaliknya, yaitu menginjeksi database transaction
ke context.
package dbtx
import (
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type KeyTransaction string
const TXKey KeyTransaction = "unique-key-transaction"
// ExtractTx extract transaction from context and transform database into dbtx.DBTX
func ExtractTx(ctx context.Context, defaultPool *pgxpool.Pool) DBTX {
tx, ok := ctx.Value(TXKey).(pgx.Tx)
if !ok || tx == nil {
return NewPGStore(defaultPool, nil)
}
return NewPGStore(nil, tx)
}
// injectTx injects transaction to context
func injectTx(ctx context.Context, tx pgx.Tx) context.Context {
return context.WithValue(ctx, TXKey, tx)
}
4. TxManager dan fungsi WithAtomic
WithAtomic mengotomasi penggunaan ExtractTx dan injectTx ini. Merupakan wrapper function yang apabila gagal akan melakukan ROLEBACK, dan apabila berhasil akan melakukan COMMIT database transaction.
Singkatnya, ketika WithAtomic dipanggil, context akan terisi dengan database transaction, selanjutnya context berisi database transaction itu yang akan dipakai untuk menjalankan operasi-operasi database berikutnya, repository otomatis akan menggunakannya transaction ini karena melakukan ExtractTx setiap kali perintah database dieksekusi.
pada layer logika kita hanya berurusan dengan WithAtomic ini.
package dbtx
import (
"log/slog"
"github.com/jackc/pgx/v5/pgxpool"
)
type TxManager interface {
WithAtomic(ctx context.Context, tFunc func(ctx context.Context) error) error
}
type txManager struct {
db *pgxpool.Pool
log *slog.Logger
}
func NewTxManager(sqlDB *pgxpool.Pool, log *slog.Logger) TxManager {
return &txManager{
db: sqlDB,
log: log,
}
}
// =========================================================================
// TRANSACTION
// WithAtomic runs function within transaction
// The transaction commits when function were finished without error
func (r *txManager) WithAtomic(ctx context.Context, tFunc func(ctx context.Context) error) error {
// begin transaction
tx, err := r.db.Begin(ctx)
if err != nil {
return fmt.Errorf("begin transaction: %w", err)
}
// run callback
err = tFunc(injectTx(ctx, tx))
if err != nil {
// if error, rollback
if errRollback := tx.Rollback(ctx); errRollback != nil {
r.log.Error("rollback transaction", slog.String("error", errRollback.Error()))
}
return err
}
// if no error, commit
if errCommit := tx.Commit(ctx); errCommit != nil {
return fmt.Errorf("failed to commit transaction: %w", errCommit)
}
return nil
}
5. Implementasi WithAtomic dan ExtractTx
Service Layer:
Service layer menggunakan TxManager.WithAtomic untuk membungkus logika bisnis dalam transaksi. Ini memastikan bahwa semua operasi database dalam logika bisnis tersebut dilakukan secara atomik.
Repository Layer:
Repository layer menggunakan ExtractTx untuk mendapatkan objek DBTX yang tepat (berbasis transaksi atau koneksi biasa) dari context. Semua operasi database di repository dilakukan melalui objek DBTX ini.
Sehingga codenya akan kurang lebih menjadi seperti berikut.
type service struct {
Repo AccountStorer
TxManager TxManager // helper untuk transaction menjadi dependecy tambahan atau bisa digabung ke repo
}
func (s *service) TransferMoney(ctx context.Context, input model.TransferDTO) error {
// shared variable untuk menampung hasil didalam WithAtomic jika ada
// result := ...
// Membungkus prosesnya dengan database transaction
txErr := s.TxManager.WithAtomic(ctx, func(ctx context.Context) error {
// Mengambil account A
accountA, err := s.Repo.GetAccountByID(ctx, input.AccountA)
if err != nil {
return err // Gagal mengambil account A
}
// Mengambil account B
accountB, err := s.Repo.GetAccountByID(ctx, input.AccountB)
if err != nil {
return err // Gagal mengambil account B
}
// Memeriksa apakah saldo account A cukup
if accountA.Balance < input.Amount {
return errors.New("saldo tidak cukup") // Gagal karena saldo tidak cukup
}
// Mengurangi saldo account A
accountA.Balance -= input.Amount
if err := s.Repo.UpdateAccount(ctx, accountA); err != nil {
return err // Gagal update saldo account A
}
// Menambahkan jumlah ke saldo account B
accountB.Balance += input.Amount
if err := s.Repo.UpdateAccount(ctx, accountB); err != nil {
return err // Gagal update saldo account B
}
return nil
})
if txErr != nil {
return txErr
}
return nil
}
// Mengambil account berdasarkan ID
func (r *repo) GetAccountByID(ctx context.Context, id uint) (model.AccountEntity, error) {
dbtx := ExtractTx(ctx, r.db) // mengekstraksi context dan menjadikan db biasa menjadi DBTX interface
var account model.AccountModel
err := dbtx.QueryRow(ctx, "SELECT * FROM accounts WHERE id = $1", id).Scan(
/* ...scan fields of account... */ )
return account, err
}
// Mengupdate account
func (r *repo) UpdateAccount(ctx context.Context, account model.AccountEntity) error {
dbtx := ExtractTx(ctx, r.db) // mengekstraksi context dan menjadikan db biasa menjadi DBTX interface
_, err := dbtx.Exec(ctx, `
UPDATE accounts
SET balance = $1
WHERE id = $2`, account.Balance, account.ID)
return err
}
Dengan mengimplementasikan cara diatas, kita berhasil memisahkan lapisan logika dari ketergantungan pada library pihak ketiga. Pada contoh repository yang saya sertakan, dapat dilihat bahwa untuk mengganti ORM pun, service layer tidak memerlukan perubahan apapun. YEYY.
Mari kita jabarkan lagi, apa saja keuntungannya :
- Logic layer tetap murni, tidak tercemar oleh package gorm atau driver lainnya.
- Transaksi database dapat dikendalikan dengan efektif, memungkinkan untuk mengatur scope transaksi dijaga sekecil mungkin jika diperlukan. Pendekatan ini berbeda dengan penerapan transaksi dalam middleware, yang dapat menyebabkan seluruh proses logika berada dalam satu transaksi database.
- Readability kode tetap terjaga.
- Unit testing tetap berfokus pada logika bisnis saja.
Sample Github Repository
Saya menyertakan contoh kode dalam dua versi, satu untuk GORM dan satu lagi untuk implementasi lainnya (pgx). Di sini, GORM lebih simple karena secara dasar GORM telah menggabungkan operasi database biasa dengan operasi database transaction.
Berikut ini repositorynya : REPOSITORY
Dalam menerapkan transaksi database, penting juga untuk mempertimbangkan kemungkinan terjadinya deadlock. Dalam contoh kode yang saya berikan di atas, saya telah menyederhanakan kode dengan mengesampingkan aspek-aspek tersebut. Saya akan membahas tentang deadlock lebih lanjut dalam kesempatan berikutnya.