Lapisan sudah tersusun dan dependensi sudah menunjuk ke dalam, tetapi satu pertanyaan mekanis masih terbuka: siapa yang membuat objek-objek ini dan menghubungkannya? Dependency injection (DI) menjawabnya dengan prinsip sederhana: sebuah struct menerima dependency yang dibutuhkan dari luar, bukan membuatnya sendiri. Di Go, prinsip ini diwujudkan lewat constructor biasa — tanpa framework, tanpa container, tanpa anotasi. Pada chapter ini kita praktikkan constructor injection, menempatkan perakitan di composition root, mengelola lifecycle seperti koneksi yang harus ditutup, dan melihat mengapa interface sebaiknya didefinisikan di sisi pemakainya.

Constructor Injection ala Go #

Bentuk idiomatik DI di Go hanyalah constructor yang menerima dependency sebagai parameter:

go
func NewLayananPesanan(p PemberiTahu) *LayananPesanan {
	return &LayananPesanan{pemberiTahu: p}
}

Struct LayananPesanan tidak membangun pengirim email sendiri dan tidak mencarinya dari variabel global. Struct tersebut hanya menyatakan kebutuhan lewat parameter constructor, lalu pemanggil — biasanya main — yang memutuskan implementasinya. Tempat keputusan perakitan ini disebut composition root: satu lokasi di main.go yang mengenal semua implementasi konkret dan menyambungkannya. Membaca main yang baik terasa seperti membaca diagram perakitan; seluruh bentuk aplikasi terlihat dari urutan pemanggilan constructor.

Interface di Sisi Pemakai #

Keputusan penting kedua: interface PemberiTahu didefinisikan di package yang memakainya (service), bukan di package implementasinya. Pendekatan consumer-side interface ini khas Go dan sudah kita bahas tuntas pada chapter Kecilnya Interface . Service hanya mendeklarasikan method yang benar-benar dipanggilnya — satu method Beritahu — sehingga implementasi baru cukup menyediakan perilaku itu, tidak peduli tipenya apa.

Diagram di atas memperlihatkan struktur yang sehat: main mengenal semuanya, service hanya mengenal interface, dan dua implementasi saling tidak mengenal.

Demo: Dua Mode dalam Satu Binary #

Berkas di-demo.go menjalankan layanan pesanan yang sama dengan dua dependency berbeda. Mode produksi memakai EmailSMTP yang perlu ditutup, mode uji memakai AntreanMemori tanpa jaringan sama sekali:

go
package main

import "fmt"

// Service mendefinisikan interface yang DIBUTUHKANNYA (consumer side).

type PemberiTahu interface {
	Beritahu(penerima, pesan string) error
}

type LayananPesanan struct {
	pemberiTahu PemberiTahu
}

func NewLayananPesanan(p PemberiTahu) *LayananPesanan {
	return &LayananPesanan{pemberiTahu: p}
}

func (l *LayananPesanan) Konfirmasi(penerima, nomor string) error {
	pesan := fmt.Sprintf("Pesanan %s sudah dikonfirmasi.", nomor)
	return l.pemberiTahu.Beritahu(penerima, pesan)
}

// Implementasi produksi: "email" (simulasi cetak) dengan lifecycle.

type EmailSMTP struct {
	total int
}

func (e *EmailSMTP) Beritahu(penerima, pesan string) error {
	e.total++
	fmt.Printf("[email] ke %s: %s\n", penerima, pesan)
	return nil
}

func (e *EmailSMTP) Close() {
	fmt.Printf("[email] koneksi ditutup, %d pesan terkirim\n", e.total)
}

// Implementasi uji: antrean di memori, tanpa jaringan.

type AntreanMemori struct {
	pesan []string
}

func (a *AntreanMemori) Beritahu(penerima, pesan string) error {
	a.pesan = append(a.pesan, penerima+" | "+pesan)
	return nil
}

// main adalah composition root: satu-satunya tempat perakitan.

func main() {
	fmt.Println("== mode produksi: email ==")
	jalankanProduksi()

	fmt.Println()
	fmt.Println("== mode uji: antrean memori ==")
	antrean := &AntreanMemori{}
	layanan := NewLayananPesanan(antrean)
	if err := layanan.Konfirmasi("[email protected]", "PSN-103"); err != nil {
		fmt.Println("gagal:", err)
	}
	for _, p := range antrean.pesan {
		fmt.Println("[antrean]", p)
	}
}

func jalankanProduksi() {
	email := &EmailSMTP{}
	defer email.Close() // composition root memegang lifecycle
	layanan := NewLayananPesanan(email)
	if err := layanan.Konfirmasi("[email protected]", "PSN-101"); err != nil {
		fmt.Println("gagal:", err)
	}
	if err := layanan.Konfirmasi("[email protected]", "PSN-102"); err != nil {
		fmt.Println("gagal:", err)
	}
}

Jalankan program dengan perintah berikut dari direktori chapter:

bash
go run di-demo.go

Output yang Dihasilkan:

text
== mode produksi: email ==
[email] ke [email protected]: Pesanan PSN-101 sudah dikonfirmasi.
[email] ke [email protected]: Pesanan PSN-102 sudah dikonfirmasi.
[email] koneksi ditutup, 2 pesan terkirim

== mode uji: antrean memori ==
[antrean] [email protected] | Pesanan PSN-103 sudah dikonfirmasi.

Mari kita telaah bagian-bagian penting program di atas:

  1. Baris 7-9 (type PemberiTahu interface ...): interface dideklarasikan tepat di atas struct pemakainya. Kebutuhan service menjadi kontrak yang dibaca sekilas, bukan tersebar di package implementasi.
  2. Baris 15-17 (func NewLayananPesanan ...): constructor injection. Tidak ada flag, tidak ada kondisi if produksi; pemilihan implementasi terjadi sepenuhnya di pemanggil.
  3. Baris 36-38 (func (e *EmailSMTP) Close ...): dependency yang membuka sumber daya menyediakan cara menutupnya. Perhatikan bahwa LayananPesanan tidak mengenal Close; layanan tidak boleh tahu detail lifecycle dependency-nya.
  4. Baris 59-60 (antrean := &AntreanMemori{} ...): mode uji merakit ulang aplikasi dengan fake di memori. Inilah alasan test service tidak perlu server SMTP — cukup struct kecil yang mengimplementasikan satu method.
  5. Baris 69-71 (func jalankanProduksi ... defer email.Close()): lifecycle dimulai dan diakhiri di composition root. defer memastikan koneksi tertutup meskipun pemanggilan konfirmasi di tengah jalan mengembalikan error.

Tanpa Framework, Tanpa Singleton #

Sebagian ekosistem memakai framework DI reflektif ala Spring; Go justru memilih arah sebaliknya. Wiring manual di main eksplisit, bisa dilacak dengan editor biasa, dan error perakitan tertangkap saat kompilasi. Bila jumlah constructor di main membengkak, ada solusi codegen seperti google/wire yang menghasilkan fungsi perakitan dari daftar provider — hasil akhirnya tetap kode Go biasa, hanya ditulis otomatis.

Kebalikan dari DI yang sehat adalah singleton global:

go
var pemberiTahuGlobal *EmailSMTP // dibaca seluruh package: sulit diuji

func init() {
	pemberiTahuGlobal = &EmailSMTP{}
}

Singleton menyembunyikan dependensi di balik variabel package, membuat test tidak bisa mengganti implementasi tanpa menyentuh kode produksi, dan membuka pelombongan data race saat diakses goroutine bersamaan. Bila Anda tergoda menambah variabel var db *sql.DB global, tunda dulu: deklarasikan field di struct dan suntikkan lewat constructor.

Dengan perakitan yang rapi, aplikasi tinggal satu kebutuhan agar bisa berjalan di banyak lingkungan: nilai konfigurasi yang dimuat dengan aman dan tervalidasi. Chapter berikutnya membahas configuration management dari environment variable sampai perlindungan secret.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

di-demo.go

Unduh berkas
go
package main

import "fmt"

// Service mendefinisikan interface yang DIBUTUHKANNYA (consumer side).

type PemberiTahu interface {
	Beritahu(penerima, pesan string) error
}

type LayananPesanan struct {
	pemberiTahu PemberiTahu
}

func NewLayananPesanan(p PemberiTahu) *LayananPesanan {
	return &LayananPesanan{pemberiTahu: p}
}

func (l *LayananPesanan) Konfirmasi(penerima, nomor string) error {
	pesan := fmt.Sprintf("Pesanan %s sudah dikonfirmasi.", nomor)
	return l.pemberiTahu.Beritahu(penerima, pesan)
}

// Implementasi produksi: "email" (simulasi cetak) dengan lifecycle.

type EmailSMTP struct {
	total int
}

func (e *EmailSMTP) Beritahu(penerima, pesan string) error {
	e.total++
	fmt.Printf("[email] ke %s: %s\n", penerima, pesan)
	return nil
}

func (e *EmailSMTP) Close() {
	fmt.Printf("[email] koneksi ditutup, %d pesan terkirim\n", e.total)
}

// Implementasi uji: antrean di memori, tanpa jaringan.

type AntreanMemori struct {
	pesan []string
}

func (a *AntreanMemori) Beritahu(penerima, pesan string) error {
	a.pesan = append(a.pesan, penerima+" | "+pesan)
	return nil
}

// main adalah composition root: satu-satunya tempat perakitan.

func main() {
	fmt.Println("== mode produksi: email ==")
	jalankanProduksi()

	fmt.Println()
	fmt.Println("== mode uji: antrean memori ==")
	antrean := &AntreanMemori{}
	layanan := NewLayananPesanan(antrean)
	if err := layanan.Konfirmasi("[email protected]", "PSN-103"); err != nil {
		fmt.Println("gagal:", err)
	}
	for _, p := range antrean.pesan {
		fmt.Println("[antrean]", p)
	}
}

func jalankanProduksi() {
	email := &EmailSMTP{}
	defer email.Close() // composition root memegang lifecycle
	layanan := NewLayananPesanan(email)
	if err := layanan.Konfirmasi("[email protected]", "PSN-101"); err != nil {
		fmt.Println("gagal:", err)
	}
	if err := layanan.Konfirmasi("[email protected]", "PSN-102"); err != nil {
		fmt.Println("gagal:", err)
	}
}