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.
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 └─────────────────┘
Analogy: think of the four layers like a restaurant: the waiter (route) takes the order and never touches a pan, the line cook (controller) plates what the kitchen produces, the executive chef (service) decides the recipe and the rules, and the pantry runner (repository) is the only one allowed into the walk-in fridge.
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.
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.
Services contain business logic. They are pure JavaScript functions — no HTTP, no req, no res. This makes them trivially testable and reusable.
// 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); }
Production story: on a blockchain indexer processing 500–1,000 transactions per second, reorg-handling logic (rewinding and reapplying events on a chain fork) got hand-rolled inside a webhook route handler instead of the service layer. Nobody could unit-test it without a full HTTP server and a mocked chain node, and an off-by-one in rewind depth shipped to production and double-counted several minutes of transactions before the nightly reconciliation job caught it.
This service can be called from an HTTP handler, a cron job, a CLI script, or a test — it does not care. That last point matters more than it looks: a service with no req/res and no direct database driver can be called directly from a test file with plain objects as arguments. P-5 (Testing) builds on this directly — its unit tests call these exact service functions with mocked repositories, no HTTP server or database involved.
Once you have more than a handful of services, a common failure mode appears: orders.service.js needs to check something about the user (e.g. "is this user banned?") so it imports users.service.js — and users.service.js needs a user's order history for a profile page, so it imports orders.service.js back. Node's module loader does not reject this outright, but one of the two imports resolves to a partially-initialized module (an empty object for CommonJS require, or a binding that isn't defined yet for ESM), and a function that looked fine in isolation throws is not a function at runtime — usually the first time that code path actually executes, which can be weeks after the file was written.
This gets worse as layer folders grow, because "services calling other services" feels natural and nothing in the four-layer architecture forbids it.
Three ways out, roughly in order of preference:
Push the shared logic down. If both services need "is this user active", that check probably belongs in users.repository.js (or a small shared helper), not in users.service.js. Services calling repositories they don't "own" is fine; services calling each other in a cycle is not.
Extract a third module. If orders.service and users.service both need order-and-user logic, pull it into its own module (or a domain event) that both can import without importing each other.
Pass data instead of importing the other service.getOrderById can accept an already-loaded user object from the controller instead of calling usersService.findById itself — the controller, which already sits above both services, does the composing.
A quick way to catch these before they reach production: npx madge --circular src (or the ESLint rule import/no-cycle) lists any cyclic import chains in your project.
The repositories above are deliberately "thin" — each function is a single query with no branching business logic. This is sometimes called an anemic repository: it has no opinions, it just moves data. For most CRUD-shaped APIs this is the right call, since business rules belong in the service layer, not scattered across data access.
Two situations push you away from a purely anemic model:
Rich repositories add small, genuinely data-shape-related helpers — e.g. ordersRepo.findPendingOlderThan(days) — that would otherwise force the service to pull every row and filter in memory. The line to hold: a repository method can know how to fetch efficiently, but never why the fetch is happening (that "why" is business logic and stays in the service).
CQRS-lite — for a read-heavy service (a product catalog, a public feed), it's common to split repositories into a query side (orders.read-repository.js, denormalized, maybe backed by a view or a cache) and a command side (orders.write-repository.js, backed by the normalized write model). The two do not need to share a class or interface; they only need to agree on the shape of the entity they both eventually produce. This is overkill for a small CRUD API — reach for it only once read and write patterns have genuinely diverged (e.g. the read side needs joins across five tables the write side never touches).
Both are refinements of the same repository layer above — neither requires abandoning the four-layer architecture.
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.
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';
The factory pattern above works cleanly when you're wiring one or two services in index.js. Once a real app has fifteen services, each depending on two or three repositories plus things like a logger, an email client, and a Redis connection, hand-wiring every createXService({ ... }) call at the top of index.js becomes its own maintenance burden — you end up with fifty lines of construction code before the app can even start.
The common fix is a composition root: one file, conventionally container.js, whose only job is building every dependency graph once and exporting the fully-wired services.
// src/container.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 { createOrdersService } from './services/orders.service.js'; import { createUsersService } from './services/users.service.js'; import { logger } from './utils/logger.js';
This is not a "DI container" in the Spring/NestJS sense — there is no reflection, no decorators, no runtime resolution. It is a plain object graph built by hand in one place. That is enough for most Node.js APIs; reach for a real DI framework (NestJS's, InversifyJS, Awilix) only once the wiring itself has enough branches — different services per tenant, or per feature flag — that a plain file becomes hard to read.
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.
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.
Knowledge Check
In a standard four-layer architecture, what is the primary responsibility of the Service layer?
Why is it recommended to throw custom error classes (like NotFoundError or ValidationError) from the Service layer instead of using res.status().json()?
When comparing "layer-based" structure (e.g., all controllers in one folder) to "feature-based" structure (e.g., all user files in a /users folder), what is a key advantage of the feature-based structure for large applications?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.