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. Panduan ini membahas DI dari masalah konkret yang ia selesaikan, tiga teknik injeksi dengan contoh kode multi-bahasa, kapan pakai DI manual vs framework, hingga anti-pattern yang sering muncul ketika DI diterapkan tanpa pemahaman yang cukup.
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.
Perbedaannya paling mudah dilihat dari perubahan kode yang paling kecil sekalipun:
// ANTI-PATTERN: UserService membuat dependency sendiri
// Akibatnya: tidak bisa dites tanpa DB, tidak bisa diganti implementasi
type UserService struct{}
func (s *UserService) GetUser(id string) (*User, error) {
repo := NewMySQLUserRepository() // ← dibuat di sini, di dalam
return repo.FindByID(id)
}
// BENAR: dependency diterima dari luar via constructor
// Akibatnya: bisa inject mock untuk test, bisa ganti implementasi bebas
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)
}
Perubahan dari anti-pattern ke yang benar terlihat kecil, tapi dampaknya besar: UserService kini tidak tahu apakah UserRepository-nya menyimpan data di MySQL, PostgreSQL, Redis, atau bahkan array in-memory untuk keperluan test. Siapa yang memanggil NewUserService yang menentukan itu.
Hubungan 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.
Tanpa DI — high-level bergantung pada low-level:
OrderService (high-level)
│ depends on (hardcoded)
▼
MySQLOrderRepository (low-level konkret)
Jika MySQL diganti PostgreSQL → OrderService harus diubah.
Dengan DI — keduanya bergantung pada abstraksi:
OrderService (high-level)
│ depends on
▼
OrderRepository (interface/abstraksi)
▲
│ implements
MySQLOrderRepository | PostgresOrderRepository | InMemoryOrderRepository
(semua implementasi bisa dipakai tanpa mengubah OrderService)
Ini juga yang menjelaskan mengapa DI adalah fondasi dari Clean Architecture dan Hexagonal Architecture — layer dalam (domain/business) tidak boleh bergantung pada layer luar (infrastruktur), dan DI adalah mekanisme yang memungkinkan itu.
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 dan wajib — kamu tidak bisa membuat objek tanpa semua dependency-nya siap.
// Go — constructor injection
type OrderService struct {
repo OrderRepository
emailer EmailSender
logger Logger
}
func NewOrderService(
repo OrderRepository,
emailer EmailSender,
logger Logger,
) *OrderService {
// Tidak ada yang tersembunyi — semua dependency visible di sini
return &OrderService{repo: repo, emailer: emailer, logger: logger}
}
// Dart/Flutter — constructor injection dengan named parameters
class AuthBloc {
final AuthRepository _repository;
final TokenStorage _tokenStorage;
final AnalyticsService _analytics;
AuthBloc({
required AuthRepository repository,
required TokenStorage tokenStorage,
required AnalyticsService analytics,
}) : _repository = repository,
_tokenStorage = tokenStorage,
_analytics = analytics;
Future<void> login(String email, String password) async {
final token = await _repository.login(email, password);
await _tokenStorage.save(token);
_analytics.track('user_logged_in', {'email': email});
}
}
// Java/Spring — constructor injection (cara yang direkomendasikan Spring sejak v4)
@Service
public class PaymentService {
private final PaymentRepository repo;
private final NotificationService notifier;
// Spring otomatis inject implementasi yang sesuai saat startup
public PaymentService(PaymentRepository repo, NotificationService notifier) {
this.repo = repo;
this.notifier = notifier;
}
}
Keunggulan constructor injection: dependency bersifat immutable (tidak bisa diganti setelah objek dibuat), compiler langsung complain jika ada dependency yang tidak diberikan, dan melihat constructor sudah cukup untuk memahami apa saja yang dibutuhkan objek ini.
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 // opsional, punya default
}
func NewEmailService(sender EmailSender, logger Logger) *EmailService {
return &EmailService{
sender: sender,
logger: logger,
rateLimit: 100, // ← default yang masuk akal
}
}
// Opsional — bisa diset jika perlu override default
func (s *EmailService) SetRateLimit(limit int) {
s.rateLimit = limit
}
// ANTI-PATTERN: setter injection untuk dependency wajib
// — objek bisa digunakan dalam keadaan tidak valid
type UserService struct {
repo UserRepository // wajib, tapi tidak di-enforce
}
func NewUserService() *UserService {
return &UserService{} // repo adalah nil — siap meledak
}
func (s *UserService) SetRepo(repo UserRepository) {
s.repo = repo
}
// Kalau 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 — konteks dan logger sering diinjeksikan per-call
func ProcessRefund(
ctx context.Context, // ← context diinjeksikan setiap call
db *sql.Tx, // ← transaction diinjeksikan per-operasi
refundRepo RefundRepository,
orderRepo OrderRepository,
refundID string,
) error {
refund, err := refundRepo.FindByID(ctx, refundID)
if err != nil {
return err
}
// proses refund menggunakan tx yang diinjeksikan
return refundRepo.MarkCompleted(ctx, db, refund)
}
Konteks (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.
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 dilakukan secara eksplisit
func main() {
// Inisialisasi infrastruktur
db, err := sql.Open("postgres", os.Getenv("DATABASE_URL"))
if err != nil {
log.Fatal(err)
}
redisClient := redis.NewClient(&redis.Options{
Addr: os.Getenv("REDIS_URL"),
})
// Inisialisasi layer repository
userRepo := repository.NewUserRepository(db)
orderRepo := repository.NewOrderRepository(db)
cacheStore := cache.NewRedisCache(redisClient)
// Inisialisasi layer service dengan dependency
userService := service.NewUserService(userRepo, cacheStore)
orderService := service.NewOrderService(orderRepo, userService)
// Inisialisasi layer handler
userHandler := handler.NewUserHandler(userService)
orderHandler := handler.NewOrderHandler(orderService)
// Jalankan server
router := setupRouter(userHandler, orderHandler)
log.Fatal(http.ListenAndServe(":8080", router))
}
Keunggulan DI manual: tidak ada magic, semuanya eksplisit, mudah di-debug, tidak butuh belajar framework baru. Kelemahannya: seiring sistem berkembang, main() bisa menjadi sangat panjang dan repetitif.
DI dengan Framework/Container #
Untuk sistem besar dengan puluhan atau ratusan dependency, framework DI membantu dengan code generation atau reflection.
// Go dengan Uber FX — framework DI berbasis functional options
func main() {
fx.New(
// Module mendeklarasikan apa yang disediakan dan dibutuhkan
fx.Provide(
database.NewPostgresDB,
cache.NewRedisClient,
repository.NewUserRepository,
repository.NewOrderRepository,
service.NewUserService,
service.NewOrderService,
handler.NewUserHandler,
handler.NewOrderHandler,
),
fx.Invoke(startHTTPServer),
).Run()
// FX otomatis menyambungkan semua dependency berdasarkan type signature
}
// FX mencocokkan: NewUserRepository butuh *sql.DB → inject dari NewPostgresDB
// FX mencocokkan: NewUserService butuh UserRepository → inject dari NewUserRepository
// dst. — semua resolusi dependency otomatis
// Spring Boot — annotation-based DI
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
// Spring scan semua @Service, @Repository, @Component
// dan otomatis inject semua dependency
}
}
@Repository
public class JpaUserRepository implements UserRepository {
private final EntityManager em;
public JpaUserRepository(EntityManager em) {
this.em = em; // Spring inject EntityManager
}
}
Kapan DI manual vs framework:
DI Manual cocok jika:
✓ Sistem kecil-menengah (< 20-30 service/repo)
✓ Tim tidak familiar dengan framework DI
✓ Ingin zero magic, semua eksplisit
✓ Compile time, tidak ada runtime overhead
DI Framework cocok jika:
✓ Sistem besar dengan banyak komponen
✓ Tim sudah familiar dengan framework
✓ Butuh lifecycle management (startup/shutdown hooks)
✓ Ingin reduce boilerplate di main()
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 bisa diimplementasikan oleh fake untuk testing
type UserRepository interface {
Save(ctx context.Context, user *User) error
FindByEmail(ctx context.Context, email string) (*User, error)
}
// Implementasi 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
}
// Implementasi test — in-memory, tidak butuh infrastruktur
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 database
func TestRegisterUser_EmailAlreadyExists(t *testing.T) {
repo := NewInMemoryUserRepository()
emailer := &FakeEmailSender{}
service := 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 := service.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
}
Dengan DI, test di atas berjalan dalam milidetik, bisa dijalankan secara paralel, tidak memerlukan docker-compose atau test database, dan hasilnya selalu deterministik.
Scope Lifecycle dalam DI #
Satu hal yang sering dilupakan saat merancang DI adalah scope — berapa lama sebuah dependency hidup.
Scope umum dalam DI:
Singleton — Satu instance untuk seluruh lifetime aplikasi
Cocok untuk: database connection pool, config, logger
Di Go: buat sekali di main(), pass ke semua yang butuh
Transient — Instance baru setiap kali di-inject
Cocok untuk: request-scoped object, command handler
Hati-hati: tidak cocok untuk objek yang mahal dibuat
Scoped — Satu instance per unit kerja (request, transaction)
Cocok untuk: HTTP request context, database transaction
Di Go: biasanya dilewatkan via context.Context
// ANTI-PATTERN: database connection dibuat setiap request — sangat mahal
func handleRequest(w http.ResponseWriter, r *http.Request) {
db, _ := sql.Open("postgres", os.Getenv("DATABASE_URL")) // ← boros
defer db.Close()
// ...
}
// BENAR: connection pool dibuat sekali (singleton), dibagikan ke semua handler
func main() {
db, _ := sql.Open("postgres", os.Getenv("DATABASE_URL"))
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(10)
repo := repository.NewUserRepository(db) // singleton
service := service.NewUserService(repo) // singleton
handler := handler.NewUserHandler(service) // singleton
http.ListenAndServe(":8080", setupRouter(handler))
}
Anti-Pattern yang Harus Dihindari #
// ✗ Membuat dependency di dalam method — IoC dilanggar
func (s *OrderService) CreateOrder(req Request) (*Order, error) {
repo := &MySQLOrderRepository{} // ← tight coupling kembali
return repo.Save(buildOrder(req))
}
// ✓ s.repo sudah diinjeksikan via constructor, langsung pakai
// ✗ Menggunakan init() atau package-level var sebagai dependency
var globalDB *sql.DB
func init() {
globalDB, _ = sql.Open("postgres", os.Getenv("DATABASE_URL"))
}
// — global state: tidak bisa di-mock, tidak bisa dikontrol di test
// ✓ Inject *sql.DB via constructor
// ✗ God constructor dengan terlalu banyak dependency
func NewOrderService(
repo OrderRepository,
userRepo UserRepository,
productRepo ProductRepository,
inventoryRepo InventoryRepository,
paymentGateway PaymentGateway,
emailSender EmailSender,
smsSender SMSSender,
pushNotifier PushNotifier,
logger Logger,
cache CacheStore,
config *Config,
eventBus EventBus,
) *OrderService { ... }
// — 12 dependency = tanda OrderService melakukan terlalu banyak hal
// ✓ Pecah menjadi beberapa service yang lebih kecil dan fokus
// ✗ Inject concrete type, bukan interface
func NewOrderService(repo *MySQLOrderRepository) *OrderService { ... }
// — tidak bisa diganti dengan implementasi lain
// ✓ Inject interface
func NewOrderService(repo OrderRepository) *OrderService { ... }
// ✗ Null/zero value sebagai "acceptable" state
service := &UserService{} // repo masih nil
// — panic saat s.repo.FindByID dipanggil
// ✓ Gunakan constructor yang enforce semua dependency wajib
service := NewUserService(repo) // compiler error jika repo tidak diberikan
Ringkasan #
- Dependency Injection adalah pattern di mana objek menerima dependency dari luar, bukan membuatnya sendiri — memindahkan tanggung jawab konstruksi ke caller.
- Tiga teknik injeksi: constructor injection (dependency wajib dan transparan — pilihan utama), setter injection (dependency opsional dengan default), dan parameter injection (dependency per-call seperti
context.Context).- Constructor injection paling direkomendasikan karena dependency terlihat jelas, bersifat immutable, dan compiler enforce bahwa semua dependency tersedia.
- DI adalah implementasi DIP (Dependency Inversion Principle) dari SOLID — high-level module bergantung pada abstraksi (interface), bukan implementasi konkret.
- Dampak paling nyata pada testability — unit test bisa inject implementasi fake/in-memory yang berjalan dalam milidetik tanpa database atau network.
- DI manual (wiring di
main()) cukup untuk sistem kecil-menengah dan lebih eksplisit; DI framework (Uber FX, Spring, NestJS) lebih cocok untuk sistem besar dengan puluhan komponen.- Perhatikan scope lifecycle: database connection pool harus singleton, request-scoped object harus scoped, jangan buat objek mahal di setiap request.
- God constructor dengan 8+ dependency adalah code smell — tanda bahwa komponen melakukan terlalu banyak hal dan perlu dipecah.
- Inject interface, bukan concrete type — ini yang memungkinkan swap implementasi tanpa mengubah konsumen.
← Sebelumnya: Inversion of Control Berikutnya: Aspect Oriented Programming →