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 →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact