Jawaban singkat untuk pertanyaan yang paling sering diajukan pembaca tentang topik ini.
01Apa itu modular monolith?
Modular monolith adalah satu aplikasi deployable yang kodenya dibagi menjadi module dengan boundary yang ditegakkan. Setiap module menyediakan public API kecil, menyembunyikan isi dalamnya, dan memiliki datanya sendiri. Antar module saling memanggil lewat facade atau event in-process, bukan menyentuh kode atau tabel module lain.
02Apakah modular monolith lebih baik dari microservices?
Lebih baik sampai Anda punya kendala spesifik yang hanya bisa diselesaikan service terpisah, seperti module dengan profil scaling yang sangat berbeda atau tim yang butuh rilis independen. Sebelum itu, Anda terhindar dari network, pipeline, dan database per service yang ditambahkan microservices. Argumen monolith-first dari Martin Fowler sejalan: boundary sulit ditentukan di awal, dan memindahkannya lebih murah di dalam satu codebase.
03Bagaimana cara menegakkan boundary module di proyek TypeScript?
Gabungkan tiga lapisan. Pakai rule no-restricted-imports ESLint dengan group pola untuk memblokir deep import ke module lain, pakai rule forbidden dependency-cruiser di CI untuk memeriksa graph dependency yang sebenarnya, dan pakai module NestJS agar provider yang tidak di-export tidak bisa di-inject. Lalu commit pelanggaran sengaja di branch sementara untuk memastikan build gagal.
04Apakah setiap module sebaiknya punya schema database sendiri?
Ya, jika Anda ingin boundary itu bermakna. Tabel bersama adalah coupling yang lebih kuat daripada import, jadi beri setiap module schema Postgres dan database role sendiri. PostgreSQL mewajibkan USAGE pada sebuah schema sebelum user bisa menyentuh objeknya, sehingga grant menjadi dinding yang tidak bisa diberikan oleh code review saja.
05Bagaimana memecah modular monolith menjadi microservices nanti?
Pilih module yang punya alasan nyata untuk keluar, pasang lapisan routing di depannya, dan ganti facade-nya dengan client yang memanggil service baru. Pindahkan schema-nya menjadi database milik service itu dan simpan flag agar bisa kembali ke jalur lama. Ini pendekatan strangler fig, dan hanya murah karena module itu sudah punya facade dan data sendiri.
Arsitektur Modular Monolith: Kapan Mengalahkan Microservices
Apa itu modular monolith dan kapan lebih baik dari microservices: satu deployable, public API per module, schema Postgres per module, dan boundary yang dijaga lint.
Modular monolith adalah satu aplikasi deployable yang dibagi menjadi module dengan public API kecil, schema database sendiri, dan komunikasi lewat pemanggilan atau event in-process, dengan boundary yang dijaga tooling. Pendekatan ini lebih unggul dari microservices sampai kebutuhan scaling, jadwal rilis, atau kepemilikan tim benar-benar menuntut pemisahan.
Kebanyakan masalah yang disalahkan pada monolith berasal dari satu hal: setiap file bisa meng-import file lain, dan setiap query bisa join ke tabel mana pun. Masalahnya bukan di deployment, tetapi di dinding yang tidak ada. Memecah jadi microservices memang menambah dinding, tetapi sekaligus menambah network, pipeline, dan database untuk setiap service.
Post ini membahas cara yang lebih murah untuk mendapatkan dinding itu: modular monolith. Isinya struktur folder, public API sebuah module, schema per module di Postgres, event in-process, aturan lint dan dependency yang membuat boundary terjaga secara mekanis, perbandingan biaya dengan contoh hitungan, dan titik di mana microservices memang menang. Saya mengerjakan backend ERP dan POS dengan NestJS dan Postgres di satu VPS, jadi biaya setiap container tambahan selalu saya timbang langsung. Opsi tooling dicek terhadap dokumentasi ESLint, NestJS, PostgreSQL, dan dependency-cruiser.
Apa itu modular monolith?
Modular monolith adalah satu codebase yang di-build menjadi satu deployable, dan di dalamnya dibagi menjadi module dengan boundary yang keras. Setiap module memiliki satu kapabilitas bisnis seperti orders, billing, atau inventory, menyembunyikan isi dalamnya, dan hanya memberi module lain permukaan publik yang sengaja dibuat kecil. Ini gagasan modular programming klasik yang diterapkan pada seluruh aplikasi.
Kata kuncinya adalah enforced. Folder bernama modules tanpa penghalang import silang hanyalah monolith dengan folder yang lebih rapi. Argumen monolith-first dari Martin Fowler bertumpu pada hal yang sama: boundary sulit ditentukan di awal dan refactoring lintas service jauh lebih sulit daripada di dalam monolith, jadi pelajari dulu di mana garisnya selagi memindahkannya masih murah. Modular monolith menjaga opsi untuk extract nanti tanpa membayarnya sekarang.
Seperti apa sebuah module, dan apa public API-nya?
Setiap module adalah folder dengan satu pintu depan. Semua di luar pintu itu bersifat private. Di TypeScript biasa, pintu depannya adalah barrel index.ts yang hanya me-re-export apa yang boleh dipakai module lain. Di NestJS gagasan yang sama sudah ada di framework: sebuah module meng-encapsulate provider-nya secara default, dan hanya provider yang tercantum di array exports yang bisa di-inject di tempat lain.
src/
main.ts
app.module.ts # imports each module's public Module, nothing else
shared/
kernel/ # ids, Money, the event bus: tiny, no business rules
modules/
orders/
index.ts # THE public API (the barrel)
orders.module.ts
orders.facade.ts # the only class other modules may call
events.ts # OrderPlaced, OrderCancelled (published language)
internal/
orders.service.ts
orders.repository.ts # reads/writes the "orders" schema only
order.entity.ts
billing/
index.ts
billing.module.ts
billing.facade.ts
internal/...
inventory/
index.ts
inventory.module.ts
inventory.facade.ts
internal/...
Layout di bawah ini contoh ilustratif bergaya ERP, bukan template untuk disalin mentah-mentah. Facade adalah satu-satunya class yang dipanggil module lain. Repository, entity, dan semua isi folder internal tidak terlihat, sehingga Anda bisa menulis ulang bagian itu tanpa ada module lain yang menyadarinya.
// modules/orders/orders.module.ts
@Module({
imports: [InventoryModule], // depends on inventory's PUBLIC module
providers: [OrdersFacade, OrdersService, OrdersRepository],
exports: [OrdersFacade], // only the facade can be injected elsewhere
})
export class OrdersModule {}
// modules/orders/index.ts (the barrel: everything not listed here is private)
export { OrdersModule } from "./orders.module";
export { OrdersFacade } from "./orders.facade";
export type { OrderPlaced } from "./events";
// Deliberately NOT exported: OrdersService, OrdersRepository, order.entity
Export facade, bukan service. Facade dengan lima method bernama sesuai aksi bisnis lebih mudah dijaga stabil daripada service dengan tiga puluh method yang tumbuh sembarangan.
Mengapa setiap module sebaiknya punya schema database sendiri?
Tabel bersama adalah coupling terkuat di monolith mana pun, lebih kuat daripada import. Begitu billing melakukan join langsung ke tabel orders, billing bergantung pada susunan kolom orders dan public API jadi tidak berarti. Solusinya: beri setiap module schema Postgres dan database role sendiri, lalu akses data module lain hanya lewat facade module itu.
PostgreSQL mendukung ini secara langsung. Dokumentasinya menyebut bahwa user tidak bisa mengakses objek di schema yang bukan miliknya kecuali pemilik memberi privilege USAGE, dan bahwa schema tidak terpisah secara kaku, sehingga user yang punya privilege bisa menjangkau schema mana pun di database itu. Kalimat kedua itulah alasan Anda butuh role dan bukan sekadar konvensi penamaan: privilege adalah dindingnya. Script di bawah adalah sketsa minimal untuk dua module.
-- One schema and one role per module.
CREATE SCHEMA orders;
CREATE SCHEMA billing;
CREATE ROLE orders_app LOGIN PASSWORD 'change-me';
CREATE ROLE billing_app LOGIN PASSWORD 'change-me';
-- Each role gets USAGE (and CREATE for migrations) on its OWN schema only.
GRANT USAGE, CREATE ON SCHEMA orders TO orders_app;
GRANT USAGE, CREATE ON SCHEMA billing TO billing_app;
-- Unqualified table names resolve to the module's own schema.
ALTER ROLE orders_app SET search_path = orders;
ALTER ROLE billing_app SET search_path = billing;
-- Close the default gap: new objects must not land in public.
REVOKE CREATE ON SCHEMA public FROM PUBLIC;
-- Now this fails with "permission denied for schema orders":
-- SET ROLE billing_app; SELECT * FROM orders.orders;
-- Billing has to ask OrdersFacade instead.
Schema public itu spesial. Secara default semua orang punya USAGE di sana, dan di database hasil upgrade dari PostgreSQL 14 atau lebih lama semua orang juga punya CREATE. Jauhkan tabel module dari public dan cabut privilege defaultnya, kalau tidak dindingnya berlubang.
Bagaimana module berkomunikasi tanpa network call?
Ada dua opsi in-process. Pemanggilan langsung lewat facade module lain tepat saat Anda butuh jawaban sekarang, misalnya bertanya ke inventory apakah 3 unit tersedia. Domain event tepat saat Anda mengumumkan sesuatu sudah terjadi dan tidak peduli siapa yang bereaksi, misalnya OrderPlaced. Billing dan inventory subscribe; orders tidak pernah meng-import keduanya.
// shared/kernel/event-bus.ts
type Handler<E> = (event: E) => Promise<void> | void;
export class InProcessBus {
private handlers = new Map<string, Handler<unknown>[]>();
on<E>(type: string, handler: Handler<E>): void {
const list = this.handlers.get(type) ?? [];
list.push(handler as Handler<unknown>);
this.handlers.set(type, list);
}
async publish<E>(type: string, event: E): Promise<void> {
// Sequential on purpose: one failing handler is visible, not swallowed.
for (const handler of this.handlers.get(type) ?? []) {
await handler(event);
}
}
}
// billing subscribes; orders never imports billing.
bus.on<OrderPlaced>("order.placed", (e) => billing.createInvoice(e.orderId));
Sketsa di bawah adalah event bus in-process bertipe, sekitar dua puluh baris. Batasnya ada pada durability: event hidup di memori, jadi kalau process mati di antara commit database dan publish, event hilang. Untuk side effect yang tidak boleh hilang, tulis event ke tabel outbox dalam transaction yang sama, seperti dibahas di post strangler fig migration, dan tabel yang sama nanti bisa menjadi jembatan ke message broker.
Bagaimana menjaga boundary module secara mekanis?
Konvensi akan luntur saat deadline mepet, jadi buat build-nya gagal. Lapisan termurah adalah rule no-restricted-imports milik ESLint. Opsi patterns-nya menerima objek dengan array group berisi pola gaya gitignore dan sebuah message, dan dokumentasi menyebut pola negasi dengan tanda seru meng-include kembali sebuah path dan harus diletakkan paling akhir. Dengan itu Anda bisa menulis satu rule per module: blokir deep import ke semua module kecuali module Anda sendiri.
// eslint.config.mjs (flat config), one block per module
const MODULES = ["orders", "billing", "inventory"];
export default MODULES.map((self) => ({
files: ["src/modules/" + self + "/**/*.ts"],
rules: {
"no-restricted-imports": ["error", {
patterns: [{
// Block every deep import under modules/, then re-include our own module.
// Negated patterns must come last, or the re-include is ignored.
group: ["@/modules/*/**", "!@/modules/" + self + "/**"],
message: "Import another module through its index.ts barrel only.",
}],
}],
},
}));
Lapisan kedua adalah dependency-cruiser, yang menganalisis graph dependency yang sebenarnya, bukan string import. Sebuah rule di array forbidden punya name, severity, bagian from, dan bagian to. Taruh nama module dalam capture group di from, lalu pakai variabel group yang sesuai di to bersama pathNot sehingga rule hanya menyala pada import yang melintasi boundary module. Lapisan ketiga adalah NestJS sendiri, di mana provider yang tidak di-export memang tidak bisa di-inject ke module lain.
// .dependency-cruiser.cjs
module.exports = {
forbidden: [
{
name: "no-deep-cross-module-import",
comment: "A module may only touch another module's index.ts",
severity: "error",
// Capture the module name in group 1 ...
from: { path: "^src/modules/([^/]+)/" },
to: {
// ... block any file under a module folder except its index.ts ...
path: "^src/modules/[^/]+/(?!index\\.ts$)",
// ... unless it is the importer's own module ($1 = group from "from").
pathNot: "^src/modules/$1/",
},
},
],
};
// CI: npx depcruise --config .dependency-cruiser.cjs src
Jalankan lint di editor untuk feedback cepat dan dependency-cruiser di CI sebagai pengaman. Lalu buktikan dindingnya bekerja: commit pelanggaran sengaja di branch sementara dan pastikan CI menjadi merah. Rule yang belum pernah gagal adalah rule yang belum Anda uji.
no-restricted-imports mencocokkan import specifier sebagaimana tertulis, jadi path relatif seperti titik titik garis miring billing garis miring internal bisa lolos dari rule yang ditulis untuk alias. Larang juga import relatif ke parent lintas module, atau andalkan dependency-cruiser yang me-resolve file sebenarnya.
Seberapa besar penghematannya dibanding microservices?
Penghematannya bersifat operasional, dan bisa dihitung. Ambil sistem dengan 6 module. Sebagai modular monolith itu 1 deployable dan 1 pipeline. Sebagai microservices itu 6 masing-masing, ditambah network di antaranya. Dengan 6 service, jumlah kemungkinan koneksi berpasangan adalah 6 kali 5 dibagi 2, yaitu 15 koneksi yang masing-masing bisa timeout, salah konfigurasi, atau berbeda versi.
Aspek
Modular monolith
Microservices
Deployable dan pipeline CI
1 dan 1
6 dan 6
Panggilan antar dua module
Function call di process yang sama
Network request yang bisa gagal atau timeout
Kemungkinan koneksi berpasangan (6 module)
0 koneksi network
Hingga 15 koneksi, dari 6 kali 5 dibagi 2
Database
Satu server, 6 schema
Biasanya 6 database jika mengikuti database per service
Memori dasar, dengan asumsi 150 MB per process Node
150 MB
900 MB, sekitar 44 persen dari VPS 2 GB
Perubahan yang melibatkan dua module
Satu commit, satu deploy, satu transaction bila memang perlu
Rilis terkoordinasi, plus saga atau outbox untuk konsistensi
Angka 150 MB adalah asumsi untuk ilustrasi, bukan hasil pengukuran; ukur process idle Anda sendiri sebelum memercayai persentasenya. Hitungannya tetap berlaku: 6 kali 150 adalah 900, dan 900 dibagi 2048 kira-kira 0,44. Availability juga berlipat. Jika sebuah request harus melewati 4 service sinkron yang masing-masing 99,9 persen available dan gagal secara independen, hasil gabungannya adalah 0,999 pangkat 4, sekitar 99,6 persen, yaitu kira-kira empat kali downtime sebuah process tunggal 99,9 persen.
Inilah microservice premium dari Fowler dalam angka. Biaya itu layak dibayar bila manfaat di bawah berlaku untuk Anda. Kalau tidak, itu hanya pajak.
Kapan microservices benar-benar menang?
Microservices menang ketika kendalanya bersifat organisasi atau fisik, bukan soal estetika. Modular monolith di-scale dengan menjalankan lebih banyak salinan identik dari keseluruhannya, dan itu berhenti efisien pada beberapa situasi tertentu.
Satu module butuh profil scaling berbeda, misalnya report renderer yang berat CPU atau image pipeline yang memaksa Anda men-scale seluruh aplikasi hanya untuk meringankan satu jalur panas.
Tim yang independen butuh jadwal rilis yang independen, dan menunggu deploy bersama benar-benar memakan waktu delivery, bukan sekadar terasa berantakan.
Sebuah module butuh runtime atau failure domain berbeda, misalnya komponen model-serving dalam bahasa lain, atau jalur pembayaran yang harus tetap hidup saat bagian lain sedang di-deploy.
Aturan compliance atau blast radius menuntut isolasi keras satu kapabilitas, termasuk credential dan data store tersendiri.
Perhatikan bahwa tidak satu pun alasan ini berbunyi codebase makin besar. Ukuran saja sudah diselesaikan oleh module. Jika alasan Anda adalah monolith terasa berantakan, rapikan boundary dulu; memecah sistem yang kusut hanya mengubah function call yang kusut menjadi network call yang kusut.
Bagaimana memutuskan, dan extract module nanti jika perlu?
Extraction adalah buah dari disiplin di atas. Module dengan facade, schema sendiri, dan coupling berbasis event bisa diangkat keluar dengan pendekatan strangler fig: pasang lapisan routing di depan, ganti implementasi facade dengan client yang memanggil service baru, dan pindahkan schema sebagai database milik service itu. Post pendamping tentang strangler fig pattern membahas prosedurnya. Tanpa dinding, extraction yang sama berawal dari pekerjaan mengurai selama berbulan-bulan. Untuk trade-off yang lebih luas, lihat post sebelumnya tentang microservices versus monolith, dan untuk sisi struktur folder dari gagasan yang sama lihat post Next.js project structure.
Pakai checklist ini sebelum memecah apa pun. Jika kotak pertama tidak bisa Anda centang untuk sebuah module, extraction belum siap.
Module hanya bisa dijangkau lewat facade dan event publiknya, dan CI membuktikannya.
Tidak ada module lain yang membaca atau menulis schema-nya, dikonfirmasi oleh database role dan bukan kebiasaan.
Anda bisa menyebut kendala konkretnya: profil scaling, jadwal rilis, runtime, atau aturan compliance.
Satu tim akan memiliki service baru itu dari ujung ke ujung, termasuk on-call dan pipeline-nya.
Tabel biaya di atas tetap menguntungkan pemisahan setelah Anda memasukkan overhead per service yang nyata.
Anda punya rencana rollback, misalnya routing flag yang mengembalikan traffic ke facade in-process.
Bangun dindingnya dulu, putuskan soal network belakangan. Modular monolith memberi struktur yang membuat microservices mungkin dilakukan, dengan biaya operasional jauh lebih kecil, dan tetap membuka opsi extraction untuk satu module yang memang layak. Pecah hanya ketika Anda bisa menyebut kendalanya, bukan ketika codebase sekadar terasa besar.