Inversion of Control #

Ada satu tanda yang sangat jelas bahwa sebuah codebase sudah tidak sehat: ketika kamu takut mengubah satu class karena tidak tahu apa lagi yang akan ikut rusak. Ketakutan itu hampir selalu berakar pada coupling yang terlalu erat — komponen yang menciptakan dependency-nya sendiri, menarik implementasi konkret dari mana-mana, dan akhirnya menjadi saling terikat satu sama lain seperti benang kusut. Inversion of Control (IoC) adalah prinsip desain yang memutus kusutan itu dengan cara yang elegan: alih-alih setiap komponen mengontrol dependency-nya sendiri, kontrol itu diserahkan ke pihak luar. Panduan ini membahas IoC dari masalah nyata yang ia selesaikan, tiga bentuk implementasinya dengan kode konkret di berbagai bahasa, dampaknya terhadap testability dan arsitektur, hingga anti-pattern yang sering muncul ketika IoC diterapkan setengah-setengah.

Apa Itu Inversion of Control? #

Inversion of Control adalah prinsip desain di mana alur kontrol sebuah program “dibalik” dibandingkan pendekatan tradisional. Pada pendekatan konvensional, kode aplikasi mengontrol segalanya: ia membuat dependency-nya sendiri, menentukan implementasi mana yang digunakan, dan memanggil library sesuai kebutuhannya. Pada IoC, kontrol itu diserahkan ke pihak eksternal — framework, container, atau mekanisme lain yang menjadi orkestrator.

Ungkapan klasik yang merangkumnya: “Don’t call us, we’ll call you.”

Tanpa IoC — kode mengontrol segalanya:

  OrderService
      │  new()
      ▼
  MySQLOrderRepository   ← dibuat langsung, hardcoded
      │  new()
      ▼
  SMTPEmailSender        ← dibuat langsung, hardcoded
      │  new()
      ▼
  StripePaymentGateway   ← dibuat langsung, hardcoded

  Masalah: OrderService tahu terlalu banyak tentang implementasi detail.
  Mengganti MySQL ke PostgreSQL = ubah OrderService.
  Test tanpa database nyata = tidak bisa.


Dengan IoC — kontrol diserahkan ke luar:

  IoC Container / main()
      │  inject
      ├──────────────→ OrderService
      │  inject             │ pakai interface
      ├──────────────→ OrderRepository (interface)
      │  inject             │ implementasi dipilih container
      ├──────────────→ EmailSender (interface)
      │  inject
      └──────────────→ PaymentGateway (interface)

  OrderService tidak tahu implementasi mana yang digunakan.
  Container yang memutuskan.

IoC bukan sebuah tool atau library — ia adalah prinsip. Spring, Guice, Laravel Service Container, Uber FX, Wire (Go) adalah implementasi dari prinsip ini. Kamu bisa menerapkan IoC tanpa framework apapun, hanya dengan disiplin desain.


Masalah yang Diselesaikan IoC #

Untuk benar-benar memahami nilai IoC, perlu dilihat dulu masalah konkret yang muncul tanpa IoC di sistem yang berkembang.

Tight Coupling dan Ketakutan Berubah #

// ANTI-PATTERN: tight coupling — OrderService membuat semua dependency sendiri
type OrderService struct{}

func (s *OrderService) CreateOrder(req CreateOrderRequest) (*Order, error) {
    // Membuat dependency secara langsung — coupling ke implementasi konkret
    repo := &MySQLOrderRepository{
        DB: sql.Open("mysql", "root:password@/orders"), // ← hardcoded
    }
    emailer := &SMTPEmailSender{
        Host: "smtp.gmail.com", // ← hardcoded
        Port: 587,
    }
    payment := &StripePaymentGateway{
        APIKey: "sk_live_abc123", // ← hardcoded
    }

    order := buildOrder(req)
    repo.Save(order)
    payment.Charge(order.UserID, order.Total)
    emailer.Send(order.UserEmail, "Order confirmed")
    return order, nil
}

Konsekuensi dari kode di atas: untuk menulis unit test CreateOrder, kamu butuh koneksi MySQL nyata, SMTP server nyata, dan Stripe API key yang valid. Test menjadi integration test yang lambat, fragile, dan tidak bisa dijalankan secara isolated. Setiap perubahan ke salah satu implementasi (misalnya mengganti SMTP dengan SendGrid) memaksa modifikasi di OrderService — padahal OrderService seharusnya tidak peduli bagaimana email dikirim.

Solusi: Dependency melalui Interface #

// BENAR: OrderService hanya bergantung pada interface, bukan implementasi
type OrderRepository interface {
    Save(ctx context.Context, order *Order) error
    FindByID(ctx context.Context, id string) (*Order, error)
}

type EmailSender interface {
    Send(ctx context.Context, to, subject, body string) error
}

type PaymentGateway interface {
    Charge(ctx context.Context, userID string, amount int64) (*ChargeResult, error)
}

// OrderService tidak tahu implementasi mana yang digunakan
type OrderService struct {
    repo    OrderRepository  // ← interface
    emailer EmailSender      // ← interface
    payment PaymentGateway   // ← interface
}

// Dependency diterima dari luar (constructor injection)
func NewOrderService(repo OrderRepository, emailer EmailSender, payment PaymentGateway) *OrderService {
    return &OrderService{repo: repo, emailer: emailer, payment: payment}
}

func (s *OrderService) CreateOrder(ctx context.Context, req CreateOrderRequest) (*Order, error) {
    order := buildOrder(req)
    if err := s.repo.Save(ctx, order); err != nil {
        return nil, err
    }
    s.payment.Charge(ctx, order.UserID, order.Total)
    s.emailer.Send(ctx, order.UserEmail, "Order confirmed", buildEmailBody(order))
    return order, nil
}

Sekarang OrderService tidak tahu apakah database-nya MySQL atau PostgreSQL, apakah email-nya SMTP atau SendGrid, apakah payment-nya Stripe atau Midtrans. Yang ia tahu hanya interface-nya. Kontrol atas implementasi ada di luar — di main() atau IoC container.


Tiga Bentuk Implementasi IoC #

IoC bukan satu teknik tunggal. Ada tiga bentuk utama yang masing-masing cocok untuk konteks berbeda.

1. Dependency Injection (DI) #

Ini adalah bentuk IoC yang paling umum dan paling langsung. Dependency tidak dibuat oleh class yang membutuhkannya — melainkan diinjeksikan dari luar melalui constructor, setter, atau parameter.

// Constructor Injection — paling direkomendasikan
// Dependency wajib terlihat jelas, tidak ada kejutan tersembunyi
func NewUserService(
    repo     UserRepository,
    cache    CacheStore,
    emailer  EmailSender,
    logger   Logger,
) *UserService {
    return &UserService{
        repo:    repo,
        cache:   cache,
        emailer: emailer,
        logger:  logger,
    }
}

// Wiring di main() — semua dependency dirakit di satu tempat
func main() {
    db         := postgres.Connect(os.Getenv("DATABASE_URL"))
    cache       := redis.Connect(os.Getenv("REDIS_URL"))
    emailer     := sendgrid.NewSender(os.Getenv("SENDGRID_API_KEY"))
    logger      := zap.NewProduction()

    userRepo    := repository.NewUserRepository(db)
    userService := service.NewUserService(userRepo, cache, emailer, logger)
    userHandler := handler.NewUserHandler(userService)

    // Semua dependency sudah dirakit — server tinggal dijalankan
    http.ListenAndServe(":8080", setupRouter(userHandler))
}
// Dart/Flutter — constructor injection tanpa framework
class OrderBloc {
  final OrderRepository _repository;
  final AnalyticsService _analytics;

  // Dependency diinjeksikan via constructor
  OrderBloc({
    required OrderRepository repository,
    required AnalyticsService analytics,
  })  : _repository = repository,
        _analytics = analytics;
}

// Di main / widget tree — kontrol ada di luar
void main() {
  final repository = OrderRepositoryImpl(api: ApiClient());
  final analytics = FirebaseAnalyticsService();
  final bloc = OrderBloc(repository: repository, analytics: analytics);
  // ...
}

2. Service Locator #

Alih-alih dependency diinjeksikan, komponen mengambilnya sendiri dari registry terpusat. Ini masih IoC karena komponen tidak membuat implementasi — ia hanya “mencarinya” dari container.

// Service Locator pattern
type ServiceLocator struct {
    services map[string]interface{}
}

func (sl *ServiceLocator) Register(name string, service interface{}) {
    sl.services[name] = service
}

func (sl *ServiceLocator) Get(name string) interface{} {
    return sl.services[name]
}

// Penggunaan — komponen mengambil dependency dari locator
func (s *OrderService) CreateOrder(req CreateOrderRequest) error {
    repo := locator.Get("orderRepository").(OrderRepository)
    return repo.Save(buildOrder(req))
}
Service Locator sering dianggap anti-pattern oleh banyak engineer karena dependency tersembunyi — dengan melihat constructor atau signature CreateOrder, kamu tidak bisa langsung tahu apa saja yang dibutuhkan. Semua dependency tersembunyi di dalam body fungsi. Gunakan constructor injection sebagai default; Service Locator hanya untuk kasus khusus seperti plugin system atau dynamic loading.

3. Event/Callback Pattern #

Bentuk IoC yang sering tidak disadari sebagai IoC: kode mendaftarkan callback, dan framework atau runtime yang memutuskan kapan callback itu dipanggil. Ini adalah “don’t call us, we’ll call you” dalam bentuk paling literal.

// HTTP framework — kamu mendaftarkan handler, framework yang memanggil
router.GET("/users/:id", func(c *gin.Context) {
    // Framework memanggil kode ini saat ada request GET /users/:id
    // Kamu tidak memanggil ini secara langsung
    userService.GetUser(c.Param("id"))
})

// Event listener — kamu mendaftarkan handler, runtime yang memanggil
eventBus.Subscribe("user.registered", func(event UserRegisteredEvent) {
    // Runtime memanggil ini saat event dipublish
    // Kamu tidak tahu kapan persis ini dipanggil
    emailService.SendWelcome(event.Email)
})

// Scheduled job — kamu mendaftarkan fungsi, scheduler yang memanggil
scheduler.Every(1).Hour().Do(func() {
    // Scheduler memanggil ini setiap jam
    reportService.GenerateHourlyReport()
})

Ketiga contoh ini adalah IoC: kontrol tentang kapan kode dipanggil ada di framework/runtime, bukan di kode itu sendiri.


Dampak terhadap Testability #

Salah satu manfaat paling konkret dari IoC adalah kemampuan untuk mengganti implementasi nyata dengan mock atau fake saat testing — tanpa mengubah satu baris pun di kode yang diuji.

// Tanpa IoC — test membutuhkan infrastruktur nyata
func TestCreateOrder_WithoutIoC(t *testing.T) {
    service := &OrderService{}
    // Di dalam CreateOrder, MySQLOrderRepository akan dibuat
    // → test ini butuh koneksi MySQL yang berjalan
    // → test ini butuh SMTP server
    // → test ini butuh Stripe API key valid
    order, err := service.CreateOrder(CreateOrderRequest{...})
    // Fragile, lambat, tidak bisa dijalankan offline
}

// Dengan IoC — test pakai implementasi fake yang cepat dan terkontrol
type FakeOrderRepository struct {
    saved []*Order
}
func (f *FakeOrderRepository) Save(_ context.Context, order *Order) error {
    f.saved = append(f.saved, order)
    return nil
}

type FakeEmailSender struct {
    sent []string
}
func (f *FakeEmailSender) Send(_ context.Context, to, subject, body string) error {
    f.sent = append(f.sent, to)
    return nil
}

