Kembali ke Artikel

Belajar Golang dari Nol #9: Interface, Kontrak yang Bikin Kode Fleksibel

Solusi IT

Di Belajar Golang dari Nol #8 kita sudah merapikan kode ke dalam package dan module. Struktur project kamu sekarang sudah bersih. Tapi ada satu masalah yang belum kita sentuh: bagaimana kalau satu tugas bisa dikerjakan dengan banyak cara berbeda? Di sinilah interface masuk. Ini salah satu fitur paling penting di Go, dan kabar baiknya, konsepnya sederhana.

Masalah: satu tugas, banyak cara

Bayangkan kamu membangun aplikasi toko online. Setiap kali ada pesanan baru, sistem harus mengirim notifikasi ke pembeli. Ada yang mau lewat WhatsApp, ada yang lewat email, ada yang lewat SMS.

Dengan bekal sampai bagian 8, kamu mungkin akan menulis begini:

func KirimNotifikasi(via string, pesan string) error {
	if via == "wa" {
		// logika kirim WhatsApp
	} else if via == "email" {
		// logika kirim email
	} else if via == "sms" {
		// logika kirim SMS
	}
	return nil
}

Kode ini jalan. Tapi coba pikirkan nasibnya tiga bulan lagi. Perusahaan mau tambah notifikasi lewat Telegram. Kamu harus buka function ini lagi, tambah satu cabang else if lagi. Tambah push notification, tambah cabang lagi. Function-nya makin gemuk, dan setiap perubahan berisiko merusak cabang yang sudah jalan.

Masalah sebenarnya bukan di if else. Masalahnya, function ini harus tahu detail semua cara pengiriman. Padahal yang dia butuhkan cuma satu hal: sesuatu yang bisa mengirim pesan. Titik.

Interface itu kontrak

Interface di Go adalah cara kita menulis kebutuhan itu secara eksplisit. Bentuknya begini:

type Notifier interface {
	Kirim(pesan string) error
}

Baca deklarasi ini seperti kontrak kerja. Perusahaan pasang lowongan: “kami butuh kurir, syaratnya bisa mengantar paket”. Perusahaan tidak peduli kurirnya naik motor, mobil, atau sepeda. Selama bisa mengantar paket, kontrak terpenuhi, dia bisa dipekerjakan.

Notifier adalah kontrak versi kode. Isinya satu syarat: siapa pun yang mau disebut Notifier harus punya method Kirim yang menerima string dan mengembalikan error. Interface tidak berisi implementasi sama sekali. Dia cuma daftar syarat.

Implementasi implisit, ciri khas Go

Di bahasa lain seperti Java atau PHP, kamu harus menulis implements Notifier secara eksplisit di deklarasi class. Go tidak begitu. Di Go, sebuah type otomatis memenuhi interface kalau dia punya semua method yang disyaratkan. Tidak ada kata kunci implements. Tidak ada pendaftaran apa pun.

Kembali ke analogi kontrak kerja: kamu tidak perlu sertifikat bertuliskan “saya kurir”. Kalau kamu terbukti bisa mengantar paket, kamu memenuhi syarat. Kemampuanmu yang bicara, bukan labelmu.

Kenapa ini enak? Dua alasan:

  • Kamu bisa membuat interface untuk type yang bukan milikmu. Misalnya type dari library orang lain sudah punya method yang cocok, dia otomatis memenuhi interface buatanmu tanpa mengubah kode library itu.
  • Kode implementasi tidak perlu tahu interface apa saja yang dia penuhi. Struct WhatsAppSender cukup fokus mengirim WhatsApp. Urusan dia dipakai sebagai Notifier atau bukan, itu urusan pemakainya.

Contoh utuh: sistem notifikasi

Sekarang kita rakit semuanya. Dua struct, satu interface, satu function yang menerima interface:

package main

import (
	"errors"
	"fmt"
)

type Notifier interface {
	Kirim(pesan string) error
}

type WhatsAppSender struct {
	Nomor string
}

func (w WhatsAppSender) Kirim(pesan string) error {
	if w.Nomor == "" {
		return errors.New("nomor WhatsApp kosong")
	}
	fmt.Println("[WA ke", w.Nomor+"]", pesan)
	return nil
}

type EmailSender struct {
	Alamat string
}

func (e EmailSender) Kirim(pesan string) error {
	if e.Alamat == "" {
		return errors.New("alamat email kosong")
	}
	fmt.Println("[Email ke", e.Alamat+"]", pesan)
	return nil
}

func ProsesNotifikasi(n Notifier, pesan string) {
	err := n.Kirim(pesan)
	if err != nil {
		fmt.Println("Gagal kirim:", err)
	}
}

func main() {
	wa := WhatsAppSender{Nomor: "0812xxxx1234"}
	email := EmailSender{Alamat: "budi@tokokopi.id"}

	ProsesNotifikasi(wa, "Pesanan kamu sudah dikirim")
	ProsesNotifikasi(email, "Pesanan kamu sudah dikirim")
}

Perhatikan ProsesNotifikasi. Parameternya bertipe Notifier, bukan WhatsAppSender atau EmailSender. Function ini tidak tahu dan tidak peduli pesan dikirim lewat apa. Dia cuma pegang kontrak: apa pun yang masuk, pasti bisa dipanggil method Kirim-nya.

Output program di atas:

[WA ke 0812xxxx1234] Pesanan kamu sudah dikirim
[Email ke budi@tokokopi.id] Pesanan kamu sudah dikirim

Karena Notifier adalah type biasa, kamu juga bisa membuat slice berisi campuran implementasi. Ini pola broadcast yang sering dipakai:

	daftar := []Notifier{
		WhatsAppSender{Nomor: "0812xxxx1234"},
		EmailSender{Alamat: "budi@tokokopi.id"},
		EmailSender{},
	}

	for _, n := range daftar {
		ProsesNotifikasi(n, "Promo akhir bulan dimulai")
	}

Elemen ketiga sengaja dibuat kosong supaya kelihatan jalur error-nya:

[WA ke 0812xxxx1234] Promo akhir bulan dimulai
[Email ke budi@tokokopi.id] Promo akhir bulan dimulai
Gagal kirim: alamat email kosong

Sekarang bandingkan dengan versi if else di awal. Mau tambah Telegram? Buat struct TelegramSender dengan method Kirim, selesai. ProsesNotifikasi tidak disentuh sama sekali. Kode lama tetap aman, fitur baru tinggal ditambahkan. Ini yang dimaksud kode fleksibel.

Interface kecil itu idiomatik

Perhatikan Notifier cuma punya satu method. Ini bukan kebetulan. Di Go, interface yang bagus justru yang kecil. Makin sedikit syarat di kontrak, makin banyak type yang bisa memenuhinya, makin fleksibel kodemu.

Standard library Go penuh contoh interface satu method. Dua yang paling sering kamu temui:

error ternyata interface

Masih ingat bagian 3 waktu kita belajar error handling? Waktu itu saya bilang error akan kita bahas lebih dalam nanti. Ini dia bayarannya. error yang selama ini kamu pakai sebenarnya interface bawaan:

type error interface {
	Error() string
}

Artinya, type apa pun yang punya method Error() string bisa dipakai sebagai error. Itulah kenapa kamu bisa mengembalikan hasil errors.New, hasil fmt.Errorf, atau error buatan library mana pun dari function yang sama. Semuanya cuma memenuhi satu kontrak kecil.

fmt.Stringer

Interface kecil lain yang berguna adalah fmt.Stringer, kontraknya satu method String() string. Kalau struct kamu memenuhinya, fmt.Println otomatis memakai method itu saat mencetak:

package main

import "fmt"

type Produk struct {
	Nama  string
	Harga int
}

func (p Produk) String() string {
	return fmt.Sprintf("%s (Rp%d)", p.Nama, p.Harga)
}

func main() {
	p := Produk{Nama: "Kopi Gayo 250g", Harga: 85000}
	fmt.Println(p)
}

Tanpa method String, outputnya format default: {Kopi Gayo 250g 85000}. Dengan method itu, outputnya jadi Kopi Gayo 250g (Rp85000). Kamu tidak pernah mendaftarkan apa pun ke package fmt. Dia cuma mengecek: type ini memenuhi kontrak Stringer atau tidak. Implementasi implisit bekerja diam-diam di sini.

any, interface kosong, dan type assertion

Ada satu interface ekstrem: interface tanpa syarat sama sekali, ditulis interface{} atau alias modernnya, any. Karena kontraknya kosong, semua type otomatis memenuhinya. Variabel bertipe any bisa diisi apa saja.

Kedengarannya praktis, tapi hati-hati. Begitu data masuk ke any, compiler tidak bisa lagi menjaga kamu. Kamu kehilangan informasi type, dan itu mahal. Untuk mengambil kembali type aslinya, kamu butuh type assertion:

var data any = "halo"

s, ok := data.(string)
if ok {
	fmt.Println("panjang teks:", len(s))
} else {
	fmt.Println("data bukan string")
}

Bentuk data.(string) artinya “saya yakin isi data adalah string, tolong keluarkan”. Variabel ok bernilai true kalau tebakanmu benar. Selalu pakai bentuk dua nilai ini. Bentuk satu nilai tanpa ok akan membuat program panic kalau tebakannya salah.

Aturan praktisnya: pakai any hanya kalau memang tidak ada pilihan, misalnya saat menangani JSON yang strukturnya tidak tentu. Untuk kode sehari-hari, interface dengan kontrak jelas seperti Notifier hampir selalu lebih baik.

Kapan tidak perlu interface

Setelah paham interface, ada godaan untuk membuatnya di mana-mana. Tahan dulu. Interface itu abstraksi, dan abstraksi ada biayanya: kode jadi lebih sulit dilacak karena pembaca harus mencari implementasi aslinya.

Patokan sederhananya:

  • Kalau cuma ada satu implementasi dan belum ada rencana nyata menambah yang lain, pakai struct langsung. Jangan bikin UserServiceInterface hanya karena terlihat rapi.
  • Buat interface saat kebutuhannya muncul: ada implementasi kedua, atau kamu perlu memisahkan kode dari dependensi eksternal.
  • Definisikan interface di sisi pemakai, bukan di sisi implementasi. Ini kebiasaan idiomatik di Go, beda dengan kebiasaan di Java.

Ingat prinsipnya: interface menjawab masalah “banyak cara untuk satu tugas”. Kalau caranya memang cuma satu, tidak ada masalah yang perlu dijawab.

Latihan: sistem pembayaran mini

Sekarang giliran kamu. Kita buat sistem checkout yang menerima beberapa metode pembayaran. Kontraknya satu: bisa membayar sejumlah uang. Aturan validasinya beda per metode. Transfer bank punya minimal nominal, e-wallet dibatasi saldo.

package main

import (
	"errors"
	"fmt"
)

type MetodePembayaran interface {
	Bayar(jumlah float64) error
}

type TransferBank struct {
	NamaBank   string
	NoRekening string
}

func (t TransferBank) Bayar(jumlah float64) error {
	if t.NoRekening == "" {
		return errors.New("nomor rekening belum diisi")
	}
	if jumlah < 10000 {
		return errors.New("minimal transfer bank Rp10.000")
	}
	fmt.Printf("Transfer Rp%.0f via %s berhasil\n", jumlah, t.NamaBank)
	return nil
}

type EWallet struct {
	Provider string
	Saldo    float64
}

func (e EWallet) Bayar(jumlah float64) error {
	if jumlah > e.Saldo {
		return errors.New("saldo " + e.Provider + " tidak cukup")
	}
	fmt.Printf("Bayar Rp%.0f pakai %s berhasil\n", jumlah, e.Provider)
	return nil
}

func Checkout(m MetodePembayaran, total float64) {
	fmt.Printf("Memproses pembayaran Rp%.0f...\n", total)
	err := m.Bayar(total)
	if err != nil {
		fmt.Println("Pembayaran gagal:", err)
		return
	}
	fmt.Println("Pesanan dikonfirmasi")
}

func main() {
	bca := TransferBank{NamaBank: "BCA", NoRekening: "1234567890"}
	dana := EWallet{Provider: "Dana", Saldo: 50000}

	Checkout(bca, 150000)
	Checkout(dana, 35000)
	Checkout(dana, 100000)
	Checkout(TransferBank{NamaBank: "BRI"}, 150000)
}

Jalankan dengan go run main.go. Outputnya:

Memproses pembayaran Rp150000...
Transfer Rp150000 via BCA berhasil
Pesanan dikonfirmasi
Memproses pembayaran Rp35000...
Bayar Rp35000 pakai Dana berhasil
Pesanan dikonfirmasi
Memproses pembayaran Rp100000...
Pembayaran gagal: saldo Dana tidak cukup
Memproses pembayaran Rp150000...
Pembayaran gagal: nomor rekening belum diisi

Amati dua hal. Pertama, Checkout memperlakukan semua metode sama rata lewat kontrak MetodePembayaran, padahal aturan validasi di baliknya berbeda. Kedua, dua kasus gagal ditangani rapi tanpa satu pun if else soal jenis metode di dalam Checkout.

Untuk latihan mandiri, coba tambah KartuKredit dengan validasi limit, lalu masukkan ke pemanggilan Checkout. Kalau kamu tidak perlu mengubah function Checkout sama sekali, berarti kamu sudah paham inti bab ini.

Rangkuman dan bagian selanjutnya

Hari ini kamu belajar bahwa interface adalah kontrak: daftar method yang harus dipenuhi, tanpa implementasi. Go memakai implementasi implisit, jadi type cukup punya method yang cocok. Interface kecil seperti error dan fmt.Stringer adalah gaya idiomatik Go. any ada untuk kasus khusus, dan type assertion adalah pintu keluarnya. Terakhir, jangan bikin interface untuk satu implementasi tanpa alasan.

Di bagian 10 kita masuk ke materi yang membuat Go terkenal: “Goroutine dan Channel: Kenalan dengan Concurrency”. Sampai ketemu di sana.

Kalau bisnismu butuh aplikasi yang dibangun dengan fondasi rapi seperti ini, tim Arrazy menyediakan jasa pengembangan sistem aplikasi dari perancangan sampai perawatan.

BACA JUGA

Artikel Lainnya di Kategori Golang

Ingin Membaca Artikel Lainnya?

Temukan lebih banyak insight dan tips tentang teknologi dan bisnis digital.

Lihat Semua Artikel