Dependency Injection #
Hampir setiap engineer pernah mengalami momen yang sama: diminta memperbaiki sebuah bug di kode yang sudah berumur setahun, dan sebelum bisa menulis satu baris pun harus memahami jaringan dependency yang saling berkaitan seperti benang kusut. ServiceA membuat RepositoryB sendiri di dalam constructor, yang kemudian membuat ClientC secara langsung, yang memerlukan konfigurasi dari os.Getenv yang tersebar di mana-mana. Untuk menulis satu unit test pun harus menyiapkan koneksi database nyata. Dependency Injection adalah pola desain yang langsung menyerang akar masalah ini: objek tidak bertanggung jawab membuat dependency-nya sendiri — dependency itu datang dari luar. Sesederhana itu konsepnya, sebesar itu dampaknya. Artikel ini membahas DI dari masalah konkret yang ia selesaikan, tiga teknik injeksi dengan contoh kode Go, Dart, dan Java, kapan pakai DI manual vs framework, scope lifecycle, hingga anti-pattern yang sering muncul ketika DI diterapkan setengah-setengah.
Apa Itu Dependency Injection? #
Dependency Injection adalah design pattern di mana sebuah objek tidak membuat sendiri dependency yang ia butuhkan, melainkan dependency tersebut diberikan dari luar. Objek hanya mendeklarasikan apa yang ia butuhkan — biasanya melalui interface — dan pihak luar (bisa main(), framework, atau test) yang memutuskan implementasi mana yang diberikan.
// ANTI-PATTERN: UserService membuat dependency sendiri — hidden coupling
type UserService struct{}
func (s *UserService) GetUser(id string) (*User, error) {
repo := NewMySQLUserRepository() // ← dibuat di dalam — tidak bisa ditest
return repo.FindByID(id)
}
// BENAR: dependency diterima dari luar via constructor
type UserService struct {
repo UserRepository // ← interface, bukan implementasi konkret
}
func NewUserService(repo UserRepository) *UserService {
return &UserService{repo: repo}
}
func (s *UserService) GetUser(id string) (*User, error) {
return s.repo.FindByID(id) // tidak tahu MySQL, PostgreSQL, atau in-memory
}
Perubahan dari anti-pattern ke yang benar terlihat kecil, tapi dampaknya sangat besar: UserService kini tidak tahu dan tidak peduli apakah UserRepository-nya menyimpan data di MySQL, PostgreSQL, Redis, atau bahkan array in-memory untuk keperluan test. Siapa yang memanggil NewUserService yang menentukan itu.
flowchart LR
subgraph Before["❌ Sebelum DI — Tight Coupling"]
US1[UserService] -->|"new MySQLRepo()"| R1[MySQLUserRepository]
R1 -->|"os.Getenv(DB_URL)"| DB1["(MySQL)"]
end
subgraph After["✅ Setelah DI — Loose Coupling"]
Caller["main / test"] -->|"inject repo"| US2[UserService]
Caller -->|"buat"| R2["UserRepository\ninterface"]
R2 -.->|"prod: implements"| R3[MySQLUserRepository]
R2 -.->|"test: implements"| R4[InMemoryUserRepository]
endHubungan DI dengan IoC dan SOLID #
DI adalah implementasi paling konkret dari Inversion of Control — jika IoC adalah prinsipnya, DI adalah mekanisme teknisnya. DI juga secara langsung mengimplementasikan Dependency Inversion Principle (DIP), huruf terakhir dari SOLID:
High-level modules should not depend on low-level modules. Both should depend on abstractions.
flowchart TD
subgraph Without["❌ Tanpa DI — High-Level bergantung Low-Level"]
OS1["OrderService\nhigh-level"] -->|depends on directly| R1["MySQLOrderRepository\nlow-level konkret"]
Note1["Ganti MySQL → PostgreSQL\n= ubah OrderService"]
end
subgraph With["✅ Dengan DI — Keduanya bergantung Abstraksi"]
OS2["OrderService\nhigh-level"] -->|depends on| IFACE["OrderRepository\ninterface / abstraksi"]
R2[MySQLOrderRepository] -->|implements| IFACE
R3[PostgresOrderRepository] -->|implements| IFACE
R4[InMemoryOrderRepository] -->|implements| IFACE
Note2["Ganti MySQL → Postgres\n= hanya ubah wiring di main()"]
end| Konsep | Hubungan dengan DI |
|---|---|
| Inversion of Control | DI adalah salah satu cara mengimplementasikan IoC |
| Dependency Inversion Principle | DI adalah implementasi teknis dari DIP |
| Clean Architecture | DI memungkinkan domain tidak tahu implementasi infrastruktur |
| Testability | DI memungkinkan swap implementasi nyata dengan fake/mock |
Tiga Teknik Injeksi #
Ada tiga cara untuk “menyuntikkan” dependency ke sebuah objek. Masing-masing punya karakteristik dan use case yang berbeda.
1. Constructor Injection — Pilihan Utama #
Dependency diberikan saat objek dibuat melalui constructor. Ini adalah teknik yang paling direkomendasikan karena membuat semua dependency terlihat jelas, wajib, dan immutable sejak objek pertama kali dibuat.
// Go — constructor injection
type OrderService struct {
repo OrderRepository // interface
emailer EmailSender // interface
logger Logger // interface
}
func NewOrderService(
repo OrderRepository,
emailer EmailSender,
logger Logger,
) *OrderService {
// Semua dependency terlihat jelas di sini — tidak ada yang tersembunyi
return &OrderService{repo: repo, emailer: emailer, logger: logger}
}
// Go — constructor injection (semua dependency wajib)
type AuthBloc struct {
repository AuthRepository
tokenStorage TokenStorage
analytics AnalyticsService
}
func NewAuthBloc(repository AuthRepository, tokenStorage TokenStorage, analytics AnalyticsService) *AuthBloc {
return &AuthBloc{
repository: repository,
tokenStorage: tokenStorage,
analytics: analytics,
}
}
func (b *AuthBloc) Login(email, password string) error {
token, err := b.repository.Login(email, password)
if err != nil {
return err
}
if err := b.tokenStorage.Save(token); err != nil {
return err
}
b.analytics.Track("user_logged_in", map[string]string{"email": email})
return nil
}
// Testing: inject fake implementation
func main() {
bloc := NewAuthBloc(
FakeAuthRepository(), // ← fake, bukan network
InMemoryTokenStorage(), // ← fake, bukan Hive/SharedPrefs
NoOpAnalytics(), // ← fake, tidak kirim event nyata
)
_ = bloc
}
// Go — constructor injection (pola yang direkomendasikan)
type PaymentService struct {
repo PaymentRepository
notifier NotificationService
}
// Dependency wajib lewat constructor — tidak ada framework magic
func NewPaymentService(repo PaymentRepository, notifier NotificationService) *PaymentService {
return &PaymentService{repo: repo, notifier: notifier}
}
Keunggulan constructor injection:
| Keunggulan | Penjelasan |
|---|---|
| Dependency terlihat jelas | Melihat constructor cukup untuk tahu semua yang dibutuhkan |
| Immutable setelah dibuat | Tidak bisa diganti setelah konstruksi — lebih aman |
| Compiler enforcement | Compiler error jika dependency tidak diberikan |
| Mudah dites | Test tinggal panggil constructor dengan fake implementation |
2. Setter Injection — Untuk Dependency Opsional #
Dependency diberikan setelah objek dibuat melalui setter method. Gunakan ini hanya untuk dependency yang benar-benar opsional — yang punya nilai default yang masuk akal jika tidak diset.
// BENAR: setter injection untuk dependency opsional dengan default
type EmailService struct {
sender EmailSender
logger Logger
rateLimit int
}
func NewEmailService(sender EmailSender, logger Logger) *EmailService {
return &EmailService{
sender: sender,
logger: logger,
rateLimit: 100, // ← default yang masuk akal
}
}
// Setter hanya untuk override default — opsional
func (s *EmailService) SetRateLimit(limit int) {
s.rateLimit = limit
}
// ANTI-PATTERN: setter injection untuk dependency wajib
type UserService struct {
repo UserRepository // wajib, tapi tidak di-enforce lewat constructor
}
func NewUserService() *UserService {
return &UserService{} // repo masih nil — bom waktu!
}
func (s *UserService) SetRepo(repo UserRepository) {
s.repo = repo
}
// Jika caller lupa memanggil SetRepo → s.repo nil → panic saat dipakai
3. Parameter Injection — Untuk Variasi Per-Call #
Dependency diberikan sebagai parameter fungsi, bukan disimpan di struct. Cocok untuk dependency yang berbeda setiap kali fungsi dipanggil, atau untuk operasi satu kali yang tidak perlu disimpan.
// Parameter injection — context dan transaction diinjeksikan per-call
func ProcessRefund(
ctx context.Context, // ← context berbeda setiap request
tx *sql.Tx, // ← transaction berbeda per-operasi
refundRepo RefundRepository,
orderRepo OrderRepository,
refundID string,
) error {
refund, err := refundRepo.FindByID(ctx, refundID)
if err != nil {
return err
}
order, err := orderRepo.FindByID(ctx, refund.OrderID)
if err != nil {
return err
}
return refundRepo.MarkCompleted(ctx, tx, refund, order)
}
context.Context di Go adalah contoh klasik parameter injection — ia membawa deadline, cancellation signal, dan trace ID yang berbeda untuk setiap request, sehingga tidak bisa disimpan di struct sebagai field permanen.
Ringkasan perbandingan tiga teknik:
| Teknik | Kapan Digunakan | Kelebihan | Kekurangan |
|---|---|---|---|
| Constructor | Dependency wajib | Eksplisit, immutable, compiler-safe | — |
| Setter | Dependency opsional dengan default | Fleksibel untuk konfigurasi | Bisa digunakan dalam state tidak valid |
| Parameter | Dependency per-call (context, tx) | Tepat untuk scope terbatas | Verbose jika banyak parameter |
DI Manual vs DI Framework #
Ada dua pendekatan dalam praktik: merakit dependency secara manual di main(), atau menggunakan framework/container yang melakukan wiring otomatis.
DI Manual — Sederhana dan Transparan #
// main.go — semua wiring eksplisit, tidak ada magic
func main() {
// Layer infrastruktur
db, err := sql.Open("postgres", os.Getenv("DATABASE_URL"))
if err != nil { log.Fatal(err) }
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(10)
redisClient := redis.NewClient(&redis.Options{
Addr: os.Getenv("REDIS_URL"),
})
// Layer repository
userRepo := repository.NewUserRepository(db)
orderRepo := repository.NewOrderRepository(db)
cache := cache.NewRedisCache(redisClient)
// Layer service
userService := service.NewUserService(userRepo, cache)
orderService := service.NewOrderService(orderRepo, userService)
// Layer handler
userHandler := handler.NewUserHandler(userService)
orderHandler := handler.NewOrderHandler(orderService)
// Server
router := setupRouter(userHandler, orderHandler)
log.Fatal(http.ListenAndServe(":8080", router))
}
DI dengan Framework/Container #
Untuk sistem besar dengan puluhan atau ratusan komponen:
// Go dengan Uber FX — framework DI berbasis functional options
func main() {
fx.New(
fx.Provide(
database.NewPostgresDB, // provide *sql.DB
cache.NewRedisClient, // provide *redis.Client
repository.NewUserRepo, // butuh *sql.DB, provide UserRepository
repository.NewOrderRepo, // butuh *sql.DB, provide OrderRepository
cache.NewRedisCache, // butuh *redis.Client, provide CacheStore
service.NewUserService, // butuh UserRepository + CacheStore
service.NewOrderService, // butuh OrderRepository + UserService
handler.NewUserHandler, // butuh UserService
handler.NewOrderHandler, // butuh OrderService
),
fx.Invoke(startHTTPServer),
).Run()
// FX otomatis resolve semua dependency berdasarkan type signature
// Tidak perlu tulis urutan manual
}
// Go — service locator tidak umum; pola terdekat adalah registry map
// (wiring manual di composition root tetap lebih transparan)
var registry = map[string]any{}
func SetupDependencies() {
// Infrastruktur
registry["httpClient"] = NewHTTPClient(Env.APIURL)
registry["storage"] = NewHiveStorage()
// Repository
registry["authRepository"] = NewAuthRepositoryImpl(registry["httpClient"].(*HTTPClient))
registry["userRepository"] = NewUserRepositoryImpl(
registry["httpClient"].(*HTTPClient),
registry["storage"].(*HiveStorage),
)
// BLoC / Service — factory: instance baru setiap kali diambil
registry["authBlocFactory"] = func() *AuthBloc {
return NewAuthBloc(
registry["authRepository"].(AuthRepository),
registry["storage"].(TokenStorage),
)
}
}
flowchart TD
subgraph Manual["DI Manual"]
M1[main.go] --> M2["Inisialisasi infra\nDB, Redis, HTTP"]
M2 --> M3["Inisialisasi repo\npakai infra"]
M3 --> M4["Inisialisasi service\npakai repo"]
M4 --> M5["Inisialisasi handler\npakai service"]
M5 --> M6[Start server]
end
subgraph Framework["DI Framework / Container"]
F1["Daftarkan semua\nprovider"] --> F2["Container resolve\ndependency otomatis"]
F2 --> F3["Lifecycle hooks\nstartup / shutdown"]
F3 --> F4[Run]
end| Aspek | DI Manual | DI Framework |
|---|---|---|
| Transparansi | Sangat eksplisit, mudah trace | Ada “magic”, perlu belajar framework |
| Boilerplate | Bertambah seiring sistem besar | Minimal, auto-resolve |
| Lifecycle | Manual (defer close) | Built-in startup/shutdown hooks |
| Cocok untuk | Sistem kecil-menengah | Sistem besar (50+ komponen) |
| Error detection | Compile time | Runtime (beberapa framework) |
DI framework yang menggunakan reflection (seperti beberapa framework Java/PHP) mendeteksi kesalahan wiring saat runtime, bukan compile time. Ini berarti konfigurasi yang salah baru ketahuan saat aplikasi dijalankan. Untuk Go, lebih baik menggunakan code generation (Wire) atau functional type matching (Uber FX) yang error-nya terdeteksi lebih awal.
DI dan Testability — Dampak Paling Nyata #
Manfaat DI yang paling langsung dirasakan dalam kehidupan sehari-hari adalah kemampuan menulis unit test yang cepat, terisolasi, dan deterministik — tanpa memerlukan database nyata, SMTP server, atau third-party API.
// Interface yang sama diimplementasikan oleh produksi dan fake
type UserRepository interface {
Save(ctx context.Context, user *User) error
FindByEmail(ctx context.Context, email string) (*User, error)
}
// Produksi: pakai PostgreSQL
type PostgresUserRepository struct{ db *sql.DB }
func (r *PostgresUserRepository) Save(ctx context.Context, user *User) error {
_, err := r.db.ExecContext(ctx,
"INSERT INTO users (id, email, name) VALUES ($1, $2, $3)",
user.ID, user.Email, user.Name)
return err
}
// Test: in-memory, tidak butuh infrastruktur apapun
type InMemoryUserRepository struct {
users map[string]*User
mu sync.RWMutex
}
func NewInMemoryUserRepository() *InMemoryUserRepository {
return &InMemoryUserRepository{users: make(map[string]*User)}
}
func (r *InMemoryUserRepository) Save(_ context.Context, user *User) error {
r.mu.Lock()
defer r.mu.Unlock()
r.users[user.Email] = user
return nil
}
func (r *InMemoryUserRepository) FindByEmail(_ context.Context, email string) (*User, error) {
r.mu.RLock()
defer r.mu.RUnlock()
if user, ok := r.users[email]; ok {
return user, nil
}
return nil, ErrUserNotFound
}
// Test berjalan dalam milidetik, tidak butuh Docker/database
func TestRegisterUser_EmailAlreadyExists(t *testing.T) {
repo := NewInMemoryUserRepository()
emailer := &FakeEmailSender{}
svc := NewUserService(repo, emailer)
// Setup: user sudah ada
repo.Save(context.Background(), &User{
ID: "existing-1", Email: "[email protected]",
})
// Test: registrasi dengan email yang sama harus gagal
_, err := svc.Register(context.Background(), RegisterRequest{
Email: "[email protected]",
Name: "Someone Else",
})
assert.ErrorIs(t, err, ErrEmailAlreadyExists)
assert.Empty(t, emailer.SentEmails) // tidak ada email yang dikirim
}
func TestRegisterUser_Success(t *testing.T) {
repo := NewInMemoryUserRepository()
emailer := &FakeEmailSender{}
svc := NewUserService(repo, emailer)
user, err := svc.Register(context.Background(), RegisterRequest{
Email: "[email protected]",
Name: "Unis Badri",
})
assert.NoError(t, err)
assert.NotEmpty(t, user.ID)
assert.Len(t, emailer.SentEmails, 1) // welcome email terkirim
}
flowchart LR
subgraph WithDI["Dengan DI — Unit Test Murni"]
T1[Test] -->|inject| FAKE["InMemoryRepo\nFakeEmailer"]
T1 -->|test| SVC[UserService]
SVC --> FAKE
T1 --> RESULT["✓ Cepat < 1ms\n✓ Deterministic\n✓ No infra"]
end
subgraph WithoutDI["Tanpa DI — Integration Test Terpaksa"]
T2[Test] --> SVC2[UserService]
SVC2 -->|butuh| DB[("(MySQL\nrunning)")]
SVC2 -->|butuh| SMTP["SMTP Server\nrunning"]
T2 --> RESULT2["✗ Lambat\n✗ Flaky\n✗ Butuh infra"]
endScope Lifecycle dalam DI #
Satu hal yang sering dilupakan saat merancang DI adalah scope — berapa lama sebuah dependency hidup dan kapan instance baru dibuat.
| Scope | Deskripsi | Contoh | Dibuat |
|---|---|---|---|
| Singleton | Satu instance seumur hidup app | DB pool, logger, config | Saat startup |
| Transient | Instance baru setiap kali dibutuhkan | Command handler, DTO builder | Setiap inject |
| Scoped | Satu instance per unit kerja | HTTP request context, DB transaction | Per request/job |
// ANTI-PATTERN: buat koneksi DB setiap request — sangat mahal dan lambat
func handleGetUser(w http.ResponseWriter, r *http.Request) {
db, _ := sql.Open("postgres", os.Getenv("DATABASE_URL")) // ← boros!
defer db.Close()
repo := repository.NewUserRepository(db)
// ...
}
// BENAR: singleton — connection pool dibuat sekali, dibagikan ke semua handler
func main() {
db, _ := sql.Open("postgres", os.Getenv("DATABASE_URL"))
db.SetMaxOpenConns(25) // max 25 koneksi concurrent
db.SetMaxIdleConns(10) // min 10 koneksi siap pakai
// Singleton: dibuat sekali, digunakan sepanjang lifetime aplikasi
repo := repository.NewUserRepository(db) // singleton
service := service.NewUserService(repo) // singleton
handler := handler.NewUserHandler(service) // singleton
http.HandleFunc("/users", handler.GetUser)
http.ListenAndServe(":8080", nil)
}
// Go — scope dengan composition root manual
type Container struct {
apiClient *ApiClient
authRepo AuthRepository
}
func NewContainer() *Container {
// Singleton: satu instance seumur lifetime app
c := &Container{apiClient: NewApiClient(Env.APIURL)}
// LazySingleton: di Go cukup dibuat sekali di sini
c.authRepo = NewAuthRepositoryImpl(c.apiClient)
return c
}
// Factory: instance baru setiap kali dipanggil
// Cocok untuk BLoC yang di-dispose dan di-recreate per screen
func (c *Container) NewAuthBloc() *AuthBloc {
return NewAuthBloc(c.authRepo)
}
Anti-Pattern yang Harus Dihindari #
// ✗ Membuat dependency di dalam method — IoC dilanggar kembali
func (s *OrderService) CreateOrder(req Request) (*Order, error) {
repo := &MySQLOrderRepository{db: globalDB} // ← tight coupling kembali!
return repo.Save(buildOrder(req))
}
// ✓ Gunakan s.repo yang sudah diinjeksikan via constructor
// ✗ Global variable sebagai dependency — tidak bisa di-mock, race condition
var globalDB *sql.DB
func init() {
globalDB, _ = sql.Open("postgres", os.Getenv("DATABASE_URL"))
}
// ✓ Inject *sql.DB via constructor, bukan global
// ✗ God constructor — tanda service melakukan terlalu banyak hal
func NewOrderService(
repo OrderRepository,
userRepo UserRepository,
productRepo ProductRepository,
inventoryRepo InventoryRepository,
paymentGW PaymentGateway,
emailer EmailSender,
sms SMSSender,
push PushNotifier,
logger Logger,
cache CacheStore,
config *Config,
bus EventBus,
) *OrderService { ... }
// 12 dependency = OrderService melakukan terlalu banyak → pecah menjadi service lebih kecil
// ✓ Jika > 4-5 dependency, evaluasi apakah SRP sudah terpenuhi
// ✗ Inject concrete type bukan interface
func NewOrderService(repo *MySQLOrderRepository) *OrderService { ... }
// ✓ Inject interface
func NewOrderService(repo OrderRepository) *OrderService { ... }
// ✗ Setter injection untuk dependency wajib — bisa digunakan dalam state invalid
type UserService struct{ repo UserRepository }
func NewUserService() *UserService { return &UserService{} } // repo = nil!
func (s *UserService) SetRepo(r UserRepository) { s.repo = r }
// ✓ Constructor injection untuk dependency wajib
func NewUserService(repo UserRepository) *UserService {
return &UserService{repo: repo} // compiler error jika tidak ada repo
}
// ✗ Inject IoC container ke dalam service — service tahu tentang container
type OrderService struct { container *Container }
func (s *OrderService) CreateOrder(req Request) error {
repo := s.container.Get("orderRepo").(OrderRepository) // hidden dependency!
...
}
// ✓ Inject dependency langsung, bukan container
Checklist Implementasi DI #
DESAIN INTERFACE:
□ Setiap dependency dideklarasikan sebagai interface di sisi consumer
□ Interface minimal — hanya method yang benar-benar digunakan consumer
□ Interface tidak bocorkan detail implementasi (tidak ada *sql.DB di interface)
CONSTRUCTOR INJECTION:
□ Semua dependency wajib di-inject via constructor
□ Constructor tidak membuat dependency sendiri (tidak ada `new`, `Open`, dll)
□ Dependency yang opsional punya default yang masuk akal
WIRING:
□ Semua wiring terpusat di satu tempat (main.go, wire.go, module.go)
□ Tidak ada global variable yang digunakan sebagai dependency
□ Scope lifecycle setiap dependency sudah dipertimbangkan
TESTING:
□ Setiap interface punya fake/mock implementation untuk testing
□ Unit test tidak butuh koneksi DB, SMTP, atau network
□ Test bisa dijalankan secara paralel tanpa race condition
SCOPE:
□ Database connection pool sebagai singleton
□ BLoC/Command handler sebagai factory (instance baru per penggunaan)
□ Request-scoped data dilewatkan via context, bukan disimpan di struct
Ringkasan #
- Dependency Injection adalah pattern di mana objek menerima dependency dari luar — memindahkan tanggung jawab konstruksi ke caller, bukan ke objek itu sendiri.
- Tiga teknik injeksi: constructor injection (dependency wajib, eksplisit, immutable — pilihan utama), setter injection (dependency opsional dengan default yang masuk akal), parameter injection (dependency per-call seperti
context.Contextdan*sql.Tx).- Constructor injection paling direkomendasikan — dependency terlihat jelas dari signature, bersifat immutable, dan compiler langsung error jika ada yang tidak diberikan.
- DI adalah implementasi DIP dari SOLID — high-level module bergantung pada abstraksi (interface), bukan implementasi konkret; ganti implementasi cukup di wiring, bukan di konsumen.
- Dampak paling nyata adalah testability — dengan DI, setiap dependency bisa diganti fake/in-memory yang berjalan dalam milidetik tanpa database atau network.
- DI manual (wiring di
main()) cukup dan lebih transparan untuk sistem kecil-menengah; DI framework (Uber FX, Wire, Spring, get_it) lebih cocok untuk sistem besar dengan banyak komponen.- Perhatikan scope lifecycle: database connection pool wajib singleton, BLoC per-screen sebaiknya factory, request-scoped object lewat context.
- God constructor (8+ dependency) adalah code smell kuat — tanda komponen melakukan terlalu banyak hal dan perlu dipecah.
- Inject interface, bukan concrete type — satu-satunya cara memungkinkan swap implementasi tanpa mengubah semua konsumennya.
← Sebelumnya: Inversion of Control Berikutnya: Aspect Oriented Programming →