Beranda / Blog / Engineering
Engineering

Struktur Folder dalam Praktik: Satu Fitur, Lima Arsitektur (Node.js)

Membangun fitur Orders yang sama menggunakan Simple MVC, Layered, Feature-Based, Clean Architecture, dan Microservices — dilengkapi strategi testing untuk masing-masing, dengan contoh kode nyata menggunakan TypeScript, Express, dan NestJS.

Yudi Nugraha
3 Mei 2026
14 menit baca

Di post sebelumnya, kita membahas bagaimana struktur folder berkembang dari Simple MVC hingga Microservices — dan mengapa setiap pendekatan lahir untuk menjawab masalah pada tahap sebelumnya.

Kali ini kita masuk ke ranah yang lebih konkret: kodenya seperti apa?

Strateginya: kita bangun satu fitur yang sama menggunakan kelima arsitektur, lalu lihat bagaimana cara masing-masing diuji. Dengan begitu, perbedaannya bisa dibandingkan secara langsung — apples-to-apples.

---

Skenario: Fitur Orders

Fitur yang kita bangun di semua arsitektur:

  • POST /orders — buat order baru
  • GET /orders/:id — ambil detail order
  • Validasi: stok produk harus tersedia sebelum order dibuat
  • Notifikasi: kirim email konfirmasi setelah order berhasil
  • Fitur ini dipilih karena melibatkan lebih dari satu layer — ada HTTP, business logic, database, dan external service. Cukup nyata untuk menunjukkan perbedaan arsitektur, tapi tidak terlalu kompleks hingga mengaburkan poin utamanya.

    Tech stack yang digunakan di semua contoh:

    KebutuhanLibrary
    HTTP framework (MVC/Layered)Express
    HTTP framework (Feature-Based)NestJS
    ORMPrisma
    Unit testJest
    Integration testSupertest
    Mockingjest.fn()
    Contract testingPact.js (@pact-foundation/pact)
    ---

    Arsitektur 1: Simple MVC

    Struktur Folder

    /controllers
      order.controller.ts
    /models
      order.model.ts
    

    Implementasi

    Di Simple MVC, semua logic ada di dalam satu route handler. Controller langsung melakukan validasi stok, menyimpan order ke database via Prisma, dan mengirim email — semuanya dalam satu fungsi.

    // controllers/order.controller.ts
    import { Request, Response, Router } from 'express';
    import { prisma } from '../db';
    import { sendEmail } from '../email';
    
    export const orderRouter = Router();
    
    orderRouter.post('/orders', async (req: Request, res: Response) => {
      const { productId, quantity, customerEmail } = req.body;
    
      const product = await prisma.product.findUnique({ where: { id: productId } });
      if (!product || product.stock < quantity) {
        return res.status(400).json({ error: 'Insufficient stock' });
      }
    
      const order = await prisma.order.create({
        data: { productId, quantity },
      });
    
      await sendEmail({
        to: customerEmail,
        subject: `Order ${order.id} confirmed`,
      });
    
      return res.status(201).json(order);
    });
    

    Kode ini mudah dibaca dan cepat ditulis. Tapi setiap kali ada logika baru — diskon, fraud check, audit log — semuanya masuk ke handler yang sama. Dalam beberapa sprint, fungsi ini bisa tumbuh menjadi ratusan baris.

    Pengujian di Simple MVC

    Karena logic dan infrastruktur bercampur dalam satu handler, unit test murni tidak mungkin dilakukan. Satu-satunya pilihan adalah integration test: jalankan seluruh aplikasi dan hit endpoint-nya.

    Di Express, ini dilakukan dengan supertest:

    // order.controller.test.ts
    import request from 'supertest';
    import app from '../app';
    import { prisma } from '../db';
    
    beforeEach(async () => {
      await prisma.product.create({ data: { id: 'prod-1', stock: 10 } });
    });
    
    afterEach(async () => {
      await prisma.order.deleteMany();
      await prisma.product.deleteMany();
    });
    
    it('POST /orders returns 201 when stock is sufficient', async () => {
      const res = await request(app)
        .post('/orders')
        .send({ productId: 'prod-1', quantity: 2, customerEmail: 'u@test.com' });
    
      expect(res.status).toBe(201);
      expect(res.body.id).toBeDefined();
    });
    
    it('POST /orders returns 400 when stock is insufficient', async () => {
      const res = await request(app)
        .post('/orders')
        .send({ productId: 'prod-1', quantity: 99, customerEmail: 'u@test.com' });
    
      expect(res.status).toBe(400);
    });
    

    Setiap test butuh database yang sudah terisi data. Test menjadi lambat karena harus melewati seluruh HTTP stack dan koneksi database. Menguji edge case memerlukan setup data yang rumit di setiap test.

    Cocok untuk: prototype, internal tool kecil, validasi ide dalam 1–2 minggu.

    ---

    Arsitektur 2: Layered Architecture

    Struktur Folder

    /controllers
      order.controller.ts
    /services
      order.service.ts
    /repositories
      order.repository.ts
    /models
      order.model.ts
    

    Implementasi

    Business logic pindah ke OrderService. Controller hanya bertugas menerima request dan mendelegasikan ke service.

    // services/order.service.ts
    import { IOrderRepository } from '../repositories/order.repository';
    import { IEmailService } from '../email/email.service';
    
    export class OrderService {
      constructor(
        private readonly repo: IOrderRepository,
        private readonly email: IEmailService,
      ) {}
    
      async createOrder(dto: { productId: string; quantity: number; customerEmail: string }) {
        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.email.send({
          to: dto.customerEmail,
          subject: `Order ${order.id} confirmed`,
        });
    
        return order;
      }
    }
    
    // controllers/order.controller.ts
    orderRouter.post('/orders', async (req: Request, res: Response) => {
      try {
        const order = await orderService.createOrder(req.body);
        return res.status(201).json(order);
      } catch (err: any) {
        return res.status(400).json({ error: err.message });
      }
    });
    

    Pengujian di Layered Architecture

    Sekarang OrderService bisa diuji secara terisolasi dengan meng-mock IOrderRepository menggunakan jest.fn() — tanpa database:

    // order.service.test.ts
    import { OrderService } from './order.service';
    
    const mockRepo = {
      findProductById: jest.fn(),
      save: jest.fn(),
    };
    
    const mockEmail = {
      send: jest.fn(),
    };
    
    const service = new OrderService(mockRepo as any, mockEmail as any);
    
    beforeEach(() => jest.clearAllMocks());
    
    it('throws when stock is insufficient', async () => {
      mockRepo.findProductById.mockResolvedValue({ stock: 0 });
    
      await expect(
        service.createOrder({ productId: 'prod-1', quantity: 2, customerEmail: 'u@test.com' })
      ).rejects.toThrow('Insufficient stock');
    });
    
    it('sends email after order is saved', async () => {
      mockRepo.findProductById.mockResolvedValue({ stock: 10 });
      mockRepo.save.mockResolvedValue({ id: 'order-1', productId: 'prod-1' });
    
      await service.createOrder({ productId: 'prod-1', quantity: 2, customerEmail: 'u@test.com' });
    
      expect(mockEmail.send).toHaveBeenCalledWith(
        expect.objectContaining({ to: 'u@test.com' })
      );
    });
    

    Test berjalan cepat karena tidak menyentuh database maupun HTTP stack. Berbagai kondisi bisa diuji hanya dengan mengubah return value dari mock.

    Cocok untuk: REST API 5–15 endpoint, tim 2–5 developer.

    ---

    Arsitektur 3: Feature-Based Structure

    Struktur Folder

    /modules
      /orders
        order.controller.ts
        order.service.ts
        order.repository.ts
        order.model.ts
        order.dto.ts
        order.module.ts
        order.spec.ts         ← test di dalam modul
      /notifications
        notification.service.ts
        notification.module.ts
    

    Implementasi

    Di sinilah NestJS masuk. Framework ini dirancang secara eksplisit untuk pendekatan modular. Setiap modul mendaftarkan provider-nya sendiri dan bisa mengimpor modul lain.

    // modules/orders/order.module.ts
    import { Module } from '@nestjs/common';
    import { OrderController } from './order.controller';
    import { OrderService } from './order.service';
    import { OrderRepository } from './order.repository';
    import { NotificationModule } from '../notifications/notification.module';
    
    @Module({
      imports: [NotificationModule],
      controllers: [OrderController],
      providers: [OrderService, OrderRepository],
    })
    export class OrderModule {}
    
    // modules/orders/order.service.ts
    import { Injectable } from '@nestjs/common';
    import { OrderRepository } from './order.repository';
    import { NotificationService } from '../notifications/notification.service';
    
    @Injectable()
    export class OrderService {
      constructor(
        private readonly repo: OrderRepository,
        private readonly notifier: NotificationService,
      ) {}
    
      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(dto);
        await this.notifier.sendOrderConfirmation(order.id);
    
        return order;
      }
    }
    

    Pengujian di Feature-Based Structure

    NestJS menyediakan Test.createTestingModule() yang memungkinkan kita membuat modul terisolasi khusus untuk testing. Provider nyata diganti dengan mock.

    // modules/orders/order.spec.ts
    import { Test, TestingModule } from '@nestjs/testing';
    import { OrderService } from './order.service';
    import { OrderRepository } from './order.repository';
    import { NotificationService } from '../notifications/notification.service';
    
    describe('OrderService', () => {
      let service: OrderService;
      let mockRepo: jest.Mocked<OrderRepository>;
      let mockNotifier: jest.Mocked<NotificationService>;
    
      beforeEach(async () => {
        const module: TestingModule = await Test.createTestingModule({
          providers: [
            OrderService,
            {
              provide: OrderRepository,
              useValue: {
                findProductById: jest.fn(),
                save: jest.fn(),
              },
            },
            {
              provide: NotificationService,
              useValue: { sendOrderConfirmation: jest.fn() },
            },
          ],
        }).compile();
    
        service = module.get(OrderService);
        mockRepo = module.get(OrderRepository);
        mockNotifier = module.get(NotificationService);
      });
    
      it('saves order and sends notification', async () => {
        mockRepo.findProductById.mockResolvedValue({ stock: 10 });
        mockRepo.save.mockResolvedValue({ id: 'order-1', productId: 'prod-1', quantity: 2 });
    
        await service.createOrder({ productId: 'prod-1', quantity: 2 });
    
        expect(mockRepo.save).toHaveBeenCalledTimes(1);
        expect(mockNotifier.sendOrderConfirmation).toHaveBeenCalledWith('order-1');
      });
    
      it('throws when stock is insufficient', async () => {
        mockRepo.findProductById.mockResolvedValue({ stock: 1 });
    
        await expect(
          service.createOrder({ productId: 'prod-1', quantity: 5 })
        ).rejects.toThrow('Insufficient stock');
      });
    });
    

    Test file hidup di dalam folder modules/orders/ — berdampingan dengan kode yang diuji. Developer baru yang masuk ke modul Orders langsung menemukan semuanya dalam satu tempat.

    Cocok untuk: SaaS product, tim 5–20 developer, aplikasi dengan banyak fitur yang terus bertambah.

    ---

    Arsitektur 4: Clean Architecture

    Struktur Folder

    /src
      /Domain
        /Orders
          Order.ts             ← pure entity
          IOrderRepository.ts  ← interface/port
      /Application
        /Orders
          CreateOrderUseCase.ts
          CreateOrderUseCase.spec.ts
      /Infrastructure
        /Database
          PrismaOrderRepository.ts
          PrismaOrderRepository.integration.spec.ts
        /Email
          SendGridNotificationAdapter.ts
      /Presentation
        /HTTP
          OrderController.ts
    

    Implementasi

    Di Clean Architecture, dependency harus mengarah ke dalam — ke domain dan application layer. Domain tidak boleh tahu tentang Prisma, Express, atau SendGrid.

    // Domain/Orders/Order.ts
    export class Order {
      readonly id: string;
      readonly productId: string;
      readonly quantity: number;
    
      constructor(params: { productId: string; productStock: number; quantity: number }) {
        if (params.quantity > params.productStock) {
          throw new Error('Insufficient stock');
        }
        this.id = crypto.randomUUID();
        this.productId = params.productId;
        this.quantity = params.quantity;
      }
    }
    
    // Domain/Orders/IOrderRepository.ts
    import { Order } from './Order';
    
    export interface IOrderRepository {
      findProductById(id: string): Promise<{ stock: number } | null>;
      save(order: Order): Promise<Order>;
    }
    
    export interface INotificationPort {
      notify(orderId: string): Promise<void>;
    }
    
    // Application/Orders/CreateOrderUseCase.ts
    import { Order } from '../../Domain/Orders/Order';
    import { IOrderRepository, INotificationPort } from '../../Domain/Orders/IOrderRepository';
    
    export class CreateOrderUseCase {
      constructor(
        private readonly repo: IOrderRepository,
        private readonly notifier: INotificationPort,
      ) {}
    
      async execute(dto: { productId: string; quantity: number }): Promise<Order> {
        const product = await this.repo.findProductById(dto.productId);
        if (!product) throw new Error('Product not found');
    
        const order = new Order({
          productId: dto.productId,
          productStock: product.stock,
          quantity: dto.quantity,
        });
    
        const saved = await this.repo.save(order);
        await this.notifier.notify(saved.id);
    
        return saved;
      }
    }
    

    CreateOrderUseCase tidak tahu kita pakai Prisma, Mongoose, atau database apapun. Ia hanya tahu IOrderRepository — sebuah kontrak.

    Pengujian di Clean Architecture

    Ada tiga lapisan test yang masing-masing punya tujuan dan kecepatan berbeda.

    Domain Entity — pure unit test, nol dependency

    // Domain/Orders/Order.spec.ts
    import { Order } from './Order';
    
    it('throws when quantity exceeds stock', () => {
      expect(() => new Order({ productId: 'prod-1', productStock: 3, quantity: 5 }))
        .toThrow('Insufficient stock');
    });
    
    it('creates order successfully when stock is sufficient', () => {
      const order = new Order({ productId: 'prod-1', productStock: 10, quantity: 3 });
    
      expect(order.productId).toBe('prod-1');
      expect(order.quantity).toBe(3);
      expect(order.id).toBeDefined();
    });
    

    Tidak ada mock, tidak ada setup. Test ini adalah pure JavaScript — berjalan dalam milidetik dan tidak pernah flaky.

    Application Use Case — mock port via jest.fn()

    // Application/Orders/CreateOrderUseCase.spec.ts
    import { CreateOrderUseCase } from './CreateOrderUseCase';
    
    const mockRepo = {
      findProductById: jest.fn(),
      save: jest.fn(),
    };
    
    const mockNotifier = {
      notify: jest.fn(),
    };
    
    const useCase = new CreateOrderUseCase(mockRepo as any, mockNotifier as any);
    
    beforeEach(() => jest.clearAllMocks());
    
    it('saves order and triggers notification', async () => {
      mockRepo.findProductById.mockResolvedValue({ stock: 10 });
      mockRepo.save.mockImplementation(async (order) => order);
    
      const result = await useCase.execute({ productId: 'prod-1', quantity: 2 });
    
      expect(mockRepo.save).toHaveBeenCalledTimes(1);
      expect(mockNotifier.notify).toHaveBeenCalledWith(result.id);
    });
    
    it('throws when product not found', async () => {
      mockRepo.findProductById.mockResolvedValue(null);
    
      await expect(
        useCase.execute({ productId: 'prod-x', quantity: 2 })
      ).rejects.toThrow('Product not found');
    });
    

    Use case diuji tanpa database dan tanpa HTTP stack. jest.fn() hanya perlu meng-mock interface — bukan implementasi konkretnya.

    Infrastructure — integration test terhadap Prisma

    // Infrastructure/Database/PrismaOrderRepository.integration.spec.ts
    import { PrismaClient } from '@prisma/client';
    import { PrismaOrderRepository } from './PrismaOrderRepository';
    import { Order } from '../../Domain/Orders/Order';
    
    const prisma = new PrismaClient();
    
    beforeEach(async () => {
      await prisma.product.create({ data: { id: 'prod-1', stock: 10 } });
    });
    
    afterEach(async () => {
      await prisma.order.deleteMany();
      await prisma.product.deleteMany();
    });
    
    afterAll(() => prisma.$disconnect());
    
    it('persists order to database', async () => {
      const repo = new PrismaOrderRepository(prisma);
      const order = new Order({ productId: 'prod-1', productStock: 10, quantity: 2 });
    
      const saved = await repo.save(order);
    
      expect(saved.id).toBeDefined();
      const found = await prisma.order.findUnique({ where: { id: saved.id } });
      expect(found).not.toBeNull();
    });
    

    Test ini satu-satunya yang menyentuh database nyata. Di CI pipeline, test ini bisa dijalankan terpisah dengan jest --testPathPattern=integration.

    Keunggulan utama: domain entity dan use case bisa di-test 100% tanpa database, tanpa HTTP, tanpa library eksternal. Ganti Prisma ke TypeORM? Hanya PrismaOrderRepository.ts yang berubah.

    Cocok untuk: fintech, healthcare, enterprise, sistem yang akan berkembang selama 5–10 tahun.

    ---

    Arsitektur 5: Microservices

    Struktur Sistem

    Di microservices, fitur Orders bukan lagi satu folder — melainkan beberapa service independen yang berjalan di proses dan container terpisah:

    /order-service          ← Express / NestJS app
    /notification-service   ← Express worker + message queue consumer
    /product-service        ← Express / NestJS app
    /api-gateway            ← nginx / Express gateway
    

    Flow antar service:

    Client
      │
      ▼
    [api-gateway]
      │
      ▼
    [order-service] ──── HTTP GET ────► [product-service]   (cek stok)
      │
      │ publish event
      ▼
    [RabbitMQ / BullMQ]  ◄── subscribe ── [notification-service]
    

    order-service tidak langsung memanggil notification-service. Ia hanya mempublish event ke message queue. notification-service subscribe dan bereaksi secara asynchronous.

    // order-service: setelah order tersimpan
    await messageQueue.publish('order.created', {
      orderId: order.id,
      customerEmail: dto.customerEmail,
    });
    

    Pengujian di Microservices

    Unit test per service

    Identik dengan Layered atau Clean Architecture secara internal. Tidak ada perbedaan — setiap service diuji sendiri menggunakan jest.fn().

    Contract testing dengan Pact.js

    Masalah baru muncul: bagaimana memastikan payload event yang di-publish order-service sesuai dengan yang diharapkan notification-service? Keduanya berjalan di proses berbeda.

    Jawabannya adalah contract testing:

    // order-service/order.consumer.pact.spec.ts
    import { PactV3, MatchersV3 } from '@pact-foundation/pact';
    import { NotificationClient } from './notification.client';
    
    const { like } = MatchersV3;
    
    const provider = new PactV3({
      consumer: 'order-service',
      provider: 'notification-service',
      dir: './pacts',
    });
    
    it('sends correct payload to notification-service', async () => {
      await provider
        .uponReceiving('an order confirmation request')
        .withRequest({
          method: 'POST',
          path: '/notify',
          body: { orderId: like('order-123') },
        })
        .willRespondWith({ status: 200 })
        .executeTest(async (mockServer) => {
          const client = new NotificationClient(mockServer.url);
          await client.notify('order-123');
        });
    });
    

    Contract ini di-generate sebagai file JSON. notification-service kemudian memverifikasi bahwa ia bisa memenuhi kontrak tersebut — tanpa order-service harus ikut berjalan.

    E2E test via Docker Compose

    Untuk verifikasi end-to-end, jalankan semua service menggunakan docker-compose up dan hit endpoint dari luar:

    // e2e/order-flow.e2e.spec.ts
    it('creates order and triggers notification', async () => {
      const res = await fetch('http://localhost:3000/orders', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          productId: 'prod-1',
          quantity: 2,
          customerEmail: 'user@test.com',
        }),
      });
    
      expect(res.status).toBe(201);
      const order = await res.json();
      expect(order.id).toBeDefined();
    });
    

    Tanpa contract testing, perubahan sekecil apapun pada payload event di order-service bisa memecah notification-service tanpa ada yang tahu — sampai sudah di production.

    Cocok untuk: tim 50+ developer, platform dengan traffic tidak merata, sistem yang harus di-scale secara independen per fitur.

    ---

    Perbandingan Side-by-Side

    AspekSimple MVCLayeredFeature-BasedClean ArchModular MonolithMicroservices
    Jumlah file untuk fitur Order2468+6–8 per modulper-service
    Unit test business logicTidak bisaBisaBisaSangat mudahMudah (per modul)Bisa per service
    Test butuh database?Ya (selalu)TidakTidakTidakTidakTidak per unit
    Contract testing dibutuhkan?TidakTidakTidakTidakTidakYa (Pact.js)
    Kecepatan test suiteLambatSedangSedangCepatSedangSedang
    Ganti databaseUbah semuaUbah repositoryUbah repositoryUbah 1 fileUbah 1 file per modulPer service
    Onboarding developer baruCepatCepatSedangLambatSedangLambat
    ---

    Kesimpulan

    Semua arsitektur bisa di-test. Tapi cara dan kemudahannya sangat berbeda.

    Simple MVC cocok saat kecepatan adalah prioritas dan sistem tidak akan berkembang jauh. Supertest cukup untuk kebutuhan testing di skala itu.

    Layered dan Feature-Based adalah sweet spot untuk sebagian besar aplikasi Node.js. jest.fn() membuat mocking bersih tanpa library tambahan. Feature-Based dengan NestJS menambahkan keuntungan dependency injection yang deklaratif dan test yang hidup di dalam modul.

    Clean Architecture adalah investasi — butuh lebih banyak file dan disiplin. Tapi domain entity dan use case bisa di-test sebagai pure TypeScript, tanpa Prisma, tanpa Express, tanpa apapun. Nilainya terasa seiring waktu ketika bisnis rules makin kompleks.

    Modular Monolith duduk tepat di antara Feature-Based dan Microservices — satu deployment unit, tapi batas modul ditegakkan oleh tooling bukan sekadar konvensi. Jika sistem sudah outgrow Feature-Based tapi belum siap dengan kompleksitas distributed system, ini adalah langkah yang tepat sebelum microservices. Baca lebih lanjut di post khusus Modular Monolith.

    Microservices bukan tentang folder lagi, tapi tentang sistem. Ia membuka kebutuhan baru — contract testing — yang tidak ada di arsitektur monolitik manapun. Pact.js adalah tooling yang perlu dipelajari saat tim mulai memecah sistem.

    Pilih arsitektur yang testingnya bisa dijaga konsisten oleh tim yang ada sekarang. Bukan yang paling canggih — yang paling bisa dipertahankan.

    ---

    Penutup

    Pertanyaan yang tepat bukan:

    > "Arsitektur mana yang terbaik?"

    Tapi:

    > "Arsitektur mana yang paling mudah diuji dan dipertahankan oleh tim saya, untuk kompleksitas yang kita hadapi sekarang?"

    Kode yang bisa diuji adalah kode yang bisa dipercaya. Dan kode yang bisa dipercaya adalah fondasi dari sistem yang bisa tumbuh.

    Tag

    Software EngineeringArchitectureTypeScriptNodeJSNestJSTesting
    Y

    Yudi Nugraha

    Software Engineer | Builder

    Artikel Lainnya

    Jelajahi lebih banyak artikel dengan topik serupa

    Lihat Semua Artikel