func TestCreateOrder_WithIoC(t *testing.T) {
    fakeRepo    := &FakeOrderRepository{}
    fakeEmailer := &FakeEmailSender{}
    fakePayment := &FakePaymentGateway{}

    // Inject fake dependency — tidak butuh database atau SMTP
    service := NewOrderService(fakeRepo, fakeEmailer, fakePayment)

    order, err := service.CreateOrder(context.Background(), CreateOrderRequest{
        UserID: "user-1",
        Items:  []Item{{ProductID: "prod-1", Qty: 2}},
    })

    assert.NoError(t, err)
    assert.Len(t, fakeRepo.saved, 1)          // ✓ order tersimpan
    assert.Len(t, fakeEmailer.sent, 1)        // ✓ email dikirim ke satu alamat
    assert.Equal(t, "[email protected]", fakeEmailer.sent[0])
}

Test ini berjalan dalam milidetik, bisa dijalankan offline, dan hasilnya selalu deterministik. Ini tidak mungkin dicapai tanpa IoC.


IoC sebagai Fondasi Arsitektur Modern #

IoC bukan hanya tentang testing — ia adalah enabler untuk arsitektur-arsitektur yang paling banyak digunakan di sistem modern.

Clean Architecture dan Hexagonal Architecture #

Kedua arsitektur ini bergantung pada IoC untuk memastikan domain layer tidak bergantung pada detail infrastruktur.

Clean Architecture — dependency direction dengan IoC:

  Domain Layer (Entity, Use Case)
       │
       │ bergantung pada interface
       ▼
  Interface/Port Layer (Repository interface, Service interface)
       ↑
       │ implementasi disediakan oleh
  Infrastructure Layer (MySQL, Redis, SMTP, HTTP)

  Domain tidak tahu MySQL ada.
  Infrastructure tidak mempengaruhi business rule.
  IoC container yang menyambungkan keduanya di runtime.
// Domain layer — hanya tahu interface, tidak tahu implementasi
package domain

type UserRepository interface {
    Save(ctx context.Context, user *User) error
    FindByEmail(ctx context.Context, email string) (*User, error)
}

type UserService struct {
    repo UserRepository // ← interface dari domain layer
}

// Infrastructure layer — implementasi konkret
package infrastructure

type PostgresUserRepository struct {
    db *sql.DB
}

func (r *PostgresUserRepository) Save(ctx context.Context, user *domain.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
}

// main.go — IoC container menyambungkan domain dan infrastructure
func main() {
    db   := connectPostgres()
    repo := &infrastructure.PostgresUserRepository{db: db}

    // Domain tidak tahu PostgresUserRepository ada
    // tapi mendapatkan implementasinya via IoC
    service := domain.NewUserService(repo)
}

IoC dan Framework #

Saat menggunakan framework seperti Spring (Java), Laravel (PHP), atau NestJS (Node.js), IoC sudah built-in. Framework-lah yang bertanggung jawab atas lifecycle object — kapan dibuat, bagaimana di-wire, kapan dihancurkan.

// Spring — @Service dan @Autowired adalah mekanisme IoC
@Service
public class OrderService {

    private final OrderRepository repo;
    private final EmailSender emailer;

    // Spring meng-inject implementasi saat startup
    @Autowired
    public OrderService(OrderRepository repo, EmailSender emailer) {
        this.repo = repo;
        this.emailer = emailer;
    }
}

// Kamu tidak pernah menulis: new OrderService(new MySQLOrderRepo(), new SMTPSender())
// Spring yang mengurusnya

Di Go yang tidak punya framework DI bawaan, pattern yang umum adalah menggunakan wire (Google) atau fx (Uber) untuk wiring, atau cukup melakukannya manual di main().


Checklist Penerapan IoC yang Benar #

DESAIN:
  □ Setiap class hanya bergantung pada interface, bukan implementasi konkret
  □ Dependency terlihat jelas di constructor — tidak ada dependency tersembunyi
  □ Interface didefinisikan di layer yang membutuhkan, bukan di layer yang mengimplementasi
  □ Tidak ada instantiasi dependency (new/make) di dalam business logic

