Kita sudah melihat kekuatan constructor injection: controller menyatakan kebutuhannya, pemanggil menyediakan. Tetapi coba bayangkan sisi framework — setiap request, framework harus membangun controller, yang butuh service, yang butuh repository, yang butuh koneksi database. Penyambungan manual seperti itu menulis ulang graf dependensi yang sama di setiap titik masuk aplikasi, dan graf itu tumbuh setiap kali fitur baru ditambahkan. Dependency container adalah mesin yang menyimpan resep pembuatan tiap objek dan menyusunnya secara otomatis. Pada chapter ini kita bangun container mini lengkap dengan resolusi malas, pembagian singleton, dan autowiring berbasis Reflection.
Masalah: Penyambungan Manual yang Meledak #
Kita pakai domain tiruan yang sederhana tapi merepresentasikan aplikasi nyata — lima kelas dengan graf dependensi berikut:
Diagram tersebut berbunyi: untuk membangun AuthService, siapa pun harus lebih dulu membangun UserRepository (yang butuh KoneksiDatabase) dan TokenService (yang butuh Mailer). Mari kita hitung akibatnya bila aplikasi punya dua titik masuk — web dan CLI — yang menyambung manual:
<?php
declare(strict_types=1);
require __DIR__ . '/layanan.php';
// Titik masuk "web" — menyalakan seluruh graf dependensi secara manual
$authWeb = new AuthService(
new UserRepository(new KoneksiDatabase()),
new TokenService(new Mailer()),
);
// Titik masuk "cli" — seluruh susunan yang sama ditulis ulang
$authCli = new AuthService(
new UserRepository(new KoneksiDatabase()),
new TokenService(new Mailer()),
);
echo 'Koneksi database terbuka: ', KoneksiDatabase::$jumlahKoneksi, "\n";
echo ($authWeb === $authCli) ? 'objek AuthService sama' : 'objek AuthService berbeda', "\n";Output yang Dihasilkan:
Koneksi database terbuka: 2
objek AuthService berbedaAngka pada output itu adalah inti masalahnya. Dua titik masuk membuka dua koneksi database padahal satu sudah cukup, dan kedua AuthService adalah objek berbeda. Perhatikan juga pola tulisannya: setiap new harus ditulis dalam urutan yang tepat, dari dalam ke luar. Dengan lima kelas ini masih terkendali; dengan tiga puluh kelas dan lima titik masuk, satu perubahan constructor (AuthService butuh Logger, misalnya) harus disalin ke semua tempat. Penyambungan manual tidak meledak secara mendadak — ia meledak perlahan lewat duplikasi.
Container sebagai Registri Factory #
Inti container mengejutkan sederhananya: sebuah array yang memetakan id layanan ke factory — closure yang tahu cara membuat objeknya. Method get() menjalankan factory; jika factory butuh layanan lain, factory menerima container itu sendiri sebagai argumen:
<?php
declare(strict_types=1);
class ContainerException extends RuntimeException
{
}
final class Container
{
/** @var array<string, callable(self): mixed> */
private array $factories = [];
/** @var array<string, true> */
private array $sharedIds = [];
/** @var array<string, mixed> */
private array $instances = [];
/** Mendaftarkan factory: setiap get() memanggilnya lagi. */
public function set(string $id, callable $factory): void
{
$this->factories[$id] = $factory;
unset($this->sharedIds[$id], $this->instances[$id]);
}
/** Mendaftarkan factory bersama: hasil get() pertama dipakai ulang. */
public function share(string $id, callable $factory): void
{
$this->factories[$id] = $factory;
$this->sharedIds[$id] = true;
unset($this->instances[$id]);
}
public function has(string $id): bool
{
return isset($this->factories[$id]);
}
public function get(string $id): mixed
{
if (array_key_exists($id, $this->instances)) {
return $this->instances[$id];
}
if (!isset($this->factories[$id])) {
throw new ContainerException("Layanan \"{$id}\" belum terdaftar di container.");
}
$object = ($this->factories[$id])($this);
if (isset($this->sharedIds[$id])) {
$this->instances[$id] = $object;
}
return $object;
}
// autowire() menyusul pada bagian Autowiring di bawah
}Empat keputusan desain pada class tersebut perlu dicermati:
set()danshare()sama-sama menyimpan factory — bedanya hanyashare()menandai id sebagai bersama. Pemisahan ini akan terlihat penuh pada demo berikutnya.get()memeriksa cache$instanceslebih dulu — hasilshare()yang sudah dibuat langsung dikembalikan tanpa memanggil factory lagi.- Factory menerima container:
($this->factories[$id])($this)— inilah yang membuat factory bisa saling merujuk: factoryUserRepositorytinggal memanggil$c->get(KoneksiDatabase::class). - Id yang tidak terdaftar melempar
ContainerException— kegagalan terjadi cepat dan dengan pesan jelas, bukanErrormisterius di tempat lain.
Karena factory berupa closure, container ini sekaligus mendapat properti penting: resolusi malas (lazy). Tidak ada satu objek pun dibuat sebelum get() dipanggil:
<?php
declare(strict_types=1);
require __DIR__ . '/container.php';
require __DIR__ . '/layanan.php';
$container = new Container();
$jumlahPanggilan = 0;
$container->set(Mailer::class, function () use (&$jumlahPanggilan): Mailer {
$jumlahPanggilan++;
return new Mailer();
});
echo "Sebelum get(): {$jumlahPanggilan} kali factory dipanggil\n";
$mailerA = $container->get(Mailer::class);
$mailerB = $container->get(Mailer::class);
echo "Setelah 2x get(): {$jumlahPanggilan} kali factory dipanggil\n";
echo ($mailerA === $mailerB) ? 'objek sama' : 'objek berbeda', "\n";Output yang Dihasilkan:
Sebelum get(): 0 kali factory dipanggil
Setelah 2x get(): 2 kali factory dipanggil
objek berbedaOutput tersebut membuktikan dua sifat sekaligus. Pertama, mendaftarkan layanan itu gratis: counter tetap nol setelah set() karena closure hanya disimpan, belum dijalankan — aplikasi CLI yang hanya memakai dua layanan tidak perlu membayar biaya pembuatan tiga puluh layanan lain. Kedua, set() memberi scope factory: dua panggilan get() menjalankan factory dua kali dan menghasilkan objek berbeda.
Singleton vs Factory: Method share #
Banyak layanan sebaiknya cukup satu instance untuk seluruh request: koneksi database, konfigurasi, klien HTTP. Untuk itu ada share() — factory tetap dipanggil sekali, hasilnya disimpan di $instances dan dibagikan. Perbandingannya langsung terlihat pada counter koneksi:
<?php
declare(strict_types=1);
require __DIR__ . '/container.php';
require __DIR__ . '/layanan.php';
$container = new Container();
// Koneksi dibuat SEKALI dan dibagikan; repository dibuat BARU tiap permintaan
$container->share(KoneksiDatabase::class, fn (): KoneksiDatabase => new KoneksiDatabase());
$container->set(
UserRepository::class,
fn (Container $c): UserRepository => new UserRepository($c->get(KoneksiDatabase::class)),
);
$repoA = $container->get(UserRepository::class);
$repoB = $container->get(UserRepository::class);
echo 'Koneksi database terbuka: ', KoneksiDatabase::$jumlahKoneksi, "\n";
echo ($repoA === $repoB) ? 'repo sama' : 'repo berbeda', "\n";Output yang Dihasilkan:
Koneksi database terbuka: 1
repo berbedaBandingkan dengan masalah di awal chapter: jumlah koneksi turun dari dua menjadi satu, meskipun UserRepository dibangun dua kali. Factory UserRepository memanggil $c->get(KoneksiDatabase::class) — panggilan kedua berbuah cache, bukan koneksi baru. Inilah komposisi yang menjadi jawaban atas penyambungan manual: satu tempat (container) tahu bahwa koneksi bersifat singleton, dan semua pihak lain tinggal meminta.
Autowiring dengan Reflection #
Mendaftarkan factory untuk tiga puluh layanan tetap menulis tiga puluh closure. Autowiring menghapus pekerjaan itu: container membaca sendiri constructor sebuah class lewat extension Reflection, lalu merekursif menyelesaikan tiap parameternya:
<?php
declare(strict_types=1);
final class Container
{
// ... set(), share(), get() seperti blok sebelumnya ...
/** Membangun object dengan membaca constructor-nya lewat Reflection. */
public function autowire(string $class): object
{
$reflector = new ReflectionClass($class);
if (!$reflector->isInstantiable()) {
throw new ContainerException("Class {$class} tidak bisa diinstansiasi.");
}
$constructor = $reflector->getConstructor();
if ($constructor === null) {
return $reflector->newInstance();
}
$arguments = [];
foreach ($constructor->getParameters() as $parameter) {
$type = $parameter->getType();
if ($type instanceof ReflectionNamedType && !$type->isBuiltin()) {
$arguments[] = $this->resolve($type->getName());
continue;
}
if ($parameter->isDefaultValueAvailable()) {
$arguments[] = $parameter->getDefaultValue();
continue;
}
throw new ContainerException(
"Parameter \${$parameter->getName()} pada {$class} tidak bisa diselesaikan."
);
}
return $reflector->newInstanceArgs($arguments);
}
/** get() bila terdaftar, autowire bila class tersebut belum terdaftar. */
private function resolve(string $id): mixed
{
if ($this->has($id)) {
return $this->get($id);
}
return $this->autowire($id);
}
}Alur autowire() memiliki lima langkah yang perlu dibedah:
new ReflectionClass($class)— membuat cermin metadata class: constructor, parameter, hingga apakah class bisa diinstansiasi (interface dan abstract class tidak bisa).$constructor->getParameters()— mengambil daftar parameter constructor lengkap dengan tipe masing-masing.$type instanceof ReflectionNamedType && !$type->isBuiltin()— hanya parameter bertipe class (bukanint,string,array) yang bisa diselesaikan lewat container; parameter bertipe class itulah yang dikirim keresolve().isDefaultValueAvailable()— parameter skalar dengan nilai bawaan (int $timeout = 30) diisi nilainya, bukan dianggap galat.newInstanceArgs($arguments)— akhirnya objek dibangun dengan daftar argumen yang telah disusun.
resolve() adalah jembatan antara dua dunia: id yang terdaftar dilayani get() (menghormati share dan factory), sisanya direkursikan lewat autowire(). Mari kita buktikan semuanya bekerja:
<?php
declare(strict_types=1);
require __DIR__ . '/container.php';
require __DIR__ . '/layanan.php';
$container = new Container();
$container->share(KoneksiDatabase::class, fn (): KoneksiDatabase => new KoneksiDatabase());
$container->share(Mailer::class, fn (): Mailer => new Mailer());
// UserRepository dan AuthService TIDAK terdaftar — autowire menyelesaikannya
$auth = $container->autowire(AuthService::class);
echo get_class($auth), "\n";
echo 'Koneksi database terbuka: ', KoneksiDatabase::$jumlahKoneksi, "\n";
try {
$container->get('layanan.tidak.ada');
} catch (ContainerException $e) {
echo 'Galat: ', $e->getMessage(), "\n";
}Output yang Dihasilkan:
AuthService
Koneksi database terbuka: 1
Galat: Layanan "layanan.tidak.ada" belum terdaftar di container.Kita hanya mendaftarkan dua layanan, tetapi autowire(AuthService::class) berhasil membangun seluruh graf: Reflection menemukan bahwa AuthService butuh UserRepository dan TokenService, lalu merekursif membangun keduanya tanpa registrasi. Counter koneksi tetap satu — dependency yang terdaftar sebagai shared tetap dihormati di kedalaman rekursi mana pun. Dan permintaan id yang memang tidak ada tetap ditolak dengan pesan yang jelas; autowire bukan sulap yang menebak nama layanan bebas.
PSR-11 dan Jebakan Service Locator #
Ekosistem PHP punya standar antarmuka container bernama PSR-11: interface ContainerInterface dengan dua method saja — get(string $id) dan has(string $id). Container kita sudah bentuknya mirip; framework besar seperti Symfony dan Laravel menyediakan implementasi yang jauh lebih lengkap di atas kontrak serupa. Tetapi ada satu jebakan klasik yang harus disebutkan sebelum Anda mulai memakai container di mana-mana — service locator:
<?php
declare(strict_types=1);
// Service Locator: controller menyimpan container dan mengambil sendiri
final class OrderControllerLocator
{
public function __construct(private readonly Container $container) {}
public function show(int $id): string
{
// dependensi tersembunyi — tidak terlihat dari signature
$users = $this->container->get(UserRepository::class);
return 'ok';
}
}
// Dependency Injection: kebutuhan dinyatakan eksplisit di constructor
final class OrderControllerInjection
{
public function __construct(private readonly UserRepository $users) {}
public function show(int $id): string
{
// dependensi terlihat jelas — mudah dibaca dan disuntik pengganti
return 'ok';
}
}Prinsip DI di balik semua ini — mengapa menyuntik lebih baik daripada mengambil — sudah kita bahas tuntas pada chapter Dependency Injection ; container hanyalah otomasi dari prinsip tersebut.
Ringkasan #
Container menyelesaikan ledakan penyambungan manual dengan tiga mekanisme berlapis: registri factory yang menghilangkan duplikasi new, scope share() yang mengatur layanan singleton tanpa pola Singleton klasik, dan autowiring berbasis Reflection yang membaca constructor sehingga mayoritas layanan tidak perlu didaftarkan sama sekali. Resolusi yang malas membuat biaya hanya dibayar saat layanan benar-benar diminta. Satu peringatan melekat: container adalah alat penyusun di titik masuk, bukan layanan serba-bisa yang disuntikkan ke mana-mana. Dengan objek-objek kini tersusun otomatis, satu pertanyaan tersisa untuk melengkapi framework mini kita: dari mana semua nilai pengaturannya berasal? Jawabannya ada pada chapter Configuration System
.
Referensi #
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
container.php
Unduh berkas<?php
declare(strict_types=1);
/**
* Container mini — inti chapter Dependency Container.
*
* Registri factory dengan dukungan share (singleton) dan autowire
* berbasis Reflection; contoh-contoh chapter memuat berkas ini
* dengan require.
*/
class ContainerException extends RuntimeException
{
}
final class Container
{
/** @var array<string, callable(self): mixed> */
private array $factories = [];
/** @var array<string, true> */
private array $sharedIds = [];
/** @var array<string, mixed> */
private array $instances = [];
/** Mendaftarkan factory: setiap get() memanggilnya lagi. */
public function set(string $id, callable $factory): void
{
$this->factories[$id] = $factory;
unset($this->sharedIds[$id], $this->instances[$id]);
}
/** Mendaftarkan factory bersama: hasil get() pertama dipakai ulang. */
public function share(string $id, callable $factory): void
{
$this->factories[$id] = $factory;
$this->sharedIds[$id] = true;
unset($this->instances[$id]);
}
public function has(string $id): bool
{
return isset($this->factories[$id]);
}
public function get(string $id): mixed
{
if (array_key_exists($id, $this->instances)) {
return $this->instances[$id];
}
if (!isset($this->factories[$id])) {
throw new ContainerException("Layanan \"{$id}\" belum terdaftar di container.");
}
$object = ($this->factories[$id])($this);
if (isset($this->sharedIds[$id])) {
$this->instances[$id] = $object;
}
return $object;
}
/** Membangun object dengan membaca constructor-nya lewat Reflection. */
public function autowire(string $class): object
{
$reflector = new ReflectionClass($class);
if (!$reflector->isInstantiable()) {
throw new ContainerException("Class {$class} tidak bisa diinstansiasi.");
}
$constructor = $reflector->getConstructor();
if ($constructor === null) {
return $reflector->newInstance();
}
$arguments = [];
foreach ($constructor->getParameters() as $parameter) {
$type = $parameter->getType();
if ($type instanceof ReflectionNamedType && !$type->isBuiltin()) {
$arguments[] = $this->resolve($type->getName());
continue;
}
if ($parameter->isDefaultValueAvailable()) {
$arguments[] = $parameter->getDefaultValue();
continue;
}
throw new ContainerException(
"Parameter \${$parameter->getName()} pada {$class} tidak bisa diselesaikan."
);
}
return $reflector->newInstanceArgs($arguments);
}
/** get() bila terdaftar, autowire bila berupa class yang belum terdaftar. */
private function resolve(string $id): mixed
{
if ($this->has($id)) {
return $this->get($id);
}
return $this->autowire($id);
}
}
layanan.php
Unduh berkas<?php
declare(strict_types=1);
/**
* Kelas domain tiruan untuk demo chapter Dependency Container.
*
* Grafnya: AuthService -> UserRepository -> KoneksiDatabase
* -> TokenService -> Mailer
*/
final class KoneksiDatabase
{
public static int $jumlahKoneksi = 0;
public function __construct()
{
self::$jumlahKoneksi++;
}
}
final class UserRepository
{
public function __construct(private readonly KoneksiDatabase $koneksi) {}
}
final class Mailer
{
}
final class TokenService
{
public function __construct(private readonly Mailer $mailer) {}
}
final class AuthService
{
public function __construct(
private readonly UserRepository $users,
private readonly TokenService $tokens,
) {}
}