Command Pattern membungkus satu aksi jadi object: cara menjalankannya (do), cara membatalkannya (undo), dan data yang dibutuhkan. Karena aksinya sekarang berupa object, dia bisa disimpan di riwayat untuk di-undo, diantrekan untuk dijalankan nanti, atau dicatat lalu diputar ulang. Bayangin kertas pesanan di warung makan. Pelayan menulis pesanan di kertas, bukan berteriak ke dapur. Kertasnya bisa ditumpuk dan dimasak berurutan, dicoret kalau pelanggan batal, dan disimpan sebagai catatan di akhir hari. Pesanan yang diteriakkan langsung gak bisa diapa-apakan lagi.
🧪 Tebakan cepat: admin Kurir Kancil mengubah status KCL-0001 dari
transitlangsung keterkirim, lalu menekan undo. Undo-nya ditulis "mundur satu langkah di daftar status" (dijemput,transit,diantar,terkirim). Status paketnya jadi apa? Tebak dulu, jawabannya ada di bagian "Undo harus mengingat, bukan menebak".
Masalahnya: tombol yang langsung mengubah data
Di panel admin versi pertama, tiap tombol langsung menulis parcel.status = "terkirim". Admin salah klik? Status lamanya sudah tertimpa dan gak tersimpan di mana pun, jadi gak ada yang bisa dibatalkan. Ingin menjalankan 300 perubahan status nanti malam, atau mencatat siapa mengubah apa? Gak bisa juga: aksinya terjadi saat itu lalu hilang.
Command: aksi sebagai object
const parcel = { resi: "KCL-0001", status: "transit" }
function ubahStatus(parcel, statusBaru) {
let statusLama
return {
status: statusBaru,
do() {
statusLama = parcel.status // diingat saat dijalankan
parcel.status = statusBaru
},
undo() {
parcel.status = statusLama
},
}
}
const riwayat = []
function jalankan(cmd) {
cmd.do()
riwayat.push(cmd)
}
function batalkan() {
const cmd = riwayat.pop()
if (!cmd) return false
cmd.undo()
return true
}
jalankan(ubahStatus(parcel, "diantar"))
jalankan(ubahStatus(parcel, "terkirim"))
console.log(parcel.status) // terkirim
batalkan()
console.log(parcel.status) // diantar
batalkan()
console.log(parcel.status) // transit
console.log(batalkan()) // false
| langkah | riwayat | parcel.status |
|---|---|---|
| awal | kosong | transit |
jalankan(ubahStatus(parcel, "diantar")) |
diantar | diantar |
jalankan(ubahStatus(parcel, "terkirim")) |
diantar, terkirim | terkirim |
batalkan() |
diantar | diantar |
batalkan() |
kosong | transit |
batalkan() |
kosong, mengembalikan false |
transit |
ubahStatusgak mengubah apa-apa saat dipanggil. Dia mengembalikan object dengan dua method:domenjalankan,undomembatalkan.doboleh jadi nama method, walaupundojuga kata kunci untukdo...while.do()menyimpanstatusLamasebelum menimpanya. Tanpa itu,undogak tahu harus kembali ke mana.status: statusBaruikut dibawa sebagai data, jadi riwayatnya bisa dibaca dan diputar ulang nanti.riwayatadalah tumpukan (stack):pushmenaruh di atas,popmengambil yang paling atas. Jadi undo membatalkan aksi terakhir dulu.batalkanmengembalikanfalsekalau riwayat sudah kosong, bukan melempar error.jalankangak tahu isi command-nya. Command lain, misalnyaubahAlamat, cukup punyadodanundojuga.
Undo harus mengingat, bukan menebak
Undo yang gak menyimpan apa pun terpaksa menebak:
const STATUS = ["dijemput", "transit", "diantar", "terkirim"]
undo() {
parcel.status = STATUS[STATUS.indexOf(parcel.status) - 1] // mundur satu
}
Admin mengubah KCL-0001 dari transit langsung ke terkirim (kurir lupa memindai diantar). Undo yang menebak mundur satu langkah dari terkirim dan menghasilkan diantar: status yang gak pernah dimiliki paket ini. Itu jawaban tebakannya. Undo yang benar mengembalikan transit, karena do() sudah menyimpannya.
Kapan disimpannya juga penting. Simpan di dalam do(), bukan saat command dibuat. Command yang dibuat jam 9 lalu dijalankan jam 12 akan mengingat status jam 9, yang mungkin sudah berubah.
Antrian dan replay: command sebagai data
Command bisa dibuat sekarang dan dijalankan nanti. antrian.forEach(jalankan) menjalankan 300 perubahan status malam hari, satu per satu. Kalau command-nya ditulis sebagai data biasa, dia juga bisa disimpan sebagai JSON dan diputar ulang di tempat lain:
const log = [
{ type: "ubahStatus", resi: "KCL-0001", status: "diantar" },
{ type: "ubahStatus", resi: "KCL-0001", status: "terkirim" },
]
// misalnya data yang dipulihkan dari backup tadi pagi
const salinan = { "KCL-0001": { resi: "KCL-0001", status: "transit" } }
for (const c of log) jalankan(ubahStatus(salinan[c.resi], c.status))
console.log(salinan["KCL-0001"].status) // terkirim
Memutar ulang catatan yang sama di atas keadaan awal yang sama menghasilkan keadaan akhir yang sama. Kamu sudah sering memakainya:
- Redux action
{ type: "status/ubah", payload: { resi, status } }adalah command sebagai data, dan reducer yang menjalankannya. - Laravel Job:
dispatch(new KirimSms($parcel))menyimpan job di antrian (database atau Redis), lalu worker menjalankannya nanti. - BullMQ:
queue.add("kirimSms", { resi })menyimpan tugas di Redis untuk worker Node.
Selector CSS bukan command: dia deklarasi gaya, bukan aksi yang bisa dijalankan, diantrekan, atau di-undo.
Latihan: createCommandStack
Latihan nanti meminta tumpukan riwayat yang umum, untuk command apa pun. Starter sudah berisi counter, dua command (counter += 5 dan counter *= 2, masing-masing dengan kebalikannya), dan tiga console.log. Kamu menulis createCommandStack() yang mengembalikan object berisi:
execute(cmd): panggilcmd.do(), lalu simpancmdke riwayat.undo(): kalau riwayat kosong,return false. Kalau gak, ambil command terakhir, panggilundo()-nya, lalureturn true.size(): jumlah command di riwayat.
Jalannya: 0 + 5 = 5, lalu 5 × 2 = 10, jadi baris pertama 10. Dua undo dari atas tumpukan: 10 / 2 = 5, lalu 5 - 5 = 0, jadi baris kedua 0. Undo ketiga di riwayat kosong mencetak false. Yang juga dinilai: satu command +5 dijalankan dua kali, lalu di-undo sekali, harus menghasilkan 10|2|true|5.
Kapan gak perlu command
- Aksinya sekali jalan, tanpa undo, antrian, atau catatan.
hitungOngkir("kilat", 2)cukup dipanggil langsung. - Efeknya keluar dari aplikasimu. Undo bisa mengembalikan
parcel.status, tapi SMS "paket diantar" yang sudah terkirim gak bisa ditarik. Untuk itu kamu butuh aksi baru, misalnya SMS koreksi.
⚠️ Jebakan yang sering ketemu
- Undo yang menebak. Mundur satu di daftar status menghasilkan
diantaruntuk paket yang gak pernah diantar. Simpan keadaan lama dido(). - Menyimpan keadaan lama saat command dibuat. Command di antrian mengingat keadaan yang sudah basi saat akhirnya dijalankan.
- Undo dari bawah tumpukan.
shift()alih-alihpop()membatalkan yang pertama dulu: (10 - 5) / 2 = 2,5, bukan 0. - Lupa
return trueataureturn false.console.log(stack.undo())mencetakundefined.
TL;DR: Command membungkus aksi jadi object dengan do() dan undo(), jadi aksinya bisa disimpan, diantrekan, di-undo, atau diputar ulang. Riwayat undo adalah tumpukan: execute memanggil cmd.do() lalu push, undo mengambil yang terakhir dengan pop, memanggil cmd.undo(), dan mengembalikan true, atau false kalau kosong. Undo harus mengingat keadaan lama yang disimpan di do(), bukan menebaknya (transit yang dilompati ke terkirim di-undo jadi diantar). Command sebagai data ({ type, resi, status }) bisa diantrekan dan diputar ulang: Redux action, Laravel Job, BullMQ. Aksi sekali jalan tanpa undo cukup dipanggil langsung.