Refactor Legacy ERP Module dengan AI Tanpa Spesifikasi Tertulis

Foto oleh Unknown author via Wikimedia Commons (Public domain)
Characterisation test meng-assert apa yang code lakukan hari ini, bukan apa yang seharusnya dilakukan. Istilah ini diperkenalkan Michael Feathers di Working Effectively with Legacy Code, dan rangkuman Wikipedia menyebut test seperti ini sebagai change detector, bukan correctness validator. Unit test merekam niat sehingga bisa gagal karena code-nya salah; characterisation test hanya bisa gagal karena behaviour-nya bergeser, dan itulah yang Anda butuhkan di module tanpa spesifikasi.
Buat behaviour saat ini bisa diamati sebelum mengubah apa pun. Kunci public boundary module-nya dengan characterisation test, masukkan seam supaya clock, database query dan lookup rate bisa diganti, lalu ekstrak satu function bernama setiap kali dengan suite dijalankan di antaranya. Test itu menjadi spesifikasi yang Anda pakai sebagai acuan, dan setiap rule yang berhasil dipulihkan dicatat sebagai comment plus test yang diganti nama.
Tidak tanpa pengawasan. Posted financial period, document numbering, perhitungan tax dan apa pun yang mengubah angka historis butuh manusia yang membaca setiap baris diff-nya, karena kesalahan di sana berupa restatement, bukan laporan bug, dan baru muncul berminggu-minggu kemudian di akhir bulan. Aturan yang saya pakai adalah bentuk kegagalannya: kalau akibat terburuknya hanya seseorang menjalankan ulang report, agent boleh bekerja sendiri di belakang test.
Karena sequence memang sengaja tidak di-roll back. Dokumentasi PostgreSQL menyatakan bahwa nilai yang diperoleh nextval tidak diambil kembali untuk dipakai ulang jika transaksi pemanggilnya abort, sehingga abort dan crash meninggalkan gap, dan dokumentasi itu menyimpulkan sequence object tidak bisa dipakai menghasilkan gapless sequence. Karena itu ERP membuat numbering sendiri dengan counter table dan lock, yaitu code panjang yang menggoda untuk disederhanakan dan justru tidak boleh.
Replay satu period yang sudah ditutup dan diaudit ke schema terpisah, lalu diff trial balance hasilnya terhadap apa yang benar-benar di-posting. Delta nol pada setiap account berarti path yang di-refactor mereproduksi satu bulan yang sudah disetujui, dan delta yang tidak nol langsung menyebut account yang rule-nya belum Anda temukan. Pastikan tidak ada bagian path posting yang menulis di luar target schema sebelum dijalankan.

