Async Callback Pattern #

Sebelum goroutine, sebelum channel, sebelum Future — ada callback. Pola ini adalah cara paling tua dan paling fundamental untuk menangani operasi asinkron: kamu menyerahkan sebuah fungsi kepada pemanggil, dan pemanggil berjanji akan menginvokasi fungsi itu saat pekerjaannya selesai. Kamu tidak menunggu. Kamu tidak polling. Kamu hanya bilang “kalau sudah selesai, panggil ini.” Callback menjadi fondasi dari hampir semua sistem event-driven yang ada hari ini — dari Node.js hingga framework GUI, dari network library hingga sistem operasi itu sendiri. Di Go, meskipun goroutine dan channel sering menjadi pilihan utama, memahami Async Callback Pattern tetap krusial: banyak library Go mengekspos API berbasis callback, dan mengetahui kapan callback lebih tepat dibanding channel adalah keahlian yang membedakan engineer yang baik dari yang biasa.

Apa itu Async Callback Pattern? #

Async Callback Pattern adalah pola di mana sebuah fungsi menerima fungsi lain sebagai parameter — disebut callback — dan memanggil callback tersebut ketika operasi asinkron selesai dikerjakan, baik berhasil maupun gagal.

Alur dasarnya selalu sama:

sequenceDiagram
    participant Caller
    participant AsyncFunc as Fungsi Async
    participant BG as Background (goroutine)
    participant CB as Callback

    Caller->>AsyncFunc: panggil dengan callback
    AsyncFunc->>BG: jalankan task di background
    AsyncFunc-->>Caller: kembali segera (non-blocking)
    Note over Caller: melanjutkan pekerjaan lain
    BG->>BG: proses berjalan...
    BG->>CB: panggil callback(result, err)
    Note over CB: logika lanjutan dieksekusi

Tiga karakteristik yang mendefinisikan pola ini:

Karakteristik Penjelasan
Non-blocking Fungsi async kembali segera tanpa menunggu proses selesai
Inversion of Control Caller menyerahkan kontrol “apa yang terjadi selanjutnya” kepada fungsi async
Continuation Logika lanjutan dikemas dalam callback, bukan ditulis setelah pemanggilan

Di Go, callback diimplementasikan sebagai function value — first-class citizen yang bisa disimpan di variabel, dikirim sebagai argumen, atau dikembalikan dari fungsi lain.


Implementasi Dasar #

Bentuk paling sederhana dari Async Callback adalah fungsi yang menerima callback dan menjalankan task di goroutine terpisah.

package main

import (
	"fmt"
	"time"
)

// Definisikan tipe callback secara eksplisit — lebih mudah dibaca dan di-refactor
type Callback func(result string, err error)

// fetchDataAsync menjalankan fetch di background dan memanggil callback saat selesai
func fetchDataAsync(id int, cb Callback) {
	go func() {
		time.Sleep(2 * time.Second) // simulasi I/O

		if id <= 0 {
			cb("", fmt.Errorf("id tidak valid: %d", id))
			return
		}

		cb(fmt.Sprintf("data untuk id %d", id), nil)
	}()
	// fungsi kembali segera — caller tidak blocking
}

func main() {
	fmt.Println("[Main] Memulai fetch data...")

	fetchDataAsync(1, func(result string, err error) {
		if err != nil {
			fmt.Println("[Callback] Error:", err)
			return
		}
		fmt.Println("[Callback] Sukses:", result)
	})

	fmt.Println("[Main] Fetch dimulai, melanjutkan pekerjaan lain")
	time.Sleep(3 * time.Second) // tunggu callback selesai sebelum program exit
}

Ada beberapa detail penting di sini yang sering diabaikan. Pertama, type alias Callback bukan sekadar kenyamanan — ia mendokumentasikan kontrak antara fungsi async dan pemanggilnya. Siapapun yang membaca kode ini langsung tahu bahwa callback menerima string dan error, dan harus menangani keduanya.

Kedua, go func() di dalam fetchDataAsync menjadikan seluruh eksekusi non-blocking — caller kembali ke baris berikutnya sebelum task selesai. Ini adalah bentuk paling murni dari Async Callback.


Callback dengan Multiple Event #

Di sistem nyata, sebuah operasi asinkron sering menghasilkan lebih dari satu jenis event — progress, sukses, error, atau cancelled. Alih-alih satu callback, kita bisa menggunakan struct yang berisi beberapa callback function.

package main

import (
	"fmt"
	"time"
)

// DownloadCallbacks mendefinisikan semua event yang bisa terjadi
type DownloadCallbacks struct {
	OnProgress func(percent int)
	OnSuccess  func(filePath string)
	OnError    func(err error)
}

func downloadFileAsync(url string, cbs DownloadCallbacks) {
	go func() {
		// Simulasi progress download
		for i := 20; i <= 100; i += 20 {
			time.Sleep(300 * time.Millisecond)
			if cbs.OnProgress != nil {
				cbs.OnProgress(i)
			}
		}

		// Simulasi hasil — url kosong dianggap error
		if url == "" {
			if cbs.OnError != nil {
				cbs.OnError(fmt.Errorf("URL tidak boleh kosong"))
			}
			return
		}

		if cbs.OnSuccess != nil {
			cbs.OnSuccess("/tmp/downloaded-file.zip")
		}
	}()
}

func main() {
	downloadFileAsync("https://example.com/file.zip", DownloadCallbacks{
		OnProgress: func(percent int) {
			fmt.Printf("[Progress] %d%%\n", percent)
		},
		OnSuccess: func(filePath string) {
			fmt.Printf("[Success] File tersimpan di: %s\n", filePath)
		},
		OnError: func(err error) {
			fmt.Printf("[Error] Download gagal: %v\n", err)
		},
	})

	time.Sleep(3 * time.Second)
}

Pola struct callback ini memberi fleksibilitas: caller bisa hanya mengisi callback yang diperlukan (field yang nil tidak akan dipanggil), dan penambahan event baru di masa depan tidak memecah signature yang sudah ada.


Masalah Callback Hell #

Sejarah panjang callback membawa satu pelajaran pahit yang berulang di setiap bahasa: callback hell. Ini terjadi ketika logika async yang berlapis memaksa callback bersarang satu di dalam lainnya, menciptakan kode yang sulit dibaca, diuji, dan di-maintain.

// ANTI-PATTERN: callback bersarang — "pyramid of doom"
func processOrder(orderID int) {
	validateOrderAsync(orderID, func(valid bool, err error) {
		if err != nil { handleError(err); return }
		if !valid { handleInvalid(); return }

		fetchInventoryAsync(orderID, func(stock int, err error) {
			if err != nil { handleError(err); return }
			if stock == 0 { handleOutOfStock(); return }

			chargePaymentAsync(orderID, func(txID string, err error) {
				if err != nil { handleError(err); return }

				shipOrderAsync(orderID, txID, func(trackingNum string, err error) {
					if err != nil { handleError(err); return }
					// Empat level dalam — dan ini belum yang terburuk
					fmt.Println("Order shipped:", trackingNum)
				})
			})
		})
	})
}

// BENAR: pisahkan setiap tahap menjadi fungsi bernama yang berdiri sendiri
func onValidated(orderID int) func(bool, error) {
	return func(valid bool, err error) {
		if err != nil { handleError(err); return }
		if !valid { handleInvalid(); return }
		fetchInventoryAsync(orderID, onInventoryFetched(orderID))
	}
}

func onInventoryFetched(orderID int) func(int, error) {
	return func(stock int, err error) {
		if err != nil { handleError(err); return }
		if stock == 0 { handleOutOfStock(); return }
		chargePaymentAsync(orderID, onPaymentCharged(orderID))
	}
}

func onPaymentCharged(orderID int) func(string, error) {
	return func(txID string, err error) {
		if err != nil { handleError(err); return }
		shipOrderAsync(orderID, txID, onOrderShipped)
	}
}

func onOrderShipped(trackingNum string, err error) {
	if err != nil { handleError(err); return }
	fmt.Println("Order shipped:", trackingNum)
}

func processOrder(orderID int) {
	validateOrderAsync(orderID, onValidated(orderID)) // ✓ flat, mudah dibaca
}

Perbedaannya drastis: versi pertama adalah piramida kode yang membuat mata lelah dan logika sulit diikuti, sementara versi kedua adalah urutan pemanggilan yang flat dan setiap tahap mudah diuji secara independen.

flowchart TD
    subgraph BAD["❌ Callback Hell"]
        A1[validateOrder] --> B1[fetchInventory\n  dalam callback A]
        B1 --> C1[chargePayment\n    dalam callback B]
        C1 --> D1[shipOrder\n      dalam callback C]
    end

    subgraph GOOD["✓ Named Callbacks"]
        A2[validateOrder] --> B2[onValidated]
        B2 --> C2[onInventoryFetched]
        C2 --> D2[onPaymentCharged]
        D2 --> E2[onOrderShipped]
    end

Callback dengan Cancellation #

Untuk operasi yang berjalan lama, callback tanpa mekanisme pembatalan adalah resep untuk goroutine leak. Solusinya adalah mengintegrasikan context.Context ke dalam fungsi async.

package main

import (
	"context"
	"fmt"
	"time"
)

type ProcessCallback func(result string, err error)

// processAsync menerima context — bisa dibatalkan dari luar
func processAsync(ctx context.Context, payload string, cb ProcessCallback) {
	go func() {
		// Simulasi pekerjaan yang bisa dibatalkan di tengah jalan
		select {
		case <-time.After(3 * time.Second):
			// Pekerjaan selesai normal
			cb(fmt.Sprintf("processed: %s", payload), nil)
		case <-ctx.Done():
			// Context dibatalkan sebelum selesai
			cb("", fmt.Errorf("operasi dibatalkan: %w", ctx.Err()))
		}
	}()
}

func main() {
	// Buat context yang akan timeout setelah 1 detik
	ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
	defer cancel()

	done := make(chan struct{})

	processAsync(ctx, "order-123", func(result string, err error) {
		defer close(done)
		if err != nil {
			fmt.Println("[Callback] Error:", err)
			return
		}
		fmt.Println("[Callback] Hasil:", result)
	})

	<-done // tunggu callback selesai
	fmt.Println("[Main] Selesai")
}

Menggunakan select dengan ctx.Done() dan operasi utama memastikan goroutine selalu punya jalur keluar — baik selesai normal maupun dibatalkan. Tidak ada goroutine yang terjebak menunggu selamanya.

stateDiagram-v2
    [*] --> Running : goroutine dimulai
    Running --> Completed : operasi selesai sebelum deadline
    Running --> Cancelled : ctx.Done() menerima sinyal
    Completed --> [*] : callback(result, nil) dipanggil
    Cancelled --> [*] : callback("", err) dipanggil

Menggabungkan Banyak Callback Async #

Ketika beberapa operasi async perlu berjalan paralel dan hasilnya dikumpulkan, kombinasi callback dengan sync.WaitGroup dan mutex adalah pola yang solid.

package main

import (
	"fmt"
	"sync"
	"time"
)

type StringCallback func(result string, err error)

func fetchAsync(name string, delay time.Duration, cb StringCallback) {
	go func() {
		time.Sleep(delay)
		cb(fmt.Sprintf("data dari %s", name), nil)
	}()
}

func main() {
	var (
		mu      sync.Mutex
		results []string
		wg      sync.WaitGroup
	)

	sources := []struct {
		name  string
		delay time.Duration
	}{
		{"cache", 100 * time.Millisecond},
		{"database", 400 * time.Millisecond},
		{"external-api", 250 * time.Millisecond},
	}

	for _, src := range sources {
		wg.Add(1)
		fetchAsync(src.name, src.delay, func(result string, err error) {
			defer wg.Done()
			if err != nil {
				fmt.Println("Error:", err)
				return
			}
			mu.Lock()
			results = append(results, result) // ✓ lock saat akses shared state
			mu.Unlock()
		})
	}

	wg.Wait() // tunggu semua callback selesai

	fmt.Println("Semua hasil terkumpul:")
	for _, r := range results {
		fmt.Println(" -", r)
	}
}
Callback dipanggil dari goroutine berbeda. Ini berarti callback berjalan concurrently dengan goroutine lain. Setiap akses ke shared state di dalam callback — seperti slice results di atas — harus dilindungi dengan mutex atau mekanisme sinkronisasi lainnya. Mengabaikan ini menyebabkan data race yang sering hanya muncul di production, bukan saat development.

Async Callback vs Channel vs Future #

Ketiga pendekatan ini sering tertukar satu sama lain. Memahami trade-off masing-masing membantu kamu memilih yang tepat untuk setiap situasi.

Aspek Async Callback Channel Future–Promise
Model Event-based Message-based Result-based
Blocking Tidak ada Saat send/receive Hanya saat Get()
Error handling Parameter callback Error channel terpisah Dibawa bersama nilai
Composability Rendah (callback hell) Sedang Tinggi (WhenAll, dll)
Idiomatik Go Tidak (tapi valid) Ya Ya (dengan generics)
Cocok untuk Simple async, event API Orchestration, pipeline Satu hasil dari satu task
Goroutine management Manual Semi-otomatis Semi-otomatis
flowchart TD
    Q{Perlu hasil\ndi titik tertentu?} -- Ya --> Q2{Satu hasil\natau banyak?}
    Q -- Tidak --> Q3{Ada banyak\nevent type?}
    Q2 -- Satu --> FP[Future–Promise]
    Q2 -- Banyak / streaming --> CH[Channel]
    Q3 -- Ya --> CB[Struct Callback\nmulti-event]
    Q3 -- Tidak --> Q4{Banyak operasi\nparalel?}
    Q4 -- Ya --> CH
    Q4 -- Tidak --> CB2[Simple Callback]

Kapan Tidak Menggunakan Async Callback #

Tetap gunakan Async Callback jika:
  ✓ Mengintegrasikan library eksternal yang mengekspos API berbasis callback
  ✓ Operasi menghasilkan banyak event berbeda (progress, success, error)
  ✓ Tidak perlu menunggu hasil di titik tertentu
  ✓ Tim sudah familier dengan pattern ini dan codebase konsisten menggunakannya

Pertimbangkan Channel atau Future jika:
  ✗ Logika async berlapis lebih dari dua level — tanda callback hell akan datang
  ✗ Perlu chaining atau komposisi banyak operasi async
  ✗ Error handling menjadi rumit dengan callback
  ✗ Butuh cancellation yang mudah dan ekspresif
  ✗ Ini adalah kode Go baru tanpa constraint library eksternal

Anti-Pattern yang Harus Dihindari #

// ✗ Callback dipanggil lebih dari sekali — melanggar kontrak
func badAsync(cb func(string, error)) {
	go func() {
		result, err := doWork()
		cb(result, err) // dipanggil sekali
		if err != nil {
			cb("", err) // ✗ dipanggil lagi — caller tidak siap untuk ini
		}
	}()
}

// ✓ Callback dipanggil tepat sekali, apapun kondisinya
func goodAsync(cb func(string, error)) {
	go func() {
		result, err := doWork()
		cb(result, err) // ✓ satu panggilan, selesai
	}()
}

// ✗ Callback tanpa error — tidak ada cara untuk melaporkan kegagalan
func noErrorCallback(cb func(string)) {
	go func() {
		result, err := doWork()
		if err != nil {
			// error dibuang — caller tidak tahu ada yang salah
			return
		}
		cb(result)
	}()
}

// ✓ Callback selalu membawa error sebagai parameter kedua
func withErrorCallback(cb func(string, error)) {
	go func() {
		result, err := doWork()
		cb(result, err) // ✓ caller bertanggung jawab menangani error
	}()
}

// ✗ Goroutine leak — tidak ada mekanisme berhenti
func leakyAsync(cb func(string, error)) {
	go func() {
		time.Sleep(24 * time.Hour) // bagaimana jika caller sudah tidak peduli?
		cb("selesai", nil)         // goroutine ini hidup selamanya jika caller sudah exit
	}()
}

// ✓ Gunakan context untuk lifecycle management
func safeAsync(ctx context.Context, cb func(string, error)) {
	go func() {
		select {
		case <-time.After(24 * time.Hour):
			cb("selesai", nil)
		case <-ctx.Done():
			cb("", ctx.Err()) // ✓ goroutine bisa berhenti saat diminta
		}
	}()
}

// ✗ Akses shared state tanpa sinkronisasi — data race
var sharedResults []string
func unsafeCallback(result string, err error) {
	sharedResults = append(sharedResults, result) // ✗ race condition
}

// ✓ Lindungi shared state dengan mutex
var (
	mu             sync.Mutex
	protectedResults []string
)
func safeCallback(result string, err error) {
	mu.Lock()
	defer mu.Unlock()
	protectedResults = append(protectedResults, result) // ✓ aman dari race condition
}

Checklist Review Async Callback #

DESAIN CALLBACK:
  □ Tipe callback didefinisikan sebagai type alias yang eksplisit
  □ Callback selalu membawa error sebagai parameter (func(T, error))
  □ Callback hanya dipanggil tepat sekali per eksekusi async

LIFECYCLE GOROUTINE:
  □ Goroutine async punya jalur exit yang jelas (context atau done signal)
  □ Tidak ada goroutine yang bisa leak karena tidak ada mekanisme berhenti
  □ Callback yang mengelola multiple async menggunakan WaitGroup

KONKURENSI:
  □ Akses shared state di dalam callback dilindungi dengan mutex
  □ Callback tidak diasumsikan berjalan di goroutine yang sama dengan caller
  □ Race condition diverifikasi dengan go test -race

KOMPLEKSITAS:
  □ Callback tidak bersarang lebih dari dua level
  □ Callback panjang dipecah menjadi named function
  □ Pertimbangan untuk migrasi ke channel/Future jika logika berkembang

DOKUMENTASI:
  □ Fungsi async mendokumentasikan kapan callback dipanggil
  □ Fungsi async mendokumentasikan apakah callback dipanggil di goroutine terpisah
  □ Kontrak "sekali panggil" didokumentasikan secara eksplisit

Ringkasan #

  • Async Callback adalah pola concurrency paling fundamental — hampir semua abstraksi async modern (Future, Promise, async/await) dibangun di atasnya.
  • Callback menerima kontrol atas “apa yang terjadi selanjutnya” — ini adalah Inversion of Control: caller menyerahkan logika lanjutan kepada fungsi async.
  • Selalu definisikan tipe callback sebagai type alias — ini mendokumentasikan kontrak dan membuat kode lebih mudah di-refactor.
  • Callback harus selalu membawa errorfunc(T, error) adalah signature standar; callback tanpa error tidak bisa melaporkan kegagalan.
  • Callback dipanggil tepat sekali — memanggil callback lebih dari sekali melanggar kontrak dan bisa menyebabkan bug yang sulit dideteksi.
  • Callback hell terjadi ketika callback bersarang — solusinya adalah memecah setiap tahap menjadi named function terpisah yang flat dan bisa diuji independen.
  • Gunakan struct callback untuk multiple event — OnProgress, OnSuccess, OnError yang terpisah lebih ekspresif daripada satu callback dengan banyak kondisi.
  • Callback berjalan di goroutine berbeda — akses shared state di dalam callback harus selindungi dengan mutex untuk mencegah data race.
  • Integrasikan context untuk cancellationselect dengan ctx.Done() memastikan goroutine async selalu punya jalur keluar.
  • Kenali batasnya — untuk operasi berlapis atau yang butuh komposisi, channel atau Future lebih idiomatik di Go; callback bersinar saat mengintegrasikan API eksternal atau event sederhana.

← Sebelumnya: Future-Promise   Berikutnya: Fork–Join →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact