Tampilan
Kenapa penting
Setiap class Spring Boot yang kamu tulis — controller, service, repository, konfigurasi — dikelola oleh IoC container. Kalau kamu tidak paham cara container membuat dan menyambungkan object, error seperti "required a single bean, but 2 were found", data pelanggan yang "bocor" ke pelanggan lain, atau test yang mustahil ditulis tanpa menyalakan seluruh aplikasi akan terasa seperti sihir. Dependency Injection juga topik interview yang hampir pasti muncul untuk posisi backend Java. Bab ini menjelaskan apa yang dilakukan container untukmu dan aturan yang harus kamu ikuti supaya hasilnya aman.
Konsep inti
Masalah: new di mana-mana
Bayangkan service checkout yang membuat sendiri dependency-nya:
java
// Potongan — contoh desain yang bermasalah
class CheckoutService {
private final BankTransferGateway gateway = new BankTransferGateway();
private final OrderRepository orders = new OrderRepository("jdbc:postgresql://localhost/shop");
String checkout(Order order) {
orders.save(order);
return gateway.charge(order.id(), order.total());
}
}Masalahnya:
CheckoutServiceterikat ke implementasi konkret. Ganti ke e-wallet berarti mengedit class ini.- Unit test tidak bisa mengganti gateway dengan versi palsu — test akan mencoba "membayar" sungguhan dan butuh database hidup.
- Detail konfigurasi (URL database) bocor ke logika bisnis, dan setiap class yang butuh repository membuat koneksinya sendiri.
Ingat Dependency Inversion Principle dari bab prinsip desain di M02: modul tingkat tinggi bergantung pada abstraksi, bukan implementasi. Langkah pertamanya: bergantung pada interface, dan terima dependency dari luar lewat constructor.
java
interface PaymentGateway {
String charge(String orderId, long amount);
}
class BankTransferGateway implements PaymentGateway {
public String charge(String orderId, long amount) {
return "BANK-" + orderId;
}
}
class FakePaymentGateway implements PaymentGateway {
int calls = 0;
public String charge(String orderId, long amount) {
calls++;
return "FAKE-" + orderId;
}
}
class CheckoutService {
private final PaymentGateway gateway; // bergantung pada abstraksi
CheckoutService(PaymentGateway gateway) { // dependency masuk lewat constructor
this.gateway = gateway;
}
String checkout(String orderId, long amount) {
if (amount <= 0) {
throw new IllegalArgumentException("Jumlah harus positif");
}
return gateway.charge(orderId, amount);
}
}
public class ManualWiring {
public static void main(String[] args) {
// "Composition root": satu-satunya tempat yang tahu implementasi konkret
CheckoutService production = new CheckoutService(new BankTransferGateway());
System.out.println(production.checkout("ORD-1", 150_000));
FakePaymentGateway fake = new FakePaymentGateway();
CheckoutService underTest = new CheckoutService(fake);
System.out.println(underTest.checkout("ORD-2", 75_000) + " calls=" + fake.calls);
}
}Output:
text
BANK-ORD-1
FAKE-ORD-2 calls=1Inilah Dependency Injection (DI) tanpa framework: object tidak membuat dependency-nya, tetapi menerimanya. Yang tersisa hanyalah pekerjaan merakit di main. Untuk tiga class itu mudah; untuk ratusan class dengan dependency berlapis, merakit manual menjadi beban — dan di situlah Spring masuk.
IoC container dan ApplicationContext
Inversion of Control (IoC) berarti kendali pembuatan dan penyambungan object dibalik: bukan kodemu yang memanggil new dan merakit, tetapi container yang melakukannya. Di Spring, container itu adalah ApplicationContext. Object yang dikelola container disebut bean.
Saat SpringApplication.run(...) dipanggil, container:
- memindai package (component scan) dan membaca class konfigurasi untuk mengumpulkan definisi bean,
- menentukan urutan pembuatan berdasarkan dependency,
- membuat setiap bean, meng-inject dependency-nya, dan menjalankan callback lifecycle,
- menyimpan bean itu untuk dibagikan ke siapa pun yang membutuhkan.
SpringApplication.run mengembalikan context-nya, jadi kamu bisa mengintip isinya — berguna untuk belajar, tetapi jarang dipakai di kode aplikasi karena injeksi lewat constructor jauh lebih rapi:
java
// Potongan — main class
ConfigurableApplicationContext context = SpringApplication.run(KatalogApplication.class, args);
CheckoutService service = context.getBean(CheckoutService.class);Bean dan stereotype
Cara paling umum mendaftarkan bean: beri annotation stereotype pada class. Semuanya turunan @Component, dan semuanya ditemukan component scan:
| Annotation | Dipakai untuk |
|---|---|
@Component | komponen umum |
@Service | logika bisnis (secara teknis sama dengan @Component, tetapi memperjelas peran) |
@Repository | akses data; Spring juga menerjemahkan exception persistence menjadi exception Spring |
@RestController | menerima request HTTP dan mengembalikan data |
@Configuration | class berisi method @Bean (lihat di bawah) |
java
// Potongan — versi Spring dari contoh di atas
@Component
class BankTransferGateway implements PaymentGateway {
public String charge(String orderId, long amount) {
return "BANK-" + orderId;
}
}
@Service
class CheckoutService {
private final PaymentGateway gateway;
CheckoutService(PaymentGateway gateway) { // Spring mencari bean bertipe PaymentGateway
this.gateway = gateway;
}
// ...
}Tidak ada new dan tidak ada main yang merakit. Spring melihat constructor CheckoutService butuh PaymentGateway, menemukan satu bean yang cocok (BankTransferGateway), lalu menyuntikkannya. Bean diberi nama default berupa nama class dengan huruf awal kecil: bankTransferGateway.
Constructor injection — dan kenapa bukan field injection
Ada tiga cara injeksi. Pakai constructor injection sebagai default:
java
// Potongan — field injection (hindari)
@Service
class CheckoutService {
@Autowired
private PaymentGateway gateway; // tidak bisa final, tersembunyi dari luar
}| Aspek | Constructor injection | Field injection |
|---|---|---|
Field final | bisa — object immutable setelah dibuat | tidak bisa |
| Dependency terlihat | jelas di signature constructor | tersembunyi di field private |
| Unit test | new CheckoutService(fake) — tanpa Spring | butuh Spring atau reflection untuk mengisi field |
| Dependency wajib | object tidak bisa dibuat tanpa dependency | bisa null bila dibuat di luar container |
| Sinyal desain | constructor dengan 8 parameter terasa "bau" | mudah menambah @Autowired tanpa disadari |
Bila class hanya punya satu constructor, Spring memakainya otomatis — @Autowired tidak perlu ditulis. Setter injection masih berguna untuk dependency yang benar-benar opsional, tetapi jarang dibutuhkan.
@Configuration dan @Bean
Stereotype hanya bisa ditempel pada class milikmu. Untuk object dari library atau JDK — atau object yang butuh logika pembuatan khusus — pakai method @Bean di class @Configuration:
java
// Potongan
import java.net.http.HttpClient;
import java.time.Clock;
import java.time.Duration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class InfrastructureConfig {
@Bean
Clock clock() {
return Clock.systemUTC();
}
@Bean
HttpClient httpClient() {
return HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(3))
.build();
}
}Return value setiap method didaftarkan sebagai bean (namanya = nama method), dan kini service mana pun bisa meminta Clock atau HttpClient lewat constructor. Di test, kamu cukup memberi Clock.fixed(...) agar waktu bisa diprediksi.
Beberapa implementasi: @Primary, @Qualifier, dan List
Tambahkan implementasi kedua:
java
// Potongan
@Component
class WalletGateway implements PaymentGateway {
public String charge(String orderId, long amount) {
return "EWALLET-" + orderId;
}
}Sekarang aplikasi gagal start. Spring Boot menjelaskan bahwa parameter constructor CheckoutService butuh satu bean PaymentGateway, tetapi menemukan dua (bankTransferGateway dan walletGateway), lalu menyarankan @Primary, @Qualifier, atau menerima banyak bean. Ketiga solusinya:
java
// Potongan — pilihan 1: tetapkan default
@Primary
@Component
class BankTransferGateway implements PaymentGateway { /* ... */ }
// Potongan — pilihan 2: pilih eksplisit di titik injeksi
@Service
class RefundService {
private final PaymentGateway gateway;
RefundService(@Qualifier("walletGateway") PaymentGateway gateway) {
this.gateway = gateway;
}
}
// Potongan — pilihan 3: terima semuanya
@Service
class PaymentHealthCheck {
private final List<PaymentGateway> gateways; // berisi semua bean PaymentGateway
PaymentHealthCheck(List<PaymentGateway> gateways) {
this.gateways = gateways;
}
}@Primary cocok bila ada satu pilihan "normal". @Qualifier untuk pengecualian di titik tertentu. List (atau Map<String, PaymentGateway> dengan nama bean sebagai kunci) untuk pola strategy — lihat contoh di bawah.
Scope bean dan bahaya state di singleton
Scope menentukan berapa instance yang dibuat:
| Scope | Instance | Cara menandai |
|---|---|---|
singleton (default) | satu per container, dibagi semua pemakai | tidak perlu apa-apa |
prototype | baru setiap kali bean diminta ke container | @Scope("prototype") |
request (aplikasi web) | satu per request HTTP | @RequestScope |
Hampir semua bean di aplikasi backend adalah singleton — dan itu baik, asalkan bean tidak menyimpan state yang berubah per request. Server web melayani banyak request secara bersamaan di thread berbeda (detail concurrency dibahas di level Advanced), dan semuanya memakai instance bean yang sama. Bahkan tanpa thread, efeknya sudah terlihat:
java
import java.util.List;
class CartCalculator {
private long total; // BUG: state per-request di object bersama
long sum(List<Long> prices) {
for (long price : prices) {
total += price;
}
return total;
}
}
class SafeCartCalculator {
long sum(List<Long> prices) {
long total = 0; // variabel lokal: milik satu pemanggilan saja
for (long price : prices) {
total += price;
}
return total;
}
}
public class SingletonState {
public static void main(String[] args) {
CartCalculator shared = new CartCalculator(); // seperti bean singleton
System.out.println("Budi: " + shared.sum(List.of(50_000L, 20_000L)));
System.out.println("Sari: " + shared.sum(List.of(10_000L)));
SafeCartCalculator safe = new SafeCartCalculator();
System.out.println("Budi: " + safe.sum(List.of(50_000L, 20_000L)));
System.out.println("Sari: " + safe.sum(List.of(10_000L)));
}
}Output:
text
Budi: 70000
Sari: 80000
Budi: 70000
Sari: 10000Sari ditagih 80.000 untuk belanja 10.000 karena total Budi masih tersimpan di field. Aturannya: field di bean singleton hanya untuk dependency dan konfigurasi (biasanya final); data per request disimpan di variabel lokal atau dikirim lewat parameter.
Satu jebakan lagi: bean prototype yang di-inject ke singleton hanya dibuat sekali, karena constructor singleton hanya dipanggil sekali. Bila kamu butuh instance baru setiap pemakaian, inject ObjectProvider<T> lalu panggil getObject() saat dibutuhkan.
Lifecycle dan @PostConstruct
Urutan hidup sebuah bean: constructor → injeksi dependency → @PostConstruct → siap dipakai → @PreDestroy saat container ditutup. @PostConstruct (dari jakarta.annotation) cocok untuk validasi atau inisialisasi ringan yang butuh dependency yang sudah lengkap:
java
// Potongan
import jakarta.annotation.PostConstruct;
@Component
class ShippingRateTable {
private final Map<String, Long> ratePerKg = new HashMap<>();
@PostConstruct
void load() {
ratePerKg.put("JAWA", 9_000L);
ratePerKg.put("SUMATERA", 14_000L);
if (ratePerKg.isEmpty()) {
throw new IllegalStateException("Tabel ongkir kosong");
}
}
long rateFor(String region) {
return ratePerKg.getOrDefault(region, 25_000L);
}
}Bila @PostConstruct melempar exception, aplikasi gagal start — lebih baik gagal di awal daripada gagal saat pelanggan checkout. Map di sini adalah state yang hanya diisi sekali saat start lalu dibaca, bukan state per request, jadi aman.
Contoh di dunia backend
Toko online mendukung beberapa metode bayar. Daripada switch raksasa di service, setiap gateway adalah bean, dan router memilih gateway berdasarkan metode yang dikirim klien. Menambah metode baru cukup menambah satu class — router tidak perlu diubah (Open/Closed Principle dari M02).
java
// Potongan — aplikasi checkout
public enum PaymentMethod { BANK_TRANSFER, E_WALLET, VIRTUAL_ACCOUNT }
public interface PaymentGateway {
PaymentMethod method();
String charge(String orderId, long amount);
}
@Component
class BankTransferGateway implements PaymentGateway {
public PaymentMethod method() { return PaymentMethod.BANK_TRANSFER; }
public String charge(String orderId, long amount) { return "BANK-" + orderId; }
}
@Component
class WalletGateway implements PaymentGateway {
public PaymentMethod method() { return PaymentMethod.E_WALLET; }
public String charge(String orderId, long amount) { return "EWALLET-" + orderId; }
}
@Service
public class PaymentRouter {
private final Map<PaymentMethod, PaymentGateway> gateways;
private final Clock clock;
PaymentRouter(List<PaymentGateway> all, Clock clock) { // semua gateway + bean Clock dari @Configuration
this.gateways = new EnumMap<>(PaymentMethod.class);
for (PaymentGateway gateway : all) {
if (gateways.putIfAbsent(gateway.method(), gateway) != null) {
throw new IllegalStateException("Gateway ganda untuk " + gateway.method());
}
}
this.clock = clock;
}
public String pay(String orderId, long amount, PaymentMethod method) {
PaymentGateway gateway = gateways.get(method);
if (gateway == null) {
throw new IllegalArgumentException("Metode bayar belum didukung: " + method);
}
return gateway.charge(orderId, amount) + "@" + Instant.now(clock);
}
}Perhatikan semua field final dan diisi sekali di constructor: PaymentRouter singleton yang aman dipakai banyak request sekaligus. Unit test-nya tidak butuh Spring sama sekali:
java
// Potongan — unit test
PaymentRouter router = new PaymentRouter(
List.of(new BankTransferGateway()),
Clock.fixed(Instant.parse("2026-10-23T00:00:00Z"), ZoneOffset.UTC));
assertThat(router.pay("ORD-9", 50_000, PaymentMethod.BANK_TRANSFER))
.isEqualTo("BANK-ORD-9@2026-10-23T00:00:00Z");Pertanyaan interview
1. Apa itu IoC dan Dependency Injection, dan apa manfaatnya?
IoC berarti kendali pembuatan dan penyambungan object diserahkan ke container, bukan dilakukan kode aplikasi dengan new. DI adalah teknik yang dipakai: object menerima dependency dari luar (biasanya lewat constructor). Manfaatnya: class bergantung pada abstraksi, implementasi mudah diganti, unit test bisa memakai test double, dan konfigurasi terpusat.
2. Kenapa constructor injection lebih disarankan daripada field injection?
Dependency terlihat jelas di constructor, field bisa final sehingga object immutable, object tidak mungkin dibuat tanpa dependency wajibnya, dan unit test cukup memanggil new dengan test double tanpa menyalakan Spring. Constructor yang terlalu banyak parameter juga menjadi sinyal bahwa class memikul terlalu banyak tanggung jawab.
3. Apakah bean Spring thread-safe?
Tidak otomatis. Bean default ber-scope singleton dan dipakai bersama oleh semua request yang berjalan bersamaan. Bean aman bila stateless — field hanya berisi dependency/konfigurasi final, dan data per request ada di variabel lokal atau parameter. Menyimpan data request di field singleton menyebabkan data bocor antarpengguna.
Jebakan umum
- Menyimpan data per request di field bean singleton (user aktif, total keranjang). Data bocor ke request lain. Pakai variabel lokal atau parameter.
- Field injection dengan
@Autowired. Dependency tersembunyi dan test sulit. Pakai constructor dengan fieldfinal. - Dua implementasi satu interface tanpa penentu. Aplikasi gagal start; tambahkan
@Primary,@Qualifier, atau injectList. - Memanggil
newuntuk class yang seharusnya bean. Object hasilnewdi luar container tidak mendapat injeksi — dependency-nyanull. Biarkan Spring yang membuat, minta lewat constructor. - Mengira prototype yang di-inject ke singleton akan selalu baru. Ia dibuat sekali; pakai
ObjectProvider<T>. - Kerja berat atau panggilan jaringan di
@PostConstruct. Memperlambat atau menggagalkan start; simpan untuk inisialisasi ringan dan validasi.
Ringkasan
- DI: object menerima dependency dari luar; IoC: container (
ApplicationContext) yang membuat dan menyambungkan bean. - Daftarkan bean dengan stereotype (
@Component,@Service,@Repository,@RestController) atau method@Beandi@Configurationuntuk class library/JDK. - Constructor injection + field
finaladalah default; satu constructor tidak butuh@Autowired. - Banyak implementasi satu interface:
@Primary,@Qualifier, atau injectList/Mapuntuk pola strategy. - Singleton adalah scope default — jaga bean tetap stateless; prototype dan request scope untuk kasus khusus.
- Lifecycle: constructor → injeksi →
@PostConstruct→ dipakai →@PreDestroy.