Beranda / Blog / Engineering
Engineering

Modular Monolith dalam Praktik: Batas Modul yang Ditegakkan (Node.js)

Membangun fitur Orders menggunakan Modular Monolith dengan TypeScript dan NestJS — public API per modul, komunikasi antar modul, penegakan batas dengan ESLint, dan strategi testing yang tepat.

Yudi Nugraha
3 Mei 2026
8 menit baca

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 baru
  • GET /orders/:id — ambil detail order
  • Validasi: stok produk harus tersedia
  • Notifikasi: kirim email setelah order berhasil
  • ---

    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:

  • Modul lain hanya boleh mengimpor dari modules/*/public/index.ts
  • Tidak ada yang boleh mengimpor langsung dari modules/*/internal/
  • Ditegakkan oleh 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:

  • Interface injection — ketika kamu butuh feedback langsung (return value) dari modul lain
  • Event bus — ketika komunikasi bisa async dan kamu ingin decoupling maksimal
  • ---

    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

    AspekFeature-BasedModular MonolithMicroservices
    Batas modulKonvensi folderESLint-enforced public APINetwork boundary
    Deployment1 unit1 unitN unit
    Kompleksitas operasionalRendahRendahTinggi
    Contract testingTidak perluTidak perluWajib (Pact.js)
    Migrasi ke microservicesSulitMudahN/A
    DatabaseSharedShared, prefix per modulTerpisah
    Developer experienceBebasTerstrukturKompleks
    ---

    Kapan Menggunakan Modular Monolith

    Modular Monolith adalah pilihan yang tepat ketika:

  • Tim sudah merasakan Feature-Based mulai sulit di-maintain karena coupling antar modul yang tidak terasa
  • Sistem kemungkinan akan dipecah ke microservices di masa depan — batas yang sudah bersih memudahkan pemisahan
  • Organisasi dengan 10–30 developer yang masih ingin single deployment
  • Sistem dengan kebutuhan konsistensi data tinggi yang tidak cocok untuk distributed transaction
  • ---

    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.

    Tag

    Software EngineeringArchitectureTypeScriptNodeJSNestJSTesting
    Y

    Yudi Nugraha

    Software Engineer | Builder

    Artikel Lainnya

    Jelajahi lebih banyak artikel dengan topik serupa

    Lihat Semua Artikel