Dependency injection tanpa framework
Di Go, dependency injection biasanya berarti "oper lewat parameter konstruktor". Tidak ada anotasi, tidak ada container, tidak ada resolusi saat runtime — dan justru itu yang membuat kesalahan perakitan ketahuan saat kompilasi.
Intisari
- Bangun semuanya di
main, oper ke bawah lewat konstruktor. Itu sudah DI. - Jangan pakai variabel global untuk
*sql.DB, logger, atau konfigurasi — itu dependensi tersembunyi yang mematikan testabilitas. - Terima interface, kembalikan struct. Interface dideklarasikan di sisi pemakai (Fase 1).
init()dansync.Onceuntuk membangun dependensi adalah bentuk lain dari global — hindari.- Wire menghasilkan kode perakitan saat kompilasi kalau
main-mu sudah terlalu panjang; ia bukan container runtime.
Bentuk yang dipakai
// cmd/server/main.go — SATU-SATUNYA tempat yang tahu semua bagian
func jalankan(ctx context.Context) error {
cfg, err := config.Muat()
if err != nil {
return err
}
log := slog.New(slog.NewJSONHandler(os.Stdout,
&slog.HandlerOptions{Level: cfg.LevelLog}))
pool, err := platform.BukaPool(ctx, cfg.DSN)
if err != nil {
return err
}
defer pool.Close()
rdb := platform.BukaRedis(cfg.Redis)
defer rdb.Close()
// Infrastruktur → domain → transport. Arahnya selalu ke atas.
produkRepo := postgres.NewProdukRepo(pool)
produkCache := cache.NewRedis(rdb, "produk", time.Minute)
produkSvc := produk.NewLayanan(produkRepo, produkCache, log)
pesananRepo := postgres.NewPesananRepo(pool)
pesananSvc := pesanan.NewLayanan(pesananRepo, produkSvc, log)
h := httpapi.New(produkSvc, pesananSvc, log)
srv := &http.Server{Addr: cfg.Addr, Handler: h.Rute() /* timeout... */}
return sajikan(ctx, srv, log)
}
func main() {
ctx, hentikan := signal.NotifyContext(context.Background(),
os.Interrupt, syscall.SIGTERM)
defer hentikan()
if err := jalankan(ctx); err != nil {
slog.Error("aplikasi berhenti", "err", err)
os.Exit(1)
}
}
Pola main tipis + jalankan(ctx) error layak ditiru. Ia memberi satu
tempat untuk defer yang benar-benar berjalan (os.Exit di dalam
jalankan akan melewati semua defer), dan ia membuat seluruh perakitan bisa
dipanggil dari tes integrasi.
Kenapa bukan global
// ❌ Terlihat praktis, dan menghancurkan tiga hal sekaligus
var DB *sql.DB
func init() {
DB, _ = sql.Open("pgx", os.Getenv("DATABASE_URL"))
}
func AmbilProduk(id int64) (Produk, error) {
return DB.QueryRow(...) // dependensi tak terlihat dari tanda tangan
}
| Yang hilang | Akibatnya |
|---|---|
| Testabilitas | Tidak bisa mengganti database di tes tanpa menyentuh global |
| Kejelasan | Tanda tangan fungsi berbohong: ia menyentuh database, dan tidak terlihat |
| Kendali urutan | init() berjalan sebelum main; kesalahan konfigurasi jadi crash tanpa konteks |
| Kendali umur | Tidak ada tempat untuk Close() |
| Beberapa instans | Tidak bisa punya pool tulis dan pool baca terpisah |
init() layak dipakai untuk hal yang benar-benar statis — mendaftarkan driver,
mengompilasi regex konstan. Ia tidak layak untuk apa pun yang menyentuh jaringan, membaca
environment, atau bisa gagal.
Bergantung pada perilaku, bukan pada implementasi
// internal/pesanan/layanan.go — pesanan butuh SEDIKIT dari produk
type Katalog interface {
Ambil(ctx context.Context, id int64) (produk.Produk, error)
}
type Layanan struct {
simpan Penyimpan
katalog Katalog // bukan *produk.Layanan
jam func() time.Time
}
func NewLayanan(s Penyimpan, k Katalog, opsi ...Opsi) *Layanan {
l := &Layanan{simpan: s, katalog: k, jam: time.Now}
for _, o := range opsi {
o(l)
}
return l
}
Perhatikan field jam func() time.Time. Waktu adalah dependensi tersembunyi yang
paling sering dilupakan — dan penyebab utama tes yang flaky. Menyuntikkannya membuat "apa yang
terjadi tanggal 1" bisa diuji tanpa mengubah jam sistem. Hal yang sama berlaku untuk pembangkit UUID dan
angka acak.
Functional options untuk yang opsional
type Opsi func(*Layanan)
func DenganJam(f func() time.Time) Opsi {
return func(l *Layanan) { l.jam = f }
}
func DenganBatasRetry(n int) Opsi {
return func(l *Layanan) { l.retry = n }
}
// Wajib jadi parameter; opsional jadi opsi.
svc := pesanan.NewLayanan(repo, katalog,
pesanan.DenganBatasRetry(5))
Go tidak punya parameter opsional maupun bernama, dan konstruktor dengan tujuh parameter cepat jadi tidak terbaca. Pola ini memberi keduanya, sambil menjaga dependensi wajib tetap wajib — sesuatu yang tidak bisa dilakukan struct konfigurasi biasa.
Kalau main sudah terlalu panjang
// Kelompokkan perakitan per area, tetap di satu berkas
type Aplikasi struct {
Cfg config.Config
Log *slog.Logger
Pool *pgxpool.Pool
Produk *produk.Layanan
Pesanan *pesanan.Layanan
}
func Rakit(ctx context.Context, cfg config.Config) (*Aplikasi, func(), error) {
...
tutup := func() {
pool.Close()
rdb.Close()
}
return &Aplikasi{...}, tutup, nil
}
Wire (sumber materi ini) menghasilkan fungsi Rakit itu untukmu, dari deklarasi
penyedia — saat kompilasi, bukan saat runtime. Keluarannya kode Go biasa yang bisa kamu baca dan
langkahi di debugger, dan graf dependensi yang salah jadi error kompilasi. Ia mulai berguna sekitar
20–30 dependensi; di bawah itu, perakitan manual lebih jelas.
Yang sebaiknya tidak dipakai: container DI berbasis refleksi. Ia memindahkan kesalahan perakitan dari kompilasi ke runtime — persis kebalikan dari yang membuat Go layak dipakai untuk sistem besar.
Ringkasan aturan
- Semua dependensi lewat konstruktor. Nol variabel paket yang bisa berubah.
- Terima interface kecil, kembalikan struct konkret.
- Interface dideklarasikan oleh pemakai, hanya berisi method yang benar-benar dipanggil.
- Suntikkan waktu, keacakan, dan ID — semuanya sumber tes yang flaky.
- Satu composition root, di
cmd/<binari>/main.go. - Kembalikan fungsi penutup bersama objeknya, supaya
mainbisadefer.
Latihan: ambil kode yang memakai time.Now() di dalam logika bisnis dan ubah jadi
field jam func() time.Time. Tulis satu tes yang menyuntikkan jam tetap dan membuktikan
perilaku "kedaluwarsa setelah 7 hari" tanpa satu pun time.Sleep.
Rangkuman ini sengaja dipangkas ke bagian yang dipakai di roadmap. Buka sumber aslinya saat kamu butuh detail lengkap atau referensi parameter.