Back/Module P-1 Application Architecture and Project Structure
Module P-1·22 min read

Layered architecture (routes → controllers → services → data), feature folders vs layer folders, the service layer pattern, and dependency injection basics.

Module P-1 — Application Architecture and Project Structure

What this module covers: The Blog API in F-8 worked, but it had a problem: route handlers were doing everything — validating input, querying the database, and formatting responses. At fifty routes that becomes unmaintainable. This module introduces the layered architecture pattern that separates concerns cleanly, explains why each layer exists, and gives you a project structure that scales to hundreds of endpoints without becoming a mess. Every production Node.js codebase uses some version of this.


Why Architecture Matters

Consider a route handler that does everything:

javascript
router.post('/orders', async (req, res, next) => { try { const { userId, items } = req.body; // Validation if (!userId || !items?.length) { return res.status(400).json({ error: 'userId and items required' }); } // Business logic const user = await prisma.user.findUnique({ where: { id: userId } }); if (!user) return res.status(404).json({ error: 'User not found' }); if (user.status === 'banned') return res.status(403).json({ error: 'Account suspended' }); // More business logic let total = 0; const orderItems = []; for (const item of items) { const product = await prisma.product.findUnique({ where: { id: item.productId } }); if (!product) return res.status(404).json({ error: `Product ${item.productId} not found` }); if (product.stock < item.quantity) return res.status(409).json({ error: `Insufficient stock for ${product.name}` }); total += product.price * item.quantity; orderItems.push({ productId: product.id, quantity: item.quantity, price: product.price }); } // Database writes const order = await prisma.order.create({ data: { userId, total, items: { create: orderItems } }, include: { items: true }, }); // Side effects await sendOrderConfirmationEmail(user.email, order); await updateInventory(orderItems); res.status(201).json(order); } catch (err) { next(err); } });

This is 40 lines doing six different jobs. Problems:

  • Untestable in isolation — to test the "banned user" rule you must mock HTTP, the database, email, and inventory
  • Impossible to reuse — if a mobile app and a webhook both need to place orders, this logic is duplicated or copy-pasted
  • Opaque — reading it you cannot tell where HTTP ends and business logic begins

The solution is layers.


The Four-Layer Architecture

HTTP Request
     ↓
┌─────────────────┐
│   Router/Route  │  Handles HTTP: reads req, calls controller, sets res
└────────┬────────┘
         ↓
┌─────────────────┐
│   Controller    │  Orchestrates: validates input, calls services, formats response
└────────┬────────┘
         ↓
┌─────────────────┐
│    Service      │  Business logic: rules, calculations, orchestrates data access
└────────┬────────┘
         ↓
┌─────────────────┐
│  Data Access    │  Database queries: only place that talks to the DB/ORM
└─────────────────┘

Each layer has one job and knows only about the layer below it. A service never reads req.body. A route handler never writes SQL.


Project Structure

src/
├── routes/               # Express routers — thin, just wire up controllers
│   ├── users.routes.js
│   ├── posts.routes.js
│   └── orders.routes.js
│
├── controllers/          # Request/response handling — no business logic
│   ├── users.controller.js
│   ├── posts.controller.js
│   └── orders.controller.js
│
├── services/             # Business logic — no HTTP, no req/res
│   ├── users.service.js
│   ├── posts.service.js
│   └── orders.service.js
│
├── repositories/         # Data access — only place that calls prisma/pool
│   ├── users.repository.js
│   ├── posts.repository.js
│   └── orders.repository.js
│
├── middleware/           # Cross-cutting concerns
│   ├── auth.js           # JWT verification
│   ├── validate.js       # Request validation
│   └── errorHandler.js   # Global error handler
│
├── errors/               # Custom error classes
│   └── AppError.js
│
├── db/
│   └── prisma.js         # Prisma client singleton
│
└── index.js              # Entry point — wires everything together

This is the feature-based slice vs layer-based choice. The structure above groups by layer (all services together). For large apps you may prefer grouping by feature (users/users.route.js, users/users.service.js, etc.). Either works — the important thing is the layer separation, not the folder names.


Layer 1: Routes

Routes are pure wiring. They register a URL pattern, apply middleware, and delegate to a controller. Nothing else.

javascript
// src/routes/orders.routes.js import { Router } from 'express'; import { authenticate } from '../middleware/auth.js'; import { validate } from '../middleware/validate.js'; import { createOrderSchema } from '../validators/orders.schema.js'; import * as ordersController from '../controllers/orders.controller.js'; const router = Router(); router.post( '/', authenticate, // middleware: verify JWT validate(createOrderSchema), // middleware: validate body ordersController.createOrder, // controller: handle the request ); router.get('/:id', authenticate, ordersController.getOrderById); router.get('/', authenticate, ordersController.listOrders); export default router;

No logic. No database calls. Just: middleware chain → controller.


Layer 2: Controllers

Controllers handle the HTTP boundary. They read from req, call services, and write to res. They contain no business logic — they orchestrate.

javascript
// src/controllers/orders.controller.js import * as ordersService from '../services/orders.service.js'; export async function createOrder(req, res, next) { try { const order = await ordersService.createOrder({ userId: req.user.id, // set by authenticate middleware items: req.body.items, }); res.status(201).json(order); } catch (err) { next(err); } } export async function getOrderById(req, res, next) { try { const order = await ordersService.getOrderById( parseInt(req.params.id), req.user.id, ); res.json(order); } catch (err) { next(err); } } export async function listOrders(req, res, next) { try { const { page = '1', limit = '20' } = req.query; const orders = await ordersService.listOrdersByUser(req.user.id, { page: parseInt(page), limit: parseInt(limit), }); res.json(orders); } catch (err) { next(err); } }

Notice: the controller knows about req, res, and next. The service knows nothing about them.


Layer 3: Services

Services contain business logic. They are pure JavaScript functions — no HTTP, no req, no res. This makes them trivially testable and reusable.

javascript
// src/services/orders.service.js import * as ordersRepo from '../repositories/orders.repository.js'; import * as usersRepo from '../repositories/users.repository.js'; import * as productsRepo from '../repositories/products.repository.js'; import { AppError } from '../errors/AppError.js'; import { sendOrderConfirmationEmail } from './email.service.js'; export async function createOrder({ userId, items }) { // 1. Validate the user const user = await usersRepo.findById(userId); if (!user) throw new AppError('User not found', 404); if (user.status === 'banned') throw new AppError('Account suspended', 403); // 2. Validate and price the items let total = 0; const orderItems = []; for (const item of items) { const product = await productsRepo.findById(item.productId); if (!product) throw new AppError(`Product ${item.productId} not found`, 404); if (product.stock < item.quantity) { throw new AppError(`Insufficient stock for ${product.name}`, 409); } total += product.price * item.quantity; orderItems.push({ productId: product.id, quantity: item.quantity, price: product.price }); } // 3. Create the order const order = await ordersRepo.create({ userId, total, items: orderItems }); // 4. Side effects (after successful creation) sendOrderConfirmationEmail(user.email, order).catch(err => { // Don't fail the order if email fails — log and move on console.error('Failed to send order confirmation:', err.message); }); return order; } export async function getOrderById(orderId, requestingUserId) { const order = await ordersRepo.findById(orderId); if (!order) throw new AppError('Order not found', 404); // Business rule: users can only see their own orders if (order.userId !== requestingUserId) { throw new AppError('Forbidden', 403); } return order; } export async function listOrdersByUser(userId, { page, limit }) { const offset = (page - 1) * limit; return ordersRepo.findByUserId(userId, { limit, offset }); }

This service can be called from an HTTP handler, a cron job, a CLI script, or a test — it does not care.


Layer 4: Repositories

Repositories are the only layer that talks to the database. They take plain arguments and return plain data objects. No business logic — just queries.

javascript
// src/repositories/orders.repository.js import prisma from '../db/prisma.js'; export async function create({ userId, total, items }) { return prisma.order.create({ data: { userId, total, items: { create: items }, }, include: { items: { include: { product: true } } }, }); } export async function findById(id) { return prisma.order.findUnique({ where: { id }, include: { items: { include: { product: true } } }, }); } export async function findByUserId(userId, { limit, offset }) { return prisma.order.findMany({ where: { userId }, orderBy: { createdAt: 'desc' }, take: limit, skip: offset, include: { items: true }, }); }

If you switch from Prisma to raw SQL, you change only this file. Routes, controllers, and services are untouched.


Custom Error Classes

Instead of res.status(404).json(...) scattered everywhere, throw typed errors from services and catch them in a central handler.

javascript
// src/errors/AppError.js export class AppError extends Error { constructor(message, statusCode = 500) { super(message); this.name = 'AppError'; this.statusCode = statusCode; Error.captureStackTrace(this, this.constructor); } } export class NotFoundError extends AppError { constructor(resource = 'Resource') { super(`${resource} not found`, 404); this.name = 'NotFoundError'; } } export class ValidationError extends AppError { constructor(message) { super(message, 400); this.name = 'ValidationError'; } } export class UnauthorizedError extends AppError { constructor(message = 'Authentication required') { super(message, 401); this.name = 'UnauthorizedError'; } } export class ForbiddenError extends AppError { constructor(message = 'Forbidden') { super(message, 403); this.name = 'ForbiddenError'; } }
javascript
// src/middleware/errorHandler.js import { AppError } from '../errors/AppError.js'; export function errorHandler(err, req, res, next) { // Known application errors — safe to expose message if (err instanceof AppError) { return res.status(err.statusCode).json({ error: err.message }); } // Prisma errors if (err.code === 'P2025') return res.status(404).json({ error: 'Record not found' }); if (err.code === 'P2002') return res.status(409).json({ error: 'Duplicate record' }); // Unknown — log and return generic message console.error(`[${new Date().toISOString()}] Unhandled error:`, err); res.status(500).json({ error: process.env.NODE_ENV === 'production' ? 'Internal server error' : err.message, }); }

Now services throw new NotFoundError('Order') and the error handler maps it to 404. No HTTP code knowledge needed in services.


Dependency Injection Basics

The layers above are coupled — ordersService directly imports ordersRepo. This is fine for most applications. But for testing, you may want to inject the repository as a dependency so you can swap it for a mock.

Simple factory pattern:

javascript
// src/services/orders.service.js export function createOrdersService(deps) { const { ordersRepo, usersRepo, productsRepo } = deps; return { async createOrder({ userId, items }) { const user = await usersRepo.findById(userId); // ... rest of the logic }, }; } // src/index.js — wire up with real dependencies import * as ordersRepo from './repositories/orders.repository.js'; import * as usersRepo from './repositories/users.repository.js'; import * as productsRepo from './repositories/products.repository.js'; import { createOrdersService } from './services/orders.service.js'; export const ordersService = createOrdersService({ ordersRepo, usersRepo, productsRepo }); // In tests — wire up with fake dependencies const ordersService = createOrdersService({ ordersRepo: { findById: async () => mockOrder }, usersRepo: { findById: async () => mockUser }, productsRepo: { findById: async () => mockProduct }, });

This is optional at first. Start with direct imports and refactor to DI if testing becomes painful.


Applying This to the Blog API

Refactoring F-8's Blog API to the layered structure:

src/
├── routes/
│   ├── users.routes.js      → app.use('/users', usersRouter)
│   └── posts.routes.js      → app.use('/posts', postsRouter)
├── controllers/
│   ├── users.controller.js  → reads req, calls service, writes res
│   └── posts.controller.js
├── services/
│   ├── users.service.js     → createUser, getUserById (business rules here)
│   └── posts.service.js     → createPost, publishPost, deletePost
├── repositories/
│   ├── users.repository.js  → prisma.user.* calls
│   └── posts.repository.js  → prisma.post.* calls
├── errors/
│   └── AppError.js
├── middleware/
│   └── errorHandler.js
├── db/
│   └── prisma.js
└── index.js

Each file has one job. Each file is independently testable. Adding a new feature means adding files in each layer, not modifying existing ones.


Feature Folders vs Layer Folders

The structure above groups by layer. For larger applications, grouping by feature can be better:

src/
├── users/
│   ├── users.routes.js
│   ├── users.controller.js
│   ├── users.service.js
│   ├── users.repository.js
│   └── users.schema.js      # validation schemas
├── posts/
│   ├── posts.routes.js
│   ├── posts.controller.js
│   ├── posts.service.js
│   └── posts.repository.js
├── shared/
│   ├── middleware/
│   ├── errors/
│   └── db/
└── index.js

This co-locates everything related to one domain. Adding a new feature is adding one folder. The trade-off: slightly harder to enforce layer boundaries (nothing stops a service file from importing a controller in the same folder). Use whichever makes navigation faster for your team.


Summary

  • Routes wire URLs to controllers. No logic, no database calls.
  • Controllers handle HTTP. Read req, call services, write res. No business logic.
  • Services contain business logic. Pure JavaScript functions. No HTTP. Testable in isolation.
  • Repositories contain database access. Only layer that calls Prisma or pool.query. Swappable.
  • Custom error classes allow services to throw typed errors that the global handler maps to HTTP status codes.
  • Dependency injection is optional for simple apps, valuable for testing. Factory pattern works without a DI container.
  • The exact folder names do not matter. The layer boundaries do.

Next: authentication — JWT tokens, bcrypt password hashing, and protecting your routes so only the right users can access the right resources.

Discussion