Command Pattern — Design Patterns

Command membungkus permintaan (apa yang harus dilakukan + data) ke dalam object. Object itu bisa disimpan, diantri, di-undo, direplay, atau dikirim ke…

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 transit langsung ke terkirim, 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

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:

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:

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

⚠️ Jebakan yang sering ketemu

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.

Yang akan kamu pelajari