Foto oleh Unknown author via Wikimedia Commons (Public domain)
Ringkasan Utama
Ketika business rule sebuah legacy ERP module hanya ada di dalam code-nya, lakukan refactor di belakang characterisation test: assertion yang mengunci apa yang sistem lakukan hari ini, termasuk behaviour yang Anda curigai salah, supaya setiap perubahan menjadi terlihat. Biarkan agent mengerjakan extraction sempit di belakang jaring itu, dan jauhkan posted period, document numbering serta tax dari jangkauannya.
Module itu adalah approval chain untuk purchase requisition: satu file, sekitar sembilan ratus baris, dan tumpukan kondisi atas nilai, branch, cost centre, serta role si pengaju sendiri. Satu-satunya hal yang menyerupai spesifikasi adalah commit message tahun 2017 yang menyebut bahwa threshold-nya disepakati bersama finance pada rapat hari Selasa. Orang yang hadir di rapat itu keluar dua tahun kemudian. Rule-nya masih jalan setiap hari, di setiap requisition, di tiga branch.
Itu kondisi normal pekerjaan maintenance ERP, bukan kondisi luar biasa, dan itu mengubah arti sebuah refactor: Anda tidak bisa memverifikasi perubahan terhadap requirement yang tidak Anda punya. Tulisan ini adalah metode yang saya pakai sebagai gantinya — kunci behaviour-nya, potong seam-nya, lalu serahkan satu extraction sempit ke agent satu per satu — plus empat module yang tidak akan saya biarkan dikerjakan agent tanpa pengawasan.
Di ERP yang dibangun untuk satu perusahaan, dokumen requirement adalah artefak paling cepat mati dalam proyek. Dokumen itu menggambarkan sistem seperti saat di-scope, tergeser oleh tiga minggu pertama user acceptance testing, lalu tidak pernah diedit lagi. Yang bertahan adalah code-nya, dan itu membuat code menjadi spesifikasi, mau diniatkan begitu atau tidak.
Jadi sebelum menulis apa pun, cari tahu di mana rule yang masih hidup sebenarnya berada. Di module ini ada empat tempat, dan tidak satu pun berupa dokumen:
Konsekuensinya adalah satu hal yang membedakan ini dari refactor biasa. Pekerjaan pertama bukan memperbaiki code, dan juga bukan memahaminya. Pekerjaan pertama adalah membuat apa yang code lakukan sekarang bisa diamati dari luar, supaya sebuah perubahan berhenti menjadi tidak terlihat.
Characterisation test melakukan itu. Istilahnya milik Michael Feathers, dari Working Effectively with Legacy Code, dan definisinya sengaja sempit: test-nya meng-assert apa yang code lakukan, bukan apa yang seharusnya dilakukan, dan tujuannya adalah membuat perubahan bisa terdeteksi. Rangkuman Wikipedia soal praktik ini menyebutnya change detector, bukan correctness validator, dan mencatat bahwa test seperti ini tidak memverifikasi behaviour yang benar, karena hal itu bisa mustahil ditentukan.
Ini satu-satunya konteks di software di mana test yang merekam behaviour saat ini adalah tujuan, bukan kritik. Teknik yang membuatnya cepat ditulis justru terasa aneh: assert sesuatu yang Anda tahu salah, jalankan, dan biarkan diff kegagalannya mencetak nilai yang sebenarnya. Baru setelah itu tempel nilai aslinya dan beri nama test sesuai apa yang Anda amati.
// Characterisation, not correctness. The test name says what the code DOES;
// the comment says what I think of it. Nobody should later mistake either
// assertion below for a statement of what finance asked for.
import { describe, it, expect } from "vitest";
import { resolveApprovalChain } from "../src/requisition/approval";
const requester = {
id: 4821,
role: "branch_manager",
branchId: 7,
costCentre: "OPS-JKT",
};
describe("resolveApprovalChain: observed behaviour, September 2026", () => {
it("characterises: a branch manager's own requisition skips the branch step", () => {
const chain = resolveApprovalChain(requester, { amountIdr: 25_000_000 });
// I asserted [] on the first run on purpose. The failure diff printed the
// real chain, and this is a paste of it. Reading 900 lines would have
// given me my guess about the behaviour, not the behaviour.
expect(chain.map((step) => step.role)).toEqual([
"finance_controller",
"director",
]);
});
it("characterises: an amount EQUAL to the band edge takes the lower band", () => {
const chain = resolveApprovalChain(requester, { amountIdr: 20_000_000 });
// I believe this is wrong. It is pinned anyway, because if an agent
// "corrects" the comparison operator I want a red suite today, not a
// finance email after month-end.
expect(chain.map((step) => step.role)).toEqual(["finance_controller"]);
});
});Dua aturan soal nama itu, karena nama adalah yang diwarisi pembaca berikutnya. Beri awalan characterises pada setiap nama, supaya tidak ada yang salah mengira itu pernyataan niat. Dan kunci di public boundary module-nya, bukan di internal-nya, kalau tidak test itu justru menghalangi refactor yang ingin dimungkinkannya — assertion pada private helper adalah kunci pada bentuk yang sedang Anda coba ubah.
Seam, dalam istilah Feathers dan di bab empat buku itu, adalah tempat di mana Anda bisa mengubah behaviour tanpa mengedit di tempat itu. Terdengar abstrak sampai Anda mencarinya di sebuah ERP module, di mana lima hal yang sama selalu muncul: clock, database query, pembacaan configuration table, service document numbering, dan lookup currency rate. Menamai lima hal itu adalah sebagian besar pekerjaannya, karena sisa module-nya hanyalah aritmetika atas hasil kelimanya.
// Before: three dependencies buried mid-function, so the only way to test
// this is to have a database, a network and the right date.
export async function resolveApprovalChain(requester: Requester, doc: Doc) {
const today = new Date();
const bands = await bandRepo.forBranch(requester.branchId);
const rate = await fxService.rateFor(doc.currency, today);
// ... 900 lines of accumulated policy
}
// After: the same three, hoisted into one optional deps object. This is the
// seam. The default argument is what matters: every existing call site still
// compiles untouched, so introducing the seam changes no behaviour.
export interface ChainDeps {
now: () => Date;
loadBands: (branchId: number) => Promise<Band[]>;
rateFor: (currency: string, on: Date) => Promise<number>;
}
const productionDeps: ChainDeps = {
now: () => new Date(),
loadBands: (branchId) => bandRepo.forBranch(branchId),
rateFor: (currency, on) => fxService.rateFor(currency, on),
};
export async function resolveApprovalChain(
requester: Requester,
doc: Doc,
deps: ChainDeps = productionDeps,
) {
const today = deps.now();
const bands = await deps.loadBands(requester.branchId);
const rate = await deps.rateFor(doc.currency, today);
// ...
}Di TypeScript, seam yang paling murah adalah default parameter. Object dependency masuk, implementasi production menjadi default-nya, dan tidak ada call site yang berubah — persis itu yang membuat langkah ini aman dilakukan sebelum Anda punya test yang layak dipercaya. Characterisation test-nya kemudian mengirim clock yang dibekukan dan band table yang tetap, sehingga berhenti bergantung pada tanggal hari ini dan pada isi configuration table minggu itu.
Ada masalah ayam dan telur di sini yang perlu disebut, karena di situlah waktu saya paling banyak terbuang. Memasukkan seam itu sendiri sebuah perubahan, sementara Anda ingin punya test sebelum mengubah apa pun. Jalan keluarnya adalah mengunci dulu di boundary terluar yang bisa Anda capai tanpa edit sama sekali — HTTP handler atau entry point service, terhadap database sungguhan, dengan lambat — potong seam-nya di bawah jaring itu, lalu baru pindahkan kuncinya ke dalam, ke function yang sebenarnya ingin Anda kerjakan. Test yang lambat itu hanya perancah dan dihapus di akhir.
Unit pekerjaan agent di sini lebih kecil daripada yang terasa seharusnya: satu function bernama, dari satu rentang baris, dengan suite dijalankan di antara setiap langkah. Instruksi yang luas menghasilkan diff yang memindahkan sebelas hal, dan suite yang merah kemudian hanya memberi tahu bahwa satu dari sebelas itu salah. Loop yang akhirnya saya pakai punya empat langkah.
Instruksi read-only harus diucapkan terang-terangan. Cara tercepat mengubah suite merah menjadi hijau adalah mengedit assertion-nya, dan agent yang diminta membuat test lulus akan melakukan tepat itu, dengan senang hati dan dengan penjelasan yang masuk akal. Di module yang test-nya adalah satu-satunya spesifikasi yang Anda punya, itu bukan jalan pintas, itu penghapusan spesifikasi.
Tempel output test yang gagal ke dalam prompt, jangan menjelaskan kegagalannya. Agent jadi punya nilai expected dan received yang sebenarnya, file-nya, nomor barisnya dan nama assertion-nya, lalu berhenti menebak band mana yang Anda maksud. Menjelaskan kegagalan dalam bentuk prosa justru membuang bagian yang paling berguna.

Agent tidak boleh menyentuh posted financial period, document numbering, perhitungan tax, atau apa pun yang mengubah angka historis, tanpa ada manusia yang membaca setiap baris diff-nya. Ini bukan penilaian soal kemampuan. Ini soal bentuk kegagalannya. Di module reporting atau master data, diff yang salah menghasilkan angka salah di layar, ada yang mengeluh dalam satu jam, dan Anda menjalankannya ulang. Di posting, numbering dan tax, diff yang salah menghasilkan angka yang sudah dilaporkan, dan itu baru muncul berminggu-minggu kemudian saat seseorang melakukan rekonsiliasi di akhir bulan.
Masing-masing dari keempatnya punya alasan sendiri. Posted period dijaga begitu ketat oleh produk ERP yang matang sehingga Odoo menyediakan dua lock date terpisah untuknya: Lock Everything date yang memblokir baik modifikasi journal entry yang sudah di-posting maupun posting baru dengan tanggal pada atau sebelum tanggal itu, dan Hard Lock date yang tidak bisa dibalik, untuk yurisdiksi yang mensyaratkan inalterability. Fitur itu ada karena kelas perubahan ini harus dibuat mustahil, bukan sekadar tidak dianjurkan.
Document numbering adalah yang paling terlihat mudah di-refactor, dan justru bukan. Gapless numbering adalah kewajiban hukum di beberapa yurisdiksi, dan itu tidak bisa diserahkan pada database sequence: dokumentasi PostgreSQL menyatakan dengan jelas bahwa nilai yang diperoleh nextval tidak diambil kembali untuk dipakai ulang jika transaksi pemanggilnya abort, sehingga abort dan crash meninggalkan gap, dan karena itu sequence object tidak bisa dipakai untuk menghasilkan gapless sequence. Itulah sebabnya setiap ERP membuat numbering sendiri dengan counter table dan lock — code yang panjang dan janggal, yang tampak persis seperti sesuatu yang layak dirapikan agent. Perhitungan tax dan landed cost melengkapi daftarnya: mengalokasikan ulang landed cost mengubah inventory valuation secara retroaktif, dan bersamanya cost of goods sold pada invoice yang sudah diterbitkan.
Alasan batas ini lebih ketat daripada tampaknya adalah latensi. Posting rule yang salah tidak gagal sebagai laporan bug. Ia gagal sebagai restatement, ditemukan di akhir bulan oleh orang yang merekonsiliasi angka total, berminggu-minggu setelah diff-nya di-merge dan lama setelah tidak ada lagi yang ingat apa yang berubah. Pada titik itu Anda tidak sedang men-debug code, Anda sedang menjelaskan ke finance mengapa angka bulan lalu bergeser.
Versi yang bisa ditegakkan dari aturan ini adalah deny list, karena instruksi di dalam prompt hanyalah preferensi sementara permission rule bukan. Claude Code mengevaluasi deny rule sebelum ask dan allow, dan match pertama yang menentukan tanpa memandang seberapa spesifik, jadi deny rule mengalahkan flag per session:
// .claude/settings.json
// deny is evaluated before ask and allow, and the first match decides, so
// these beat any allow rule and any --allowedTools flag for the session.
{
"permissions": {
"deny": [
"Edit(./src/accounting/posting/**)",
"Edit(./src/accounting/period-close/**)",
"Edit(./src/tax/**)",
"Edit(./src/numbering/**)",
"Edit(./db/migrations/**)"
]
}
}Jujurlah soal apa yang Anda dapat dari itu. Dokumentasinya eksplisit bahwa deny rule untuk Read dan Edit mencakup file tool bawaan dan command file yang dikenali Claude Code di dalam Bash, seperti cat, head, tail dan sed, tetapi tidak mencakup subprocess sembarang — untuk penegakan di level sistem operasi, dokumentasi itu mengarahkan Anda ke sandbox. Jadi deny list adalah pagar terhadap edit yang tidak sengaja, bukan security boundary. Aturan kerja di bawahnya lebih sederhana: kalau akibat terburuk dari diff yang salah adalah seseorang menjalankan ulang sebuah report, agent boleh bekerja sendiri di belakang test; kalau akibat terburuknya adalah angka yang sudah tercetak di sebuah dokumen berubah, ada manusia yang membaca setiap baris.
Di sekitar extraction ketiga atau keempat, sebuah kondisi berhenti terasa seperti noise dan menjadi sebuah kalimat. Di module ini kondisi itu adalah skip untuk branch manager: dulu para manager memiliki cost centre mereka sendiri di bawah struktur organisasi yang lebih lama, sehingga requisition yang mereka ajukan sudah disetujui oleh orang yang akan dituju chain itu. Rule-nya sama sekali tidak sembarangan. Ia benar pada 2017 dan hanya kebetulan hidup lebih lama daripada alasannya.
Kalimat itu lebih bernilai daripada refactor-nya, dan ia tidak punya tempat hidup kecuali Anda menaruhnya di suatu tempat. Jadi setiap rule yang berhasil dipulihkan mendapat tiga artefak sebelum saya lanjut:
Artefak kedua juga satu-satunya metrik kemajuan yang berarti di module seperti ini. Bukan baris yang dihapus, bukan file yang dipecah, bukan coverage. Berapa banyak test yang sudah bisa Anda ganti nama, karena angka itulah ukuran spesifikasi yang benar-benar berhasil Anda pulihkan.

Batas itu bukan pengecualian permanen, dan itu merepotkan, karena code posting biasanya paling butuh dikerjakan dibanding apa pun di sistem. Jalan masuknya adalah replay: ambil satu period yang sudah ditutup dan diaudit, jalankan ulang logic posting-nya ke sebuah schema terpisah, lalu diff trial balance hasilnya terhadap apa yang benar-benar di-posting. Itu membuat module yang dijaga bisa diamati dengan cara yang sama seperti characterisation test membuat sebuah function bisa diamati, tanpa menulis satu baris pun ke ledger.
# Replay one closed month into a scratch schema, then diff the trial balance.
# Nothing writes to public.*; the real ledger is read-only for the whole run.
psql -v ON_ERROR_STOP=1 -c 'CREATE SCHEMA replay_2026_07'
node ./scripts/replay-posting.mjs --period 2026-07 --target replay_2026_07
psql -v ON_ERROR_STOP=1 <<'SQL'
-- One row per account where the refactored posting path disagrees with what
-- was actually posted and already audited. An empty result is the whole point.
SELECT COALESCE(p.account_code, r.account_code) AS account_code,
COALESCE(p.balance, 0) AS posted,
COALESCE(r.balance, 0) AS replayed,
COALESCE(r.balance, 0) - COALESCE(p.balance, 0) AS delta
FROM (SELECT account_code, sum(debit - credit) AS balance
FROM public.gl_entry
WHERE period = '2026-07'
GROUP BY account_code) p
FULL JOIN
(SELECT account_code, sum(debit - credit) AS balance
FROM replay_2026_07.gl_entry
GROUP BY account_code) r
ON r.account_code = p.account_code
WHERE COALESCE(p.balance, 0) <> COALESCE(r.balance, 0)
ORDER BY abs(COALESCE(r.balance, 0) - COALESCE(p.balance, 0)) DESC;
SQLDelta nol pada setiap account berarti path yang sudah di-refactor mereproduksi satu bulan yang sudah disetujui, dan tanpa adanya spesifikasi, itu bukti terkuat yang tersedia di seluruh kantor. Delta yang tidak nol pada satu account bahkan lebih baik: itu adalah rule yang belum Anda temukan, dan ia baru saja memberi tahu di account mana ia tinggal.
Dua batasan, saya sebut karena saya pernah kena keduanya. Replay membuktikan masa lalu, bukan masa depan, jadi rule yang memang berubah di tengah period akan muncul sebagai delta yang benar dan harus dikecualikan manual. Dan replay hanya jujur kalau schema terpisahnya benar-benar terpisah, artinya pastikan tidak ada bagian dari path posting yang menulis di luar target schema-nya sebelum Anda menjalankannya — satu nama table yang di-hard-code di branch yang jarang dipakai sudah cukup untuk mengubah latihan ini menjadi posting sungguhan.
Metodenya tidak canggih. Buat behaviour saat ini bisa diamati, lalu ubah dalam unit yang cukup kecil sehingga test merah bisa menyebut perubahan mana penyebabnya. Yang ditambahkan agent adalah unit kecil itu menjadi murah, sehingga extraction yang tadinya akan Anda tunda satu kuartal selesai dalam satu sore. Yang tidak berubah adalah siapa yang bertanggung jawab ketika sebuah angka yang sudah di-posting bergeser — itulah sebabnya module yang bisa menggeser angka seperti itu selalu dibaca manusia di setiap diff, dan sebabnya file test tetap read-only selama agent bekerja.
Sumber & bacaan lanjutan