WIRING:
  □ Semua dependency dirakit di satu tempat (main.go, AppModule, container)
  □ Tidak ada global state atau singleton tersembunyi
  □ Konfigurasi diinjeksikan sebagai dependency, bukan dibaca dari os.Getenv di mana-mana

TESTING:
  □ Unit test bisa berjalan tanpa database, network, atau external service
  □ Fake/mock bisa dibuat dengan mengimplementasi interface — tidak butuh library mocking
  □ Test untuk satu komponen tidak butuh setup komponen lain

ARSITEKTUR:
  □ Domain/business layer tidak import package infrastructure
  □ Dependency direction selalu dari luar ke dalam (infrastructure → domain, bukan sebaliknya)

Anti-Pattern yang Harus Dihindari #

// ✗ Membuat dependency di dalam method, bukan di constructor
func (s *OrderService) CreateOrder(req Request) error {
    repo := NewMySQLOrderRepository() // ← IoC dilanggar, coupling kembali
    return repo.Save(buildOrder(req))
}
// ✓ Dependency sudah ada di s.repo, diinjeksikan via constructor

// ✗ Menggunakan package-level variable sebagai dependency
var globalDB *sql.DB // ← dependency tersembunyi, sulit di-test

func (s *OrderService) CreateOrder(req Request) error {
    globalDB.Exec("INSERT INTO orders...") // ← langsung akses global
}
// ✓ Inject *sql.DB (atau wrapper-nya) via constructor

// ✗ Interface yang terlalu besar — memaksa implementasi method yang tidak dibutuhkan
type UserRepository interface {
    Save(user User) error
    FindByID(id string) (*User, error)
    FindByEmail(email string) (*User, error)
    FindAll() ([]*User, error)
    Delete(id string) error
    Update(user User) error
    Count() int
    // ... 10 method lagi
}
// Untuk test yang hanya butuh Save dan FindByID, fake harus implement 12 method
// ✓ Interface segregation — definisikan interface sekecil yang dibutuhkan consumer
type UserSaver interface {
    Save(ctx context.Context, user User) error
}
type UserFinder interface {
    FindByID(ctx context.Context, id string) (*User, error)
}

// ✗ Circular dependency — A butuh B, B butuh A
// Ini tanda bahwa boundary antar komponen salah
type ServiceA struct{ b *ServiceB }
type ServiceB struct{ a *ServiceA }
// ✓ Pisahkan ke interface atau ekstrak shared dependency ke komponen ketiga

Ringkasan #

  • Inversion of Control membalik arah kontrol: komponen tidak membuat dependency-nya sendiri — dependency diberikan dari luar oleh framework, container, atau kode wiring.
  • “Don’t call us, we’ll call you” — framework memanggil kode kita, bukan sebaliknya; ini yang terjadi di HTTP handler, event listener, dan scheduler.
  • Tiga bentuk utama: Dependency Injection (paling direkomendasikan, dependency transparan lewat constructor), Service Locator (dependency tersembunyi, hindari untuk business logic), dan Event/Callback (kontrol timing ada di runtime).
  • Constructor injection adalah pilihan terbaik — semua dependency terlihat jelas, tidak ada kejutan tersembunyi, interface langsung bisa dipakai untuk mocking.
  • Testability meningkat drastis — dengan IoC, implementasi nyata bisa diganti fake/mock tanpa mengubah kode yang diuji; unit test bisa berjalan tanpa infrastruktur.
  • Fondasi arsitektur modern: Clean Architecture, Hexagonal Architecture, dan DDD semuanya bergantung pada IoC untuk memastikan domain layer tidak bergantung pada detail infrastruktur.
  • Interface didefinisikan di layer consumer, bukan di layer implementor — ini yang membuat dependency direction benar.
  • Wiring di satu tempat (main.go atau AppModule) — bukan tersebar di seluruh codebase.
  • Anti-pattern utama: membuat dependency di dalam method, global variable sebagai dependency, interface terlalu besar, dan circular dependency.

← Sebelumnya: Event Streaming   Berikutnya: Dependency Injection →

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