Repository pada chapter sebelumnya memberi setiap aggregate root pintu persistensinya sendiri. Begitu aplikasi bertumbuh, muncul operasi yang menulis lewat beberapa pintu sekaligus: memindahkan saldo antar dua akun, menyimpan pesanan beserta itemnya, atau mencatat mutasi stok dan jurnalnya dalam satu tarikan napas. Kalau satu tulisan berhasil dan tulisan berikutnya gagal, data bisnis berubah menjadi setengah jadi — dan tidak ada repository tunggal yang bertanggung jawab atas keutuhan tersebut. Unit of Work Pattern menjawabnya dengan satu objek yang mencatat seluruh perubahan selama satu transaksi bisnis berlangsung, lalu menyetornya ke database sebagai satu kesatuan yang atomik.
Masalah Atomisitas #
Mari kita mulai dari masalahnya dalam bentuk paling konkret: transfer saldo. Dua baris UPDATE harus terjadi bersama-sama atau tidak sama sekali. Solusi standarnya adalah transaksi PDO — beginTransaction(), commit(), dan rollBack() — yang juga sudah kita kenal dari section database
:
<?php
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('CREATE TABLE akun (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nama TEXT NOT NULL,
saldo INTEGER NOT NULL
)');
$masukkan = $pdo->prepare('INSERT INTO akun (nama, saldo) VALUES (?, ?)');
$masukkan->execute(['Budi', 150000]);
$masukkan->execute(['Siti', 80000]);
function tampilkanSemuaAkun(PDO $pdo): void
{
foreach ($pdo->query('SELECT nama, saldo FROM akun ORDER BY id') as $akun) {
echo "{$akun['nama']}: Rp{$akun['saldo']}\n";
}
}
function pindahkanSaldo(PDO $pdo, int $dari, int $ke, int $jumlah): void
{
$pdo->beginTransaction();
$tarik = $pdo->prepare('UPDATE akun SET saldo = saldo - ? WHERE id = ?');
$setor = $pdo->prepare('UPDATE akun SET saldo = saldo + ? WHERE id = ?');
$baca = $pdo->prepare('SELECT saldo FROM akun WHERE id = ?');
$tarik->execute([$jumlah, $dari]);
$setor->execute([$jumlah, $ke]);
$baca->execute([$dari]);
$saldoAkhir = (int) $baca->fetchColumn();
if ($saldoAkhir < 0) {
$pdo->rollBack();
echo "transfer Rp", number_format($jumlah, 0, ',', '.'),
" ditolak — saldo pengirim menjadi negatif\n\n";
return;
}
$pdo->commit();
echo "transfer Rp", number_format($jumlah, 0, ',', '.'), " berhasil\n\n";
}
pindahkanSaldo($pdo, 1, 2, 50000);
tampilkanSemuaAkun($pdo);
pindahkanSaldo($pdo, 1, 2, 500000);
tampilkanSemuaAkun($pdo);Output yang Dihasilkan:
transfer Rp50.000 berhasil
Budi: Rp100000
Siti: Rp130000
transfer Rp500.000 ditolak — saldo pengirim menjadi negatif
Budi: Rp100000
Siti: Rp130000Perhatikan runtutan pada pemanggilan kedua: UPDATE penarikan dan UPDATE penyetoran sama-sama sudah dieksekusi di dalam transaksi, dan justru pemeriksaan saldo setelahnya yang memutuskan menggulung semuanya. Hasil akhirnya tercetak jelas — saldo Budi dan Siti kembali seperti semula seolah transfer Rp500.000 tidak pernah terjadi. Inilah jaminan atomisitas: transaksi database membuat kelompok tulisan menjadi satuan yang tidak bisa pecah.
Solusi manual ini sah, tetapi plumbing-nya tersebar: setiap fungsi yang menulis banyak baris wajib mengingat beginTransaction() di awal, commit() di akhir bahagia, dan rollBack() di setiap jalur gagal. Bayangkan operasi yang menulis lewat tiga repository berbeda — siapa yang memegang transaksinya? Pertanyaan itulah yang mengantar kita ke Unit of Work.
Unit of Work: Kumpulkan, Lalu Setor Sekaligus #
Unit of Work adalah objek yang menjadi satu-satunya pemegang transaksi. Selama transaksi bisnis berlangsung, kode aplikasi cukup mendaftarkan niat perubahan — “saldo akun #2 ingin menjadi sekian” — tanpa menyentuh SQL. Saat flush() dipanggil, seluruh antrian diterjemahkan menjadi query dan dieksekusi di dalam satu transaksi database; satu saja gagal, semuanya digulung:
<?php
final class UnitOfWork
{
private PDO $pdo;
/** @var array<int, int> id akun => saldo yang diinginkan */
private array $antrian = [];
public function __construct()
{
$this->pdo = new PDO('sqlite::memory:');
$this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$this->pdo->exec('CREATE TABLE akun (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nama TEXT NOT NULL,
saldo INTEGER NOT NULL CHECK (saldo >= 0)
)');
$this->pdo->exec("INSERT INTO akun (nama, saldo) VALUES ('Budi', 150000)");
$this->pdo->exec("INSERT INTO akun (nama, saldo) VALUES ('Siti', 80000)");
}
public function bacaSaldo(int $id): int
{
$stmt = $this->pdo->prepare('SELECT saldo FROM akun WHERE id = ?');
$stmt->execute([$id]);
return (int) $stmt->fetchColumn();
}
public function begin(): void
{
$this->antrian = [];
}
public function ubahSaldo(int $id, int $saldoBaru): void
{
$this->antrian[$id] = $saldoBaru;
}
public function jumlahTertunda(): int
{
return count($this->antrian);
}
/**
* Setor seluruh perubahan tertunda dalam SATU transaksi database.
*
* @return list<string> SQL yang benar-benar dieksekusi
*/
public function flush(): array
{
$sql = [];
try {
$this->pdo->beginTransaction();
$stmt = $this->pdo->prepare('UPDATE akun SET saldo = ? WHERE id = ?');
foreach ($this->antrian as $id => $saldo) {
$stmt->execute([$saldo, $id]);
$sql[] = "UPDATE akun SET saldo = ? WHERE id = $id";
}
$this->pdo->commit();
$this->antrian = [];
} catch (Throwable $kegagalan) {
$this->pdo->rollBack();
$this->antrian = [];
throw $kegagalan;
}
return $sql;
}
}
function pindahkanSaldo(UnitOfWork $uow, int $dari, int $ke, int $jumlah): array
{
$uow->begin();
$uow->ubahSaldo($ke, $uow->bacaSaldo($ke) + $jumlah);
$uow->ubahSaldo($dari, $uow->bacaSaldo($dari) - $jumlah);
return $uow->flush();
}
$uow = new UnitOfWork();
foreach (pindahkanSaldo($uow, 1, 2, 50000) as $sql) {
echo $sql, "\n";
}
echo "Budi: Rp", $uow->bacaSaldo(1), ", Siti: Rp", $uow->bacaSaldo(2), "\n";
try {
pindahkanSaldo($uow, 1, 2, 500000);
} catch (PDOException $kegagalan) {
echo "transfer besar ditolak database — semua perubahan digulung bersama\n";
}
echo "Budi: Rp", $uow->bacaSaldo(1), ", Siti: Rp", $uow->bacaSaldo(2), "\n";
echo "antrian setelah kegagalan: ", $uow->jumlahTertunda(), " perubahan\n";Output yang Dihasilkan:
UPDATE akun SET saldo = ? WHERE id = 2
UPDATE akun SET saldo = ? WHERE id = 1
Budi: Rp100000, Siti: Rp130000
transfer besar ditolak database — semua perubahan digulung bersama
Budi: Rp100000, Siti: Rp130000
antrian setelah kegagalan: 0 perubahanMari kita bedah alurnya langkah demi langkah:
begin()membuka transaksi bisnis dengan mengosongkan antrian. Perhatikan bedanya denganbeginTransaction()PDO — belum ada transaksi database yang dibuka di sini; kita baru mencatat niat.ubahSaldo()hanya menyimpan pasangan ID dan saldo ke dalam array$antrian. Dua panggilan dipindahkanSaldo()mendaftarkan dua niat, dan tabelakunmasih utuh belum tersentuh.flush()adalah momen kebenaran: transaksi database baru dibuka di sini, seluruh antrian dieksekusi satu per satu, lalucommit()menutupnya. Dua barisUPDATEpada output pembuka adalah bukti bahwa keduanya lahir dari satuflush().- Constraint
CHECK (saldo >= 0)pada skema membuat transfer kedua gagal di tingkat database — saldo Budi akan menjadi negatif. Blokcatchmenggulung transaksi lalu melempar ulang kegagalan, sehingga pemanggil tinggal menangkapPDOException. - Saldo pada output akhir kembali menunjukkan angka yang sama seperti setelah transfer pertama:
UPDATEpenyetoran yang sempat dieksekusi ikut tergulung bersama kegagalan penarikan. Satu gagal, semua batal — definisi atomisitas yang bekerja.
Melacak Entitas yang Kotor #
Versi UnitOfWork di atas melacak niat perubahan secara eksplisit. Wujud yang lebih canggih — dan lebih akrab di dunia ORM — melacak entitas itu sendiri: objek dimuat lewat unit of work, kondisinya dipotret sebagai snapshot, dan setiap perubahan pada objek terdeteksi dengan membandingkan kondisi terkini terhadap potret awal. Entitas yang berbeda dari potret disebut dirty dan hanya entitas itulah yang dituliskan kembali saat flush():
<?php
final class UnitOfWork
{
private PDO $pdo;
/** @var array<int, array{nama: string, saldo: int}> kondisi terkini */
private array $terlacak = [];
/** @var array<int, array{nama: string, saldo: int}> kondisi saat dimuat */
private array $snapshot = [];
/** @var list<string> */
private array $sqlDieksekusi = [];
public function __construct()
{
$this->pdo = new PDO('sqlite::memory:');
$this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$this->pdo->exec('CREATE TABLE akun (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nama TEXT NOT NULL,
saldo INTEGER NOT NULL
)');
$this->pdo->exec("INSERT INTO akun (nama, saldo)
VALUES ('Budi', 150000), ('Siti', 80000), ('Rina', 210000)");
}
/** @return array<int, array{nama: string, saldo: int}> */
public function muatSemua(): array
{
foreach ($this->pdo->query('SELECT id, nama, saldo FROM akun ORDER BY id') as $baris) {
$this->terlacak[(int) $baris['id']] = [
'nama' => $baris['nama'],
'saldo' => (int) $baris['saldo'],
];
}
$this->snapshot = $this->terlacak;
return $this->terlacak;
}
public function ubahSaldo(int $id, int $saldoBaru): void
{
$this->terlacak[$id]['saldo'] = $saldoBaru;
}
public function flush(): int
{
$stmt = $this->pdo->prepare('UPDATE akun SET saldo = ? WHERE id = ?');
$jumlahUpdate = 0;
foreach ($this->terlacak as $id => $data) {
if ($data === $this->snapshot[$id]) {
continue;
}
$stmt->execute([$data['saldo'], $id]);
$this->sqlDieksekusi[] = "UPDATE akun SET saldo = {$data['saldo']} WHERE id = $id";
$this->snapshot[$id] = $data;
$jumlahUpdate++;
}
return $jumlahUpdate;
}
public function sqlDieksekusi(): array
{
return $this->sqlDieksekusi;
}
}
$uow = new UnitOfWork();
$uow->muatSemua();
$uow->ubahSaldo(2, 140000);
echo "UPDATE dieksekusi: ", $uow->flush(), "\n";
foreach ($uow->sqlDieksekusi() as $sql) {
echo $sql, "\n";
}Output yang Dihasilkan:
UPDATE dieksekusi: 1
UPDATE akun SET saldo = 140000 WHERE id = 2Tiga akun dimuat lewat muatSemua(), tetapi hanya satu yang disentuh kode aplikasi — dan output menegaskan bahwa flush() hanya mengirim satu UPDATE. Perbandingan $data === $this->snapshot[$id] adalah dirty check: array asosiatif dengan isi dan urutan kunci yang identik dinilai sama oleh operator ===, sehingga entitas yang tidak berubah di-skip tanpa menulis apa pun. Setelah ditulis, snapshot diperbarui dan entitas kembali clean — memanggil flush() untuk kedua kalinya tanpa perubahan baru tidak akan mengirim satu pun query. Di ORM sesungguhnya, tabel pemetaan ID-ke-objek ini juga berfungsi sebagai identity map: memuat akun yang sama dua kali mengembalikan objek yang sama persis, bukan salinan kembar.
Unit of Work di Dunia ORM #
Kalau deskripsi di atas terdengar akrab, itu karena arsitektur inilah yang hidup di dalam ORM PHP paling populer. Doctrine ORM membangun EntityManager-nya di sekitar Unit of Work: $entityManager->persist($entity) mendaftarkan niat, $entityManager->flush() menyetor seluruh perubahan terlacak dalam satu transaksi, dan dirty checking menentukan UPDATE mana yang benar-benar perlu dikirim. Bahkan repository resmi Doctrine hanyalah lapisan tipis di atas mekanisme yang sama. Memahami pola ini berarti memahami mengapa ORM berperilaku demikian — mengapa data belum tersimpan sebelum flush(), dan mengapa objek yang dimuat dari dua query berbeda bisa ternyata objek yang sama.
Kabar baiknya bagi kita yang tidak memakai ORM: tidak ada paksaan. Eloquent di Laravel menyediakan DB::transaction() yang lebih ringkas untuk kasus eksplisit, dan pola buatan sendiri sebesar contoh chapter ini sudah cukup untuk aplikasi menengah. Unit of Work adalah pola, bukan produk — prinsip pengumpulan dan penyetoran atomiknya bisa hidup di ukuran apa pun.
Kapan Repository dan Unit of Work Menjadi Berlebihan #
Duo repository + Unit of Work adalah senjata berat, dan senjata berat pantas ditarik hanya saat lawannya benar-benar seberat itu. Panduan praktisnya:
| Situasi | Respons yang sehat |
|---|---|
| Skrip CLI sekali jalan dengan satu tabel | PDO langsung, tanpa class tambahan |
| Satu operasi, satu tabel, tanpa aturan lintas entitas | repository sederhana cukup, UoW berlebih |
| Operasi multi-entitas yang harus atomik | transaksi PDO eksplisit di satu service |
| Banyak operasi multi-entitas + logika domain tebal | repository per aggregate root + Unit of Work |
| Sudah memakai ORM besar | pakai Unit of Work bawaan ORM, jangan menulis sendiri |
Perhatikan bahwa baris ketiga sudah menutup kebutuhan kebanyakan aplikasi kecil: satu fungsi service dengan beginTransaction()/commit()/rollBack() adalah solusi tiga baris yang jujur. Abstraksi penuh baru membayar ketika pola penyetoran atomik itu sendiri berulang di banyak service, dan ketika domain mulai menuntut bahasa yang lebih kaya daripada SQL mentah. Kecil atau besarnya pilihan ini, dan seni menahannya, kita rangkum tuntas di chapter penutup section ini: Menghindari Overengineering
.
Ringkasan #
Unit of Work menjadikan sekumpulan perubahan sebagai satu satuan: niat dikumpulkan dulu, flush() menyetornya dalam satu transaksi database, dan kegagalan di titik mana pun menggulung semuanya. Change tracking memperhalus pola ini — hanya entitas yang kotor yang ditulis, dipandu snapshot kondisi awal — dan identitasnya sebagai jantung ORM seperti Doctrine menjelaskan banyak perilaku framework yang sering terasa magis. Pola repository memberi kita pintu per aggregate root; Unit of Work memastikan seluruh pintu terbuka dan tertutup bersama-sama. Dengan seluruh peralatan desain di tangan, chapter berikutnya membahas keterampilan yang justru paling jarang diajarkan: menahan diri untuk tidak memakainya.