Home / Blog / Engineering
Engineering

Folder Structure in Practice: One Feature, Five Architectures (Node.js)

Building the same Orders feature using Simple MVC, Layered, Feature-Based, Clean Architecture, and Microservices — with testing strategies for each, using real code examples in TypeScript, Express, and NestJS.

Yudi Nugraha
May 3, 2026
14 min read

In the previous post, we talked about how folder structure evolved from Simple MVC to Microservices — and why each approach emerged to solve the problems of the one before it.

This time we go somewhere more concrete: what does the code actually look like?

The approach: we build the same feature using all five architectures, then examine how each one is tested. That way, the differences can be compared directly — apples-to-apples.

---

The Scenario: Orders Feature

The feature we build across all architectures:

  • POST /orders — create a new order
  • GET /orders/:id — fetch order details
  • Validation: product stock must be available before the order is created
  • Notification: send a confirmation email after the order succeeds
  • This feature was chosen because it involves more than one layer — HTTP, business logic, database, and an external service. Real enough to show architectural differences, but not so complex it obscures the point.

    Tech stack used across all examples:

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

    Architecture 1: Simple MVC

    Folder Structure

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

    Implementation

    In Simple MVC, all logic lives inside a single route handler. The controller directly validates stock, saves the order via Prisma, and sends an email — all in one function.

    // 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);
    });
    

    The code is readable and fast to write. But every time new logic is needed — discounts, fraud checks, audit logs — it all goes into the same handler. Within a few sprints, this function can grow to hundreds of lines.

    Testing Simple MVC

    Because logic and infrastructure are mixed in one handler, pure unit testing is not possible. The only option is integration testing: spin up the entire application and hit the endpoint.

    In Express, this is done with 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);
    });
    

    Every test requires a pre-seeded database. Tests are slow because they go through the full HTTP stack and database connection. Testing edge cases requires elaborate data setup in every test.

    Best fit for: prototypes, small internal tools, validating ideas in 1–2 weeks.

    ---

    Architecture 2: Layered Architecture

    Folder Structure

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

    Implementation

    Business logic moves to OrderService. The controller's only job is to receive the request and delegate.

    // 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 });
      }
    });
    

    Testing Layered Architecture

    Now OrderService can be tested in isolation by mocking IOrderRepository with jest.fn() — no database needed:

    // 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' })
      );
    });
    

    Tests run fast because they never touch the database or HTTP stack. Different scenarios can be tested just by changing the mock return value.

    Best fit for: REST APIs with 5–15 endpoints, teams of 2–5 developers.

    ---

    Architecture 3: Feature-Based Structure

    Folder Structure

    /modules
      /orders
        order.controller.ts
        order.service.ts
        order.repository.ts
        order.model.ts
        order.dto.ts
        order.module.ts
        order.spec.ts         ← test lives inside the module
      /notifications
        notification.service.ts
        notification.module.ts
    

    Implementation

    This is where NestJS comes in. The framework is explicitly designed for a modular approach. Each module registers its own providers and can import other modules.

    // 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;
      }
    }
    

    Testing Feature-Based Structure

    NestJS provides Test.createTestingModule() which lets us build an isolated module for testing. Real providers are replaced with mocks.

    // 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');
      });
    });
    

    The test file lives inside modules/orders/ — right next to the code it tests. A new developer joining the Orders module finds everything in one place.

    Best fit for: SaaS products, teams of 5–20 developers, applications with a growing number of features.

    ---

    Architecture 4: Clean Architecture

    Folder Structure

    /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
    

    Implementation

    In Clean Architecture, dependencies must point inward — toward the domain and application layers. The Domain must not know about Prisma, Express, or 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 has no idea whether we're using Prisma, Mongoose, or any other database. It only knows IOrderRepository — a contract.

    Testing Clean Architecture

    There are three distinct test layers, each with a different purpose and speed.

    Domain Entity — pure unit test, zero dependencies

    // 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();
    });
    

    No mocks, no setup. This is pure JavaScript — runs in milliseconds and is never 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');
    });
    

    The use case is tested without a database or HTTP stack. jest.fn() only needs to mock the interface — not the concrete implementation.

    Infrastructure — integration test against 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();
    });
    

    This is the only test that touches a real database. In the CI pipeline, it can be run separately with jest --testPathPattern=integration.

    Key advantage: domain entity and use case can be tested 100% without a database, without HTTP, without any external library. Switch from Prisma to TypeORM? Only PrismaOrderRepository.ts changes.

    Best fit for: fintech, healthcare, enterprise, systems expected to evolve over 5–10 years.

    ---

    Architecture 5: Microservices

    System Structure

    In microservices, the Orders feature is no longer a single folder — it becomes several independent services running in separate processes and containers:

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

    Flow between services:

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

    order-service never directly calls notification-service. It only publishes an event to the message queue. notification-service subscribes and reacts asynchronously.

    // order-service: after the order is saved
    await messageQueue.publish('order.created', {
      orderId: order.id,
      customerEmail: dto.customerEmail,
    });
    

    Testing Microservices

    Unit test per service

    Identical to Layered or Clean Architecture internally. Each service is tested on its own using jest.fn().

    Contract testing with Pact.js

    A new problem emerges: how do we ensure the event payload published by order-service matches what notification-service expects? They run in separate processes.

    The answer is 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');
        });
    });
    

    This contract is generated as a JSON file. notification-service then verifies it can fulfill the contract — without order-service needing to run alongside it.

    E2E test via Docker Compose

    For end-to-end verification, run all services with docker-compose up and hit the endpoint from outside:

    // 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();
    });
    

    Without contract testing, any change to the event payload in order-service can silently break notification-service — and no one finds out until production.

    Best fit for: teams of 50+ developers, platforms with uneven traffic, systems that must scale independently per feature.

    ---

    Side-by-Side Comparison

    AspectSimple MVCLayeredFeature-BasedClean ArchModular MonolithMicroservices
    Files for Orders feature2468+6–8 per moduleper-service
    Unit test business logicNot possibleYesYesVery easyEasy (per module)Yes per service
    Tests require a database?Yes (always)NoNoNoNoNo per unit
    Contract testing needed?NoNoNoNoNoYes (Pact.js)
    Test suite speedSlowMediumMediumFastMediumMedium
    Swap databaseChange everythingChange repositoryChange repositoryChange 1 fileChange 1 file per modulePer service
    Onboarding a new developerFastFastMediumSlowMediumSlow
    ---

    Conclusion

    Every architecture can be tested. But how easy — and how meaningful — that testing is varies enormously.

    Simple MVC works when speed is the priority and the system won't grow much. Supertest is sufficient for testing at that scale.

    Layered and Feature-Based are the sweet spot for most Node.js applications. jest.fn() makes mocking clean without extra libraries. Feature-Based with NestJS adds the benefit of declarative dependency injection and tests that live inside the module.

    Clean Architecture is an investment — it requires more files and more discipline. But the domain entity and use case can be tested as pure TypeScript, without Prisma, without Express, without anything. That value compounds over time as business rules grow more complex.

    Modular Monolith sits exactly between Feature-Based and Microservices — a single deployment unit, but with module boundaries enforced by tooling rather than convention. If the system has outgrown Feature-Based but isn't ready for distributed system complexity, this is the right step before microservices. Read more in the dedicated Modular Monolith post.

    Microservices is no longer about folders — it's about systems. It introduces a new need — contract testing — that doesn't exist in any monolithic architecture. Pact.js is the tooling to learn when teams start splitting the system.

    Choose the architecture whose testing can be kept consistent by the team you have right now. Not the most sophisticated one — the most maintainable one.

    ---

    Closing Thoughts

    The right question isn't:

    > "Which is the best architecture?"

    It's:

    > "Which architecture is easiest to test and maintain by my team, given the complexity we're facing right now?"

    Code that can be tested is code that can be trusted. And code that can be trusted is the foundation of a system that can grow.

    Tags

    Software EngineeringArchitectureTypeScriptNodeJSNestJSTesting
    Y

    Yudi Nugraha

    Software Engineer | Builder

    More Articles

    Explore more articles on similar topics

    View All Articles