All writing
3 min read

34 modul dalam satu monolith: pelajaran dari nwidart/laravel-modules

ERP 34 modul dan School HRIS 9 package group jalan dalam satu Laravel app. Begini cara nwidart-style modules bikin itu tidak jadi spaghetti.

Context

Dua project terakhir yang aku pegang sama-sama modular: ERP dengan 34 modul (Accounting, POS, Commerce, Payroll, CRM, dan lainnya), dan School HRIS dengan 9 package group (Kepegawaian, Presensi, Akademik, Al-Quran, dan lainnya).

Keduanya jalan dalam satu Laravel app. Satu codebase, satu deploy. Bukan microservices, bukan multi-repo.

Pertanyaan yang selalu muncul: gimana caranya 34 modul tidak saling injek jadi spaghetti?

Options yang dipertimbangkan

Option A: Struktur default Laravel

Semua model di app/Models, semua controller di app/Http/Controllers. Folder dibagi per layer, bukan per domain.

  • Pro: zero setup, semua dev Laravel familiar
  • Con: di 34 modul, app/Models isinya ratusan file. Nyari file = ctrl+P dan berdoa
  • Con: tidak ada boundary. Controller modul Sale bebas query model modul Payroll langsung

Option B: Laravel packages via Composer path repository

Tiap modul jadi package Composer sendiri di folder packages/.

  • Pro: isolation paling kuat, bisa dipublish terpisah kalau perlu
  • Con: overhead Composer per modul. Update satu file = dump-autoload
  • Con: tidak ada integrasi bawaan dengan Filament. Panel provider harus di-wire manual per package

Option C: nwidart-style modules (yang dipilih)

Pakai nwidart/laravel-modules dan variannya (coolsam/modules untuk integrasi Filament).

  • Pro: tiap modul self-contained — migrations, models, Filament resources, service provider, routes, semua dalam satu folder
  • Pro: coolsam/modules kasih first-class Filament integration. Resource auto-discover per modul
  • Con: konvensi tambahan yang harus dipahami dev baru

Struktur satu modul

Modules/
  Sale/
    Database/Migrations/
    Models/
    Filament/Resources/
    Http/Controllers/
    Providers/SaleServiceProvider.php
    Routes/

Modul yang tidak dipakai client tinggal di-disable di config. Tidak perlu hapus kode.

Komunikasi antar modul: events, bukan direct call

Aturan yang aku pegang keras: modul tidak boleh call model modul lain langsung. Lewat events.

Contoh nyata dari School HRIS: modul Kepegawaian fire EmployeeHired event. Modul Presensi listen dan auto-buat attendance profile untuk karyawan baru itu.

// Modules/Kepegawaian — fire event, tidak tahu siapa yang dengar
event(new EmployeeHired($employee));

// Modules/Presensi — listener
class CreateAttendanceProfile
{
    public function handle(EmployeeHired $event): void
    {
        AttendanceProfile::firstOrCreate([
            'employee_id' => $event->employee->id,
        ]);
    }
}

Kepegawaian tidak tahu Presensi ada. Presensi tidak tahu detail Kepegawaian. Kalau modul Presensi di-disable, Kepegawaian tetap jalan tanpa error.

Ini juga menghindari N+1 tersembunyi: listener bisa queue job, query dilakukan sekali per event, bukan berulang di controller.

Consequences

Impact nyata yang aku rasakan:

  • Onboarding dev baru: butuh waktu ekstra untuk paham konvensi modul. Setelah paham, navigasi codebase jadi lebih cepat daripada struktur default — semua yang berhubungan ada di satu folder.
  • Testing: tiap modul bisa ditest isolated. Test modul Accounting tidak perlu boot modul Commerce.
  • Client flexibility: client ERP bisa pilih subset modul. Modul yang tidak dibeli tidak aktif, tapi kodenya tetap satu repo.
  • Refactor cost: rename concept lintas modul tetap sakit. Event contract jadi public API yang harus dijaga.

Trade-off yang aku terima

Modular monolith bukan silver bullet:

  • Boundary dijaga oleh disiplin, bukan oleh network. Tidak ada yang nge-block developer nakal query lintas modul. Solusiku: code review + dokumentasi konvensi, bukan tooling.
  • Satu modul error fatal tetap bisa jatuhkan satu app. Mitigasi: Sentry per modul tagging, Telescope aktif di staging.
  • Shared kernel (user, auth, master data) tetap ada dan jadi titik coupling tertinggi.

What I’d do differently

  • Definisikan event contract sejak awal sebagai class final dengan property readonly. Refactor event payload di tengah jalan menyakitkan karena listener tersebar di banyak modul.
  • Setup modul health check lebih awal — cara cepat tahu modul mana yang aktif di environment mana.

References