Ketika aplikasi tumbuh besar, monolith sering dijadikan kambing hitam: dianggap lambat, kaku, dan sulit dikembangkan oleh banyak orang sekaligus. Padahal masalah utamanya biasanya bukan ukuran aplikasi, melainkan batas antar bagian kode yang tidak tegas sehingga semua komponen saling mengenal satu sama lain. Modular monolith menawarkan jalan tengah: aplikasi tetap menjadi satu deployment utuh, tetapi tersusun dari modul-modul dengan batas yang jelas dan dijaga secara otomatis. Pada chapter ini kita akan menyusun struktur modul, mendefinisikan public API tiap modul, lalu menulis pemeriksa sederhana yang memburu pelanggaran batas sebelum berkembang menjadi spaghetti code.

Monolit dengan Batas yang Jelas #

Pada layered architecture kita memotong kode secara horizontal berdasarkan peran teknis: satu folder Controller, satu folder Service, satu folder Repository. Akibatnya, satu fitur bisnis seperti “pesan barang” menyebar ke banyak folder, dan tidak ada satu pun tempat yang benar-benar “memiliki” fitur tersebut. Modular monolith memotong kode secara vertikal berdasarkan kapabilitas bisnis: modul Catalog memiliki controller, service, dan repository miliknya sendiri, begitu juga modul Ordering.

AspekLayered murniModular monolith
Cara memotong kodeHorizontal, per peran teknisVertikal, per kapabilitas bisnis
Contoh foldersrc/Controller, src/Servicesrc/Catalog, src/Ordering
Lokasi satu fiturMenyebar ke semua lapisanIdealnya utuh dalam satu modul
Risiko utamaLogika fitur tersebar sulit dilacakBatas modul melunak bila tidak dijaga

Pendekatan ini bukan teori laboratorium. Shopify menjalankan codebase Ruby on Rails raksasa yang disiplin dibagi menjadi modul-modul (mereka sendiri menyebutnya modular monolith), dan Shopware 6 membangun inti toko online-nya dari modul-modul di atas Symfony. Dibandingkan langsung melompat ke microservices seperti yang kita bahas pada chapter sebelumnya , modular monolith memberi disiplin yang mirip tanpa biaya operasional sistem terdistribusi.

Struktur Direktori Berbasis Modul #

Mari kita lihat bentuk konkret dari sebuah aplikasi toko online yang dipotong per modul:

text
src/
├── Catalog/
│   ├── PublicApi/
│   │   ├── ProductFinder.php
│   │   └── ProductPricing.php
│   ├── Controller/
│   ├── Service/
│   └── Repository/
├── Ordering/
│   ├── PublicApi/
│   │   └── OrderPlacement.php
│   ├── Controller/
│   ├── Service/
│   └── Repository/
├── Billing/
│   ├── PublicApi/
│   └── Service/
└── SharedKernel/
    ├── DomainEvent.php
    └── Uang.php

Setiap modul adalah mini aplikasi lengkap: Ordering berisi seluruh kode yang diperlukan untuk menangani pemesanan, dari route sampai query database. Folder PublicApi/ adalah satu-satunya pintu masuk resmi ke modul — kita akan bahas perannya sebentar lagi. Sementara itu, SharedKernel/ menampung tipe yang benar-benar dipakai bersama oleh semua modul.

Autoloading untuk struktur ini diatur lewat Composer dengan standar PSR-4, satu prefix per modul:

json
{
    "autoload": {
        "psr-4": {
            "App\\Catalog\\": "src/Catalog/",
            "App\\Ordering\\": "src/Ordering/",
            "App\\Billing\\": "src/Billing/",
            "App\\SharedKernel\\": "src/SharedKernel/"
        }
    }
}

Dengan pemetaan ini, class App\Catalog\Service\ProductService otomatis dicari di src/Catalog/Service/ProductService.php. Struktur folder, namespace, dan batas modul menjadi satu kesatuan yang sama — tidak ada modul yang “menyamar” lewat namespace asing.

Public API sebagai Kontrak Antar Modul #

Aturan intinya satu kalimat: modul lain hanya boleh bergantung pada interface di folder PublicApi/ milik sebuah modul. Implementasi konkret seperti SqlProductRepository boleh berubah kapan pun tanpa mengganggu pemanggil dari modul lain, karena pemanggil hanya mengenal kontrak ProductFinder. Aturan ini juga yang membuat modul bisa diuji secara terpisah: pengujian modul Ordering cukup menyediakan implementasi palsu dari ProductFinder.

Aturan hanya berguna jika ada yang menjalankannya. Kita bisa menulis pemeriksa otomatis yang membaca “manifest” — daftar class yang diekspos tiap modul — lalu memastikan semua yang diekspos memang berupa interface:

php
<?php

namespace Catalog\PublicApi {

    interface ProductFinder
    {
        public function productName(string $sku): ?string;
    }

    interface ProductPricing
    {
        public function retailPriceCents(string $sku): int;
    }
}

namespace Catalog\Internal {

    class SqlProductRepository
    {
    }
}

namespace App {

    final class PublicApiChecker
    {
        /**
         * @param array<string, list<string>> $manifest
         * @return list<string>
         */
        public function findViolations(array $manifest): array
        {
            $violations = [];
            foreach ($manifest as $module => $exposedClasses) {
                foreach ($exposedClasses as $fqcn) {
                    if (!interface_exists($fqcn)) {
                        $violations[] = "$module mengekspos $fqcn yang bukan interface";
                    }
                }
            }
            return $violations;
        }
    }

    $manifest = [
        'Catalog' => [
            'Catalog\PublicApi\ProductFinder',
            'Catalog\PublicApi\ProductPricing',
            'Catalog\Internal\SqlProductRepository',
        ],
    ];

    $checker = new PublicApiChecker();
    foreach ($checker->findViolations($manifest) as $violation) {
        echo "PELANGGARAN: $violation\n";
    }

    $manifestBersih = ['Catalog' => ['Catalog\PublicApi\ProductFinder']];
    $sisa = count($checker->findViolations($manifestBersih));
    echo "Manifest bersih: $sisa pelanggaran\n";
}

Output yang Dihasilkan:

text
PELANGGARAN: Catalog mengekspos Catalog\Internal\SqlProductRepository yang bukan interface
Manifest bersih: 0 pelanggaran

Mari kita bedah kode di atas:

  1. Blok namespace Catalog\PublicApi: dua interface yang memang ditujukan dikonsumsi modul lain. Nama namespace-nya eksplisit menyatakan niat tersebut.
  2. Blok namespace Catalog\Internal: class konkret penyimpanan data. Class ini detail implementasi yang tidak boleh bocor keluar modul.
  3. findViolations(): memeriksa setiap FQCN di manifest dengan interface_exists(). Class biasa dan trait akan mengembalikan false, sehingga tercatat sebagai pelanggaran.
  4. $manifest: dalam proyek nyata, daftar ini bisa diambil dengan memindai folder PublicApi/ tiap modul atau membaca berkas konfigurasi; intinya pemeriksa butuh peta “siapa mengekspos apa”.

Registry Modul untuk Menjaga Arah Dependensi #

Selain membatasi apa yang diekspos, kita juga perlu mengatur ke mana dependensi boleh mengarah. Tanpa aturan arah, modul akan saling memanggil sampai membentuk lingkaran yang mustahil dipisahkan. Solusinya: sebuah registry yang mendata modul beserta daftar dependensi yang diizinkan, lalu membandingkannya dengan dependensi nyata hasil pemindaian kode.

php
<?php

final class ModuleRegistry
{
    /** @var array<string, list<string>> */
    private array $allowed = [];

    public function register(string $module, array $allowedDependencies): void
    {
        $this->allowed[$module] = $allowedDependencies;
    }

    /**
     * @param list<string> $usedDependencies
     * @return list<string>
     */
    public function findViolations(string $module, array $usedDependencies): array
    {
        if (!isset($this->allowed[$module])) {
            return ["Modul '$module' belum terdaftar di registry."];
        }

        $violations = [];
        foreach ($usedDependencies as $dependency) {
            if (!in_array($dependency, $this->allowed[$module], true)) {
                $violations[] = "$module -> $dependency (tidak diizinkan)";
            }
        }

        return $violations;
    }
}

$registry = new ModuleRegistry();
$registry->register('SharedKernel', []);
$registry->register('Catalog', ['SharedKernel']);
$registry->register('Ordering', ['Catalog', 'SharedKernel']);
$registry->register('Billing', ['Ordering', 'SharedKernel']);

$dependensiNyata = [
    'Catalog' => ['SharedKernel'],
    'Ordering' => ['Catalog', 'SharedKernel'],
    'Billing' => ['Ordering', 'Catalog'],
];

$totalPelanggaran = 0;
foreach ($dependensiNyata as $module => $used) {
    foreach ($registry->findViolations($module, $used) as $violation) {
        echo "PELANGGARAN: $violation\n";
        $totalPelanggaran++;
    }
}

echo "Total pelanggaran: $totalPelanggaran\n";

Output yang Dihasilkan:

text
PELANGGARAN: Billing -> Catalog (tidak diizinkan)
Total pelanggaran: 1

Perhatikan pola pada data registry di atas: Ordering boleh membaca Catalog, tetapi Catalog tidak mencantumkan Ordering sebagai dependensi yang diizinkan. Dependensi hanya mengalir satu arah, sehingga siklus mustahil terbentuk secara resmi. Baris 'Billing' => ['Ordering', 'Catalog'] sengaja berisi pelanggaran: Billing ternyata membaca Catalog langsung, padahal jalur resminya adalah lewat public API Ordering — pemeriksa menangkapnya dan laporan mencatat satu pelanggaran.

Shared Kernel #

Tidak semua kode bisa dipotong rapi per modul. Tipe seperti Uang (nominal + mata uang) atau interface penanda DomainEvent dipakai hampir di semua tempat. Shared kernel adalah modul kecil yang menampung tipe-tipe tersebut, dan boleh dijadikan dependensi oleh semua modul:

php
<?php

namespace SharedKernel;

interface DomainEvent
{
    public function occurredAt(): \DateTimeImmutable;
}

final class Uang
{
    public function __construct(
        public readonly int $amountCents,
        public readonly string $currency = 'IDR',
    ) {}
}

Komunikasi Antar Modul dengan Event #

Modul Ordering selesai memproses pesanan, lalu apa? Billing perlu membuat invoice dan modul loyalitas perlu menambah poin. Cara paling mudah adalah panggilan langsung, tetapi cara tersebut menuntut Ordering mengenal semua modul lain:

php
<?php

// Panggilan langsung: Ordering mengenal Billing secara eksplisit.
// Jika Billing diubah atau dipindah, semua pemanggilan ikut rusak.
$billing->createInvoice($order);

// Event: Ordering hanya mengumumkan fakta, tidak peduli siapa mendengarkan.
$eventBus->dispatch(new OrderPlaced($orderId));

Dengan event, Ordering cukup mengumumkan fakta OrderPlaced tanpa tahu bahwa Billing dan modul loyalitas ada. Menambah konsumen baru tidak menyentuh satu baris pun kode Ordering. Kita akan bangun event bus yang bisa diuji secara lengkap pada chapter Event-Driven Architecture .

Jalur Keluar: Mengekstrak Modul Menjadi Service #

Nilai jual terbesar modular monolith adalah jalur keluarnya. Karena semua komunikasi sudah lewatan interface publik dan event, sebuah modul bisa “dipindahkan” keluar dari monolith dengan langkah yang jelas:

  1. Ganti implementasi interface modul tersebut dengan versi yang memanggil service terpisah lewat HTTP.
  2. Pindahkan tabel database milik modul ke database service baru itu.
  3. Ubah event dalam proses menjadi pesan antar service (misalnya lewat message queue).

Modul dengan batas yang bersih akan menjalani langkah-langkah tersebut relatif mulus; modul yang batasnya kotor akan menyeret puluhan dependensi tersembunyi ke mana-mana.

Ringkasan #

Modular monolith menggabungkan kesederhanaan operasional monolith dengan disiplin batas ala microservices: kode dipotong vertikal per kapabilitas bisnis, modul hanya mengekspor interface, arah dependensi dijaga registry, dan komunikasi lintas modul mengutamakan event. Pemeriksa otomatis membuat aturan-aturan tersebut hidup, bukan sekadar dokumen yang dilupakan. Begitu batas-batas ini berdiri, pertanyaan berikutnya muncul dengan alami: bagaimana modul-modul dan aplikasi klien lainnya seharusnya berkomunikasi lewat API yang dirancang dengan sadar? Itu pembahasan kita pada chapter API-First Development.

Referensi #