Di post sebelumnya, kita membangun fitur Orders menggunakan lima arsitektur — dari Simple MVC hingga Microservices. Setiap arsitektur diuji dan dibandingkan secara langsung.
Tapi ada satu arsitektur yang belum dibahas: Modular Monolith.
Ia duduk tepat di antara Feature-Based dan Microservices dalam spektrum arsitektur. Satu deployment unit seperti monolith biasa, tapi dengan batas antar modul yang ditegakkan secara eksplisit — bukan hanya konvensi folder.
---
Masalah yang Dipecahkan
Di Feature-Based Structure (NestJS), kita bisa menulis ini:
// modules/orders/orders.service.ts
import { NotificationsService } from '../notifications/notifications.service';
Import langsung ke class internal modul lain. Tidak ada yang melarang. Tidak ada yang tahu sampai coupling-nya terasa — biasanya saat refactoring atau saat ingin memisahkan modul menjadi service terpisah.
Modular Monolith memaksa satu aturan sederhana: modul lain hanya boleh mengakses apa yang kamu ekspos secara eksplisit.
---
Skenario: Fitur Orders
Sama seperti seri sebelumnya:
POST /orders — buat order baruGET /orders/:id — ambil detail order---
Struktur Folder
/src
/modules
/orders
/public
index.ts ← satu-satunya pintu masuk modul ini
orders.module.ts
create-order.dto.ts
order-created.event.ts
notification.port.ts ← interface yang dibutuhkan orders dari luar
/internal
orders.controller.ts
orders.service.ts
orders.repository.ts
order.entity.ts
orders.service.spec.ts
/notifications
/public
index.ts
notifications.module.ts
notification-adapter.ts ← implementasi notification.port
/internal
notifications.service.ts
notifications.service.spec.ts
/products
/public
index.ts
products.module.ts
product.port.ts
/internal
products.service.ts
/shared
/events
in-process-event-bus.ts
/kernel
base.entity.ts
Aturan yang berlaku:
modules/*/public/index.tsmodules/*/internal/eslint-plugin-boundaries — bukan hanya kesepakatan tim---
Public API per Modul
index.ts di folder /public adalah kontrak resmi modul — apa yang diekspos adalah apa yang boleh digunakan modul lain.
// modules/orders/public/index.ts
export { OrdersModule } from './orders.module';
export { CreateOrderDto } from './create-order.dto';
export { OrderCreatedEvent } from './order-created.event';
export { INotificationPort } from './notification.port';
// OrdersService, OrderRepository, Order entity TIDAK diekspos
// Siapapun yang mencoba mengimpornya langsung akan diblokir ESLint
Modul lain mengkonsumsinya seperti ini:
// BENAR — hanya dari public API
import { CreateOrderDto, OrderCreatedEvent } from '@modules/orders/public';
// SALAH — langsung ke internal (ESLint error)
import { OrdersService } from '@modules/orders/internal/orders.service';
---
Implementasi Orders Module
Modul orders tidak mengimpor NotificationsService secara langsung. Ia hanya tahu tentang INotificationPort — sebuah interface yang didefinisikan di public API modul orders sendiri.
// modules/orders/public/notification.port.ts
export interface INotificationPort {
sendOrderConfirmation(orderId: string): Promise<void>;
}
// modules/orders/internal/orders.service.ts
import { Injectable, Inject } from '@nestjs/common';
import { INotificationPort } from '../public/notification.port';
import { OrdersRepository } from './orders.repository';
@Injectable()
export class OrdersService {
constructor(
private readonly repo: OrdersRepository,
@Inject('NOTIFICATION_PORT')
private readonly notifier: INotificationPort,
) {}
async createOrder(dto: CreateOrderDto) {
const product = await this.repo.findProductById(dto.productId);
if (!product || product.stock < dto.quantity) {
throw new Error('Insufficient stock');
}
const order = await this.repo.save({
productId: dto.productId,
quantity: dto.quantity,
});
await this.notifier.sendOrderConfirmation(order.id);
return order;
}
}
// modules/orders/public/orders.module.ts
import { Module } from '@nestjs/common';
import { OrdersService } from '../internal/orders.service';
import { OrdersRepository } from '../internal/orders.repository';
import { OrdersController } from '../internal/orders.controller';
@Module({
controllers: [OrdersController],
providers: [
OrdersService,
OrdersRepository,
// INotificationPort di-inject dari luar saat wiring di AppModule
],
exports: [OrdersService],
})
export class OrdersModule {}
---
Komunikasi Antar Modul
Ada dua pola yang bisa dipilih. Keduanya valid tergantung pada kebutuhan.
Pola 1 — Interface Injection
orders module mendefinisikan port yang dibutuhkan. Implementasinya disediakan modul notifications dan di-wire di AppModule.
// modules/notifications/public/notification-adapter.ts
import { Injectable } from '@nestjs/common';
import { INotificationPort } from '@modules/orders/public';
import { NotificationsService } from '../internal/notifications.service';
@Injectable()
export class NotificationAdapter implements INotificationPort {
constructor(private readonly service: NotificationsService) {}
async sendOrderConfirmation(orderId: string): Promise<void> {
await this.service.send({ type: 'order_confirmed', orderId });
}
}
// app.module.ts — composition root
@Module({
imports: [OrdersModule, NotificationsModule],
providers: [
{
provide: 'NOTIFICATION_PORT',
useClass: NotificationAdapter,
},
],
})
export class AppModule {}
Pola 2 — In-Process Event Bus
orders module mempublish event. notifications module subscribe. Keduanya tidak saling mengenal satu sama lain.
// shared/events/in-process-event-bus.ts
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
@Injectable()
export class InProcessEventBus {
constructor(private readonly emitter: EventEmitter2) {}
async publish<T>(event: T): Promise<void> {
const eventName = (event as any).constructor.name;
await this.emitter.emitAsync(eventName, event);
}
}
// modules/orders/internal/orders.service.ts
async createOrder(dto: CreateOrderDto) {
const order = await this.repo.save(dto);
await this.eventBus.publish(new OrderCreatedEvent(order.id, dto.customerEmail));
return order;
}
// modules/notifications/internal/notifications.service.ts
@OnEvent('OrderCreatedEvent')
async handleOrderCreated(event: OrderCreatedEvent) {
await this.sendEmail(event.customerEmail, event.orderId);
}
Kapan memilih masing-masing:
---
Penegakan Batas dengan ESLint
Tanpa tooling, konvensi /public dan /internal hanya bersifat moral. Satu developer yang terburu-buru bisa mengabaikannya dan tidak ada yang tahu.
Setup eslint-plugin-boundaries:
npm install --save-dev eslint-plugin-boundaries
// .eslintrc.js
module.exports = {
plugins: ['boundaries'],
settings: {
'boundaries/elements': [
{
type: 'module-public',
pattern: 'src/modules/*/public/index.ts',
},
{
type: 'module-internal',
pattern: 'src/modules/*/internal/**',
},
{
type: 'shared',
pattern: 'src/shared/**',
},
],
},
rules: {
'boundaries/element-types': [
'error',
{
default: 'disallow',
rules: [
{
from: ['module-public', 'module-internal'],
allow: ['module-public', 'shared'],
},
{
from: 'module-internal',
allow: ['module-internal'],
importKind: 'value',
// hanya boleh mengimpor dari internal modul sendiri
},
],
},
],
},
};
Sekarang jika ada yang menulis:
import { OrdersService } from '@modules/orders/internal/orders.service';
ESLint akan langsung melempar error — di editor dan di CI pipeline.
---
Database: Schema per Modul
Meskipun satu database, setiap modul menggunakan prefix tabel yang berbeda untuk mencegah query lintas modul secara langsung.
// modules/orders/internal/order.entity.ts
@Entity('orders__orders') // prefix: orders__
export class OrderEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
productId: string;
@Column()
quantity: number;
}
// modules/notifications/internal/notification-log.entity.ts
@Entity('notifications__logs') // prefix: notifications__
export class NotificationLogEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column()
orderId: string;
}
Modul orders tidak boleh melakukan query langsung ke tabel notifications__*. Jika butuh data dari modul notifications, harus lewat public API-nya.
---
Testing di Modular Monolith
Unit test dalam modul
Identik dengan Feature-Based — menggunakan Test.createTestingModule() di NestJS.
// modules/orders/internal/orders.service.spec.ts
describe('OrdersService', () => {
let service: OrdersService;
let mockRepo: jest.Mocked<OrdersRepository>;
let mockNotifier: jest.Mocked<INotificationPort>;
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [
OrdersService,
{
provide: OrdersRepository,
useValue: { findProductById: jest.fn(), save: jest.fn() },
},
{
provide: 'NOTIFICATION_PORT',
useValue: { sendOrderConfirmation: jest.fn() },
},
],
}).compile();
service = module.get(OrdersService);
mockRepo = module.get(OrdersRepository);
mockNotifier = module.get('NOTIFICATION_PORT');
});
it('throws when stock is insufficient', async () => {
mockRepo.findProductById.mockResolvedValue({ stock: 0 });
await expect(
service.createOrder({ productId: 'p1', quantity: 2 })
).rejects.toThrow('Insufficient stock');
});
it('sends notification after successful order', async () => {
mockRepo.findProductById.mockResolvedValue({ stock: 10 });
mockRepo.save.mockResolvedValue({ id: 'order-1' });
await service.createOrder({ productId: 'p1', quantity: 2 });
expect(mockNotifier.sendOrderConfirmation).toHaveBeenCalledWith('order-1');
});
});
Module contract test — test hanya lewat public API
// modules/orders/orders.module.spec.ts
describe('OrdersModule — public API', () => {
it('creates order via public interface only', async () => {
const module = await Test.createTestingModule({
imports: [OrdersModule],
})
.overrideProvider('NOTIFICATION_PORT')
.useValue({ sendOrderConfirmation: jest.fn() })
.compile();
// hanya menggunakan OrdersService yang diekspos lewat exports
const service = module.get(OrdersService);
const result = await service.createOrder({ productId: 'p1', quantity: 2 });
expect(result.id).toBeDefined();
});
});
Test ini memverifikasi bahwa public API modul berfungsi secara end-to-end tanpa menyentuh internal implementation.
Architecture test — ESLint di CI
# package.json scripts
"test:arch": "eslint src --rule '{\"boundaries/element-types\": \"error\"}' --ext .ts"
# CI pipeline
npm run test:arch
Jika ada import ilegal antar modul, pipeline akan gagal dengan pesan yang jelas sebelum kode masuk ke main branch.
---
Perbandingan dengan Feature-Based dan Microservices
| Aspek | Feature-Based | Modular Monolith | Microservices |
|---|---|---|---|
| Batas modul | Konvensi folder | ESLint-enforced public API | Network boundary |
| Deployment | 1 unit | 1 unit | N unit |
| Kompleksitas operasional | Rendah | Rendah | Tinggi |
| Contract testing | Tidak perlu | Tidak perlu | Wajib (Pact.js) |
| Migrasi ke microservices | Sulit | Mudah | N/A |
| Database | Shared | Shared, prefix per modul | Terpisah |
| Developer experience | Bebas | Terstruktur | Kompleks |
Kapan Menggunakan Modular Monolith
Modular Monolith adalah pilihan yang tepat ketika:
---
Kesimpulan
Modular Monolith bukan sekadar Feature-Based yang lebih rapi. Perbedaan fundamentalnya ada di penegakan batas: konvensi vs tooling.
Dengan eslint-plugin-boundaries, setiap import yang melanggar aturan tertangkap secara otomatis — di editor sebelum commit, dan di CI sebelum merge. Tidak ada yang bisa "kebetulan" mengakses internal modul lain.
Ini membuat Modular Monolith menjadi fondasi yang jauh lebih kuat untuk tumbuh — baik dalam tim maupun dalam sistem itu sendiri.
---
Penutup
Pertanyaan yang tepat bukan:
> "Kapan kita harus beralih ke microservices?"
Tapi:
> "Apakah batas-batas domain bisnis kita sudah cukup jelas untuk dipisahkan?"
Modular Monolith membantu menjawab pertanyaan itu — sebelum kamu harus menanggung biaya operasional distributed system.