IoC, DI, Container, dan Bean di Spring Dibangun dari Nol Spring Boot
![]() |
| Dari Config ke Bean |
Saat belajar Spring Framework dan langsung mulai dari Spring Boot, ada kemungkinan besar kamu sudah sering pakai @Autowired dan @Service. Tapi begitu ada yang bertanya, "sebenarnya siapa sih yang membuat objek ini?", jawabanmu mungkin cuma, "pokoknya jalan aja sih".
Itu wajar, kok. Spring Boot memang didesain untuk menyembunyikan kerumitan setup di balik satu baris SpringApplication.run(). Masalahnya, begitu suatu hari kamu ketemu bug soal bean yang gagal di-inject, circular dependency, atau bingung kenapa ada dua bean dengan tipe yang sama, kamu jadi tidak punya pegangan konsep untuk mulai debugging.
Artikel ini mengajak kamu mundur selangkah: membangun aplikasi Spring secara manual, tanpa Spring Boot, supaya setiap konsep — IoC, DI, Container, Context, dan Bean — benar-benar terlihat dengan mata kepala sendiri, bukan cuma dihafal.
Daftar Isi
1. IoC (Inversion of Control) — Prinsipnya
Inversion of Control adalah prinsip desain di mana kontrol atas pembuatan objek dan pengaturan dependency-nya diserahkan ke pihak luar — biasanya framework — bukan dipegang sendiri oleh kode yang menggunakannya.
Bandingkan dua potongan kode berikut. Pertama, cara lama tanpa IoC:
// TANPA IoC — kita yang pegang kendali penuh
public class UserService {
private UserRepository userRepository;
public UserService() {
// Kita sendiri yang membuat dependency-nya
this.userRepository = new JpaUserRepository();
}
}
Bandingkan dengan versi yang menerapkan IoC:
// DENGAN IoC — kontrol diserahkan ke pihak luar
public class UserService {
private UserRepository userRepository;
public UserService(UserRepository userRepository) {
// Dependency datang dari luar, kita tinggal terima
this.userRepository = userRepository;
}
}
Pada contoh pertama, UserService sendiri yang menentukan implementasi konkret apa yang dipakai (JpaUserRepository). Ini membuatnya tightly coupled — susah diganti implementasinya, dan susah pula ditest karena kamu tidak bisa menyisipkan mock object dengan mudah.
Pada contoh kedua, UserService tidak peduli implementasi konkretnya apa. Ia hanya tahu bahwa dirinya butuh sesuatu yang mengimplementasikan UserRepository. Siapa yang menyediakan objek itu? Bukan urusannya lagi — itu urusan pihak luar, yang dalam konteks Spring menjadi tugas si Container yang akan kita bahas sebentar lagi.
Inilah esensi "inversion": kontrol yang biasanya dipegang oleh kelas itu sendiri, dibalik dan diserahkan ke luar.
2. DI (Dependency Injection) — Cara Mengimplementasikan IoC
Kalau IoC adalah prinsip, Dependency Injection adalah salah satu teknik konkret untuk mewujudkannya. DI berarti dependency "disuntikkan" dari luar ke dalam sebuah objek, alih-alih dibuat sendiri di dalamnya. Ada tiga cara umum melakukan DI di Java.
a. Constructor Injection (paling direkomendasikan)
@Service
public class UserService {
private final UserRepository userRepository;
@Autowired
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
}
Dependency wajib ada saat objek dibuat, karena lewat constructor, dan bisa dideklarasikan final. Cocok untuk dependency yang sifatnya wajib dan tidak berubah.
b. Setter Injection
@Service
public class UserService {
private UserRepository userRepository;
@Autowired
public void setUserRepository(UserRepository userRepository) {
this.userRepository = userRepository;
}
}
Cocok untuk dependency yang sifatnya opsional, atau yang perlu bisa diganti setelah objek dibuat.
c. Field Injection
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
}
Paling ringkas ditulis, tapi paling sulit di-unit-test karena kamu tidak bisa menyuntikkan dependency tanpa lewat reflection atau container.
Intinya: IoC adalah filosofi, DI adalah cara mempraktikkannya.
3. IoC Tidak Cuma DI
Ini poin yang sering terlewat. Karena Spring identik dengan DI, banyak orang mengira IoC sama dengan DI. Padahal DI hanyalah salah satu cara mengimplementasikan IoC. Beberapa cara lain yang menerapkan prinsip yang sama, yaitu kontrol yang diserahkan ke pihak luar:
| Pola | Cara Kerja |
|---|---|
| Service Locator | Objek secara aktif "mencari" dependency-nya dari sebuah registry terpusat |
| Factory Pattern | Sebuah factory bertugas membuat dan mengonfigurasi objek |
| Template Method | Parent class mengatur alur eksekusi, subclass hanya mengisi detail langkah tertentu |
| Observer / Event-driven | Alur program ditentukan oleh listener dan callback, bukan oleh si pemanggil |
Semuanya sama-sama "membalik kontrol", tapi DI dipilih Spring sebagai pendekatan utama karena paling deklaratif, paling eksplisit soal apa yang dibutuhkan sebuah objek, dan paling mudah ditest dengan mock.
4. IoC Container — Si Mesin di Balik Layar
IoC Container adalah komponen yang benar-benar mengeksekusi DI. Tugasnya ada tiga:
- Membuat objek — objek-objek ini disebut bean
- Meresolusi dependency — menyambungkan bean satu dengan bean lainnya sesuai kebutuhan
- Mengatur lifecycle — menentukan kapan bean dibuat, kapan diinisialisasi, dan kapan dihancurkan
Container inilah yang, di balik layar, melakukan pekerjaan yang tadinya kita lakukan manual dengan new. Ketika kamu menulis @Autowired, kamu sebenarnya sedang memberi instruksi ke container: "tolong carikan dan suntikkan bean yang cocok di sini."
5. ApplicationContext — Wadah yang Lebih Besar
Kalau Container adalah "mesin", ApplicationContext adalah "pabrik keseluruhan" yang menampung mesin itu plus konfigurasi dan environment aplikasi.
┌─────────────────────────────────────────┐
│ ApplicationContext │
│ ┌───────────────────────────────────┐ │
│ │ IoC Container │ │
│ │ ┌─────────────┐ ┌──────────────┐ │ │
│ │ │ UserService │ │UserRepository│ │ │
│ │ └─────────────┘ └──────────────┘ │ │
│ └───────────────────────────────────┘ │
│ │
│ + Properties, Profile, Environment │
└─────────────────────────────────────────┘
ApplicationContext adalah interface pintu masuk yang dipakai kode aplikasi untuk mengambil bean, membaca properti konfigurasi, dan mengetahui environment yang sedang aktif — misalnya profil dev atau production.
Nah, di sinilah AnnotationConfigApplicationContext masuk — salah satu implementasi konkret dari ApplicationContext yang membaca konfigurasi dari anotasi Java, bukan dari XML. Kita akan pakai kelas ini langsung di bagian praktik nanti.
6. Bean dan Spring Bean
Bean, dalam istilah umum Java, adalah objek apa pun yang siklus hidupnya diatur oleh sebuah container. Ini konsep lama dari JavaBeans, bukan eksklusif milik Spring.
Spring Bean lebih spesifik: objek yang dikelola oleh IoC Container milik Spring, biasanya ditandai dengan anotasi seperti @Component, @Service, @Repository, @Controller, atau didefinisikan lewat metode @Bean di dalam kelas @Configuration.
Semua bean adalah object. Tapi tidak semua object adalah bean. Bean adalah object yang "hidup"-nya diatur container — bukan kita yang new() manual.
7. Praktik: Setup Spring Manual Tanpa Spring Boot
Sekarang bagian intinya. Kita akan membangun mini aplikasi Spring tanpa Spring Boot — tanpa auto-configuration, tanpa embedded server, tanpa "magic" apa pun. Kita akan melihat sendiri kapan ApplicationContext dibuat dan kapan bean di-inject. Semua yang kamu baca di bagian ini sudah benar-benar dipraktikkan langsung, bukan cuma ditulis di atas kertas, jadi ada beberapa temuan nyata di sepanjang jalan yang akan saya selipkan.
7.1 Setup Project
Struktur project sederhana dengan Maven:
spring-manual-demo/
├── pom.xml
└── src/
└── main/
└── java/
└── com/example/
├── AppConfig.java
├── Application.java
├── repository/
│ ├── UserRepository.java
│ └── JpaUserRepository.java
└── service/
├── UserService.java
└── EmailService.java
pom.xml — perhatikan, kita hanya menambahkan spring-context, bukan spring-boot-starter apa pun:
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>spring-manual-demo</artifactId>
<version>1.0.0</version>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<spring.version>6.1.13</spring.version>
</properties>
<dependencies>
<!-- Hanya spring-context, TIDAK ada spring-boot-starter apa pun -->
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>${spring.version}</version>
</dependency>
<!-- Wajib ditambahkan: Spring 6 pindah ke Jakarta EE 9+, dan
spring-context tidak lagi membawa jakarta.annotation.* secara
transitif. Tanpa ini, @PostConstruct/@PreDestroy di 7.3
tidak akan bisa di-compile. -->
<dependency>
<groupId>jakarta.annotation</groupId>
<artifactId>jakarta.annotation-api</artifactId>
<version>2.1.1</version>
</dependency>
</dependencies>
<build>
<plugins>
<!-- Opsional: memudahkan run "mvn exec:java" -->
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.2.0</version>
<configuration>
<mainClass>com.example.Application</mainClass>
</configuration>
</plugin>
</plugins>
</build>
</project>
Ini penting: dengan hanya spring-context ditambah jakarta.annotation-api untuk lifecycle annotation, tidak ada auto-configuration yang jalan diam-diam. Semua yang terjadi adalah hasil kode yang kita tulis sendiri.
Cerita dari praktik nyata: versi awal pom.xml hanya mencantumkan spring-context saja. Begitu benar-benar dikompilasi, langsung gagal — jakarta.annotation.PostConstruct/PreDestroy yang dipakai di 7.3 butuh dependency terpisah, dan itu belum ada. Sudah ditambahkan pada kode di atas, jadi kalau kamu copy-paste dari sini, aman.
7.2 Membuat Interface dan Implementasi (Bean Kandidat)
package com.example.repository;
public interface UserRepository {
String findById(Long id);
}
package com.example.repository;
import org.springframework.stereotype.Repository;
@Repository
public class JpaUserRepository implements UserRepository {
@Override
public String findById(Long id) {
return "User-" + id + " (dari JpaUserRepository)";
}
}
@Repository menandai kelas ini sebagai kandidat Spring Bean. Tapi ingat, anotasi ini baru "berlaku" kalau container tahu harus mencarinya di package mana. Itu akan kita atur lewat @ComponentScan.
7.3 Membuat Service yang Bergantung pada Repository
package com.example.service;
import com.example.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
@Service
public class UserService {
private final UserRepository userRepository;
// Constructor Injection — dependency disuntikkan container, bukan dibuat sendiri
@Autowired
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public String getUser(Long id) {
return userRepository.findById(id);
}
@PostConstruct
public void init() {
// Dipanggil container tepat setelah bean selesai dibuat & dependency ter-inject
System.out.println("[Lifecycle] UserService siap digunakan.");
}
@PreDestroy
public void cleanup() {
// Dipanggil container sesaat sebelum context ditutup
System.out.println("[Lifecycle] UserService dibersihkan sebelum context ditutup.");
}
}
Perhatikan: UserService tidak pernah menulis new JpaUserRepository(). Ia hanya menyatakan kebutuhan lewat constructor — inilah IoC dan DI bekerja bersama.
7.4 Java Config — Pengganti XML
Di Spring tradisional, kita bisa memakai XML (beans.xml) atau Java Config. Kita pakai Java Config karena lebih modern dan type-safe:
package com.example;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.FilterType;
@Configuration
// AppConfigExplicit dikeluarkan dari scan — lihat penjelasannya di bawah
@ComponentScan(
basePackages = "com.example",
excludeFilters = @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = AppConfigExplicit.class)
)
public class AppConfig {
// Kosong — @ComponentScan yang akan menyisir package
// dan mendaftarkan semua @Component/@Service/@Repository
// sebagai bean secara otomatis.
}
@ComponentScan memberitahu container: "telusuri package com.example, dan daftarkan semua kelas yang ditandai @Component, @Service, @Repository, atau @Controller sebagai bean."
Kalau kamu ingin lebih eksplisit, tanpa component scanning, kamu juga bisa mendefinisikan bean satu per satu:
@Configuration
public class AppConfigExplicit {
@Bean
public UserRepository userRepository() {
return new JpaUserRepository();
}
@Bean
public EmailService emailService() {
return new EmailService();
}
@Bean
public UserService userService(UserRepository userRepository, EmailService emailService) {
return new UserService(userRepository, emailService);
}
}
Kedua cara ini valid. @ComponentScan lebih praktis untuk project besar; @Bean eksplisit memberi kontrol penuh — berguna misalnya saat kamu perlu mendaftarkan bean dari library pihak ketiga yang kelasnya tidak bisa kamu tandai @Component.
Ketemu bug nyata saat dicoba: kedua cara di atas memang valid, tapi hanya kalau dipakai sendiri-sendiri. Begitu AppConfigExplicit dan AppConfig diletakkan di package yang sama-sama kena @ComponentScan, keduanya jadi aktif bersamaan, dan itu langsung meledak saat dijalankan. Sebabnya, AppConfigExplicit sendiri bertanda @Configuration, yang secara meta adalah @Component, sehingga ikut ter-scan. Sekali dia ter-scan, method @Bean di dalamnya ikut didaftarkan sebagai bean tambahan, jadi ada dua bean bertipe UserRepository sekaligus.
Saat context dibuat, Spring langsung protes:
UnsatisfiedDependencyException: ... No qualifying bean of type
'com.example.repository.UserRepository' available:
expected single matching bean but found 2: jpaUserRepository,userRepository
Ini persis kasus "bingung kenapa ada dua bean dengan tipe yang sama" yang disinggung di paragraf pembuka artikel ini — dan sekarang kamu melihatnya kejadian dengan mata kepala sendiri. Perbaikannya sudah dituliskan di kode AppConfig di atas: AppConfigExplicit di-exclude dari @ComponentScan, supaya dua-duanya tetap ada di source code sebagai referensi, tapi cuma satu yang aktif.
7.5 Titik Kunci: AnnotationConfigApplicationContext
Inilah bagian yang biasanya "tersembunyi" oleh SpringApplication.run() di Spring Boot. Di sini, kita membuat context secara eksplisit, dan kita bisa melihat persis apa yang terjadi:
package com.example;
import com.example.service.UserService;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
public class Application {
public static void main(String[] args) {
System.out.println("1. Aplikasi dimulai...");
// ── Titik kunci ──────────────────────────────────────────
// AnnotationConfigApplicationContext adalah implementasi
// ApplicationContext yang membaca konfigurasi dari kelas
// yang ditandai @Configuration (bukan dari file XML).
//
// Saat baris ini dieksekusi:
// 1. Context dibuat
// 2. AppConfig dibaca → @ComponentScan dijalankan
// 3. Container membuat semua bean yang ditemukan
// 4. Container me-resolve & meng-inject dependency antar bean
// 5. Semua @PostConstruct dijalankan
AnnotationConfigApplicationContext context =
new AnnotationConfigApplicationContext(AppConfig.class);
System.out.println("2. Context siap. Bean yang terdaftar:");
for (String beanName : context.getBeanDefinitionNames()) {
System.out.println(" - " + beanName);
}
// ── Mengambil bean dari context ─────────────────────────
UserService userService = context.getBean(UserService.class);
System.out.println("3. UserService diambil dari context.");
String result = userService.getUser(42L);
System.out.println("4. Hasil: " + result);
// ── Menutup context secara eksplisit ────────────────────
// Ini akan memicu semua @PreDestroy sebelum aplikasi berhenti.
context.close();
System.out.println("5. Context ditutup.");
}
}
Bandingkan ini dengan Spring Boot, di mana semua proses di atas — pembuatan context, component scanning, pembuatan bean, dan injeksi dependency — terjadi diam-diam di balik satu baris SpringApplication.run(Application.class, args). Dengan pendekatan manual ini, kamu melihat setiap langkahnya dengan mata kepala sendiri.
7.6 Menambahkan Bean Lain untuk Melihat Resolusi Dependency
Mari tambahkan satu bean lagi untuk melihat bagaimana container menyambungkan beberapa bean sekaligus:
package com.example.service;
import org.springframework.stereotype.Service;
@Service
public class EmailService {
public void sendWelcomeEmail(String userInfo) {
System.out.println("[Email] Mengirim welcome email untuk: " + userInfo);
}
}
Lalu modifikasi UserService agar bergantung pada dua bean sekaligus:
@Service
public class UserService {
private final UserRepository userRepository;
private final EmailService emailService;
@Autowired
public UserService(UserRepository userRepository, EmailService emailService) {
this.userRepository = userRepository;
this.emailService = emailService;
}
public String getUser(Long id) {
String user = userRepository.findById(id);
emailService.sendWelcomeEmail(user);
return user;
}
}
Tanpa perlu mengubah satu baris pun di Application.java, container akan otomatis mendeteksi bahwa UserService sekarang butuh dua dependency, mencarikan keduanya dari daftar bean yang tersedia, lalu menyuntikkan keduanya lewat constructor. Ini adalah momen yang paling jelas menunjukkan container melakukan dependency resolution, bukan sekadar dependency injection satu arah.
7.7 Membuktikannya: Menjalankan Semua Kode di Atas Sungguhan
Sampai sini biasanya artikel Spring cuma bilang "jalankan dan lihat hasilnya kurang lebih begini". Kali ini kita benar-benar melakukannya. Semua kode di bagian 7 sudah ditulis, dikompilasi, dan dijalankan sungguhan dalam sebuah project bernama spring-manual-demo/, dengan setiap sub-bagian di atas (7.1 sampai 7.6) dicatat sebagai satu commit git tersendiri, lengkap dengan pesan yang menjelaskan apa yang berubah dan mengapa.
Kalau kamu mau mengikuti langkah yang sama, cukup masuk ke folder project-nya lalu compile:
cd spring-manual-demo
mvn -q compile
Tidak ada output berarti sukses. Setelah itu, jalankan aplikasinya lewat exec-maven-plugin yang sudah kita daftarkan di pom.xml pada 7.1:
mvn -q exec:java -Dexec.mainClass=com.example.Application
Dan berikut ini transkrip apa adanya, dijalankan langsung dari terminal macOS, bukan tebakan atau simulasi:
yusuf.ibrahim@Yusufs-MacBook-Pro spring-manual-demo % mvn exec:java -Dexec.mainClass=com.example.Application
[INFO] Scanning for projects...
[INFO]
[INFO] -------------------< com.example:spring-manual-demo >-------------------
[INFO] Building spring-manual-demo 1.0.0
[INFO] from pom.xml
[INFO] --------------------------------[ jar ]---------------------------------
[INFO]
[INFO] --- exec:3.2.0:java (default-cli) @ spring-manual-demo ---
1. Aplikasi dimulai...
[Lifecycle] UserService siap digunakan.
2. Context siap. Bean yang terdaftar:
- org.springframework.context.annotation.internalConfigurationAnnotationProcessor
- org.springframework.context.annotation.internalAutowiredAnnotationProcessor
- org.springframework.context.annotation.internalCommonAnnotationProcessor
- org.springframework.context.event.internalEventListenerProcessor
- org.springframework.context.event.internalEventListenerFactory
- appConfig
- jpaUserRepository
- emailService
- userService
3. UserService diambil dari context.
[Email] Mengirim welcome email untuk: User-42 (dari JpaUserRepository)
4. Hasil: User-42 (dari JpaUserRepository)
[Lifecycle] UserService dibersihkan sebelum context ditutup.
5. Context ditutup.
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 0.228 s
[INFO] Finished at: 2026-08-20T17:52:33+07:00
[INFO] ------------------------------------------------------------------------
yusuf.ibrahim@Yusufs-MacBook-Pro spring-manual-demo %
BUILD SUCCESS dalam 0,228 detik. Satu hal kecil yang perlu diketahui: daftar bean yang muncul memang lebih panjang dari yang biasanya ditulis di tutorial-tutorial lain, karena selain appConfig, jpaUserRepository, emailService, dan userService yang memang kita buat sendiri, AnnotationConfigApplicationContext juga otomatis mendaftarkan beberapa bean infrastruktur internal yang bertugas memproses anotasi @Configuration, @Autowired, dan @PostConstruct/@PreDestroy itu sendiri. Wajar, dan tidak perlu dikhawatirkan.
Kalau kamu penasaran ingin menengok kondisi kode di fase yang lebih awal, misalnya persis di akhir 7.3 sebelum Application.java ada, kamu bisa memakai git worktree supaya tidak mengganggu branch utama:
git worktree add /tmp/lihat-fase-7.3 <hash-commit-fase-7.3>
cd /tmp/lihat-fase-7.3/spring-manual-demo && mvn -q compile
cd - && git worktree remove /tmp/lihat-fase-7.3
Fase 7.1 sampai 7.4 cuma bisa di-compile, karena Application.java belum ada. Fase 7.5 dan 7.6 bisa juga langsung dijalankan — bedanya, di 7.5 baris [Email] ... belum muncul, karena EmailService memang baru ditambahkan satu fase setelahnya.
Kode lengkapnya ada di GitHub. Kalau kamu tidak mau mengetik ulang semua potongan kode di atas, seluruh project spring-manual-demo sudah di-push ke github.com/yoesoff/spring-manual-demo, dengan histori commit yang persis mengikuti urutan fase 7.1 sampai 7.6 di artikel ini. Tinggal buka tab Commits di GitHub untuk melihat kode bertambah sedikit demi sedikit, dari pom.xml kosong sampai UserService dengan dua dependency. Cukup clone dan langsung jalankan:
git clone https://github.com/yoesoff/spring-manual-demo.git
cd spring-manual-demo
mvn -q compile
mvn -q exec:java -Dexec.mainClass=com.example.Application
8. Kesimpulan
Setelah melihat semuanya bekerja secara manual, mari kita satukan potongan-potongannya:
| Istilah | Peran |
|---|---|
| IoC | Prinsip — kontrol pembuatan objek diserahkan ke pihak luar |
| DI | Teknik utama untuk mewujudkan IoC — dependency disuntikkan, bukan dibuat sendiri |
| Container | Mesin yang benar-benar membuat bean, meresolusi dependency, dan mengatur lifecycle |
| ApplicationContext | Wadah besar yang membungkus container plus konfigurasi & environment aplikasi |
| Bean | Objek yang siklus hidupnya diatur oleh container |
| AnnotationConfigApplicationContext | Implementasi konkret ApplicationContext yang membaca konfigurasi dari kelas @Configuration |
Spring Boot tidak menghilangkan konsep-konsep ini — ia hanya membungkusnya rapat-rapat agar kamu bisa langsung produktif. Tapi begitu kamu paham apa yang sebenarnya terjadi di balik SpringApplication.run(), kamu akan jauh lebih percaya diri saat debugging masalah bean, memahami urutan inisialisasi, atau bahkan membaca source code Spring itu sendiri.
Kalau kamu baru mulai belajar Spring, coba luangkan waktu membuat satu project kecil dengan cara manual seperti di atas sebelum kembali ke Spring Boot. Rasanya akan sangat berbeda — dan pemahamanmu akan jauh lebih kokoh.
Kalau ada pertanyaan atau ingin diskusi lebih lanjut soal Spring Framework, silakan hubungi saya di mhyusufibrahim@gmail.com.

Comments
Post a Comment