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 orderGET /orders/:id — fetch order detailsThis 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:
| Need | Library |
|---|---|
| HTTP framework (MVC/Layered) | Express |
| HTTP framework (Feature-Based) | NestJS |
| ORM | Prisma |
| Unit test | Jest |
| Integration test | Supertest |
| Mocking | jest.fn() |
| Contract testing | Pact.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
| Aspect | Simple MVC | Layered | Feature-Based | Clean Arch | Modular Monolith | Microservices |
|---|---|---|---|---|---|---|
| Files for Orders feature | 2 | 4 | 6 | 8+ | 6–8 per module | per-service |
| Unit test business logic | Not possible | Yes | Yes | Very easy | Easy (per module) | Yes per service |
| Tests require a database? | Yes (always) | No | No | No | No | No per unit |
| Contract testing needed? | No | No | No | No | No | Yes (Pact.js) |
| Test suite speed | Slow | Medium | Medium | Fast | Medium | Medium |
| Swap database | Change everything | Change repository | Change repository | Change 1 file | Change 1 file per module | Per service |
| Onboarding a new developer | Fast | Fast | Medium | Slow | Medium | Slow |
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.