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
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
text
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.
Project Structure
text
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
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
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
Production story: on a blockchain indexer processing 2K–50K events/sec, 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.
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
If you switch from Prisma to raw SQL, you change only this file. Routes, controllers, and services are untouched.
Circular Dependencies Between Services
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.
Anemic vs. Rich Repositories, and CQRS-lite
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.
Custom Error Classes
Instead of res.status(404).json(...) scattered everywhere, throw typed errors from services and catch them in a central handler.
javascript
javascript
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
This is optional at first. Start with direct imports and refactor to DI if testing becomes painful.
The Composition Root: A Central container.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.
javascript
javascript
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.
Applying This to the Blog API
Refactoring F-8's Blog API to the layered structure:
text
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:
text
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.
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.
// src/controllers/orders.controller.jsimport*as ordersServicefrom'../services/orders.service.js';exportasyncfunctioncreateOrder(req, res, next){try{const order =await ordersService.createOrder({userId: req.user.id,// set by authenticate middlewareitems: req.body.items,}); res.status(201).json(order);}catch(err){next(err);}}exportasyncfunctiongetOrderById(req, res, next){try{const order =await ordersService.getOrderById(parseInt(req.params.id), req.user.id,); res.json(order);}catch(err){next(err);}}exportasyncfunctionlistOrders(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);}}
// src/services/orders.service.jsimport*as ordersRepofrom'../repositories/orders.repository.js';import*as usersRepofrom'../repositories/users.repository.js';import*as productsRepofrom'../repositories/products.repository.js';import{AppError}from'../errors/AppError.js';import{ sendOrderConfirmationEmail }from'./email.service.js';exportasyncfunctioncreateOrder({ userId, items }){// 1. Validate the userconst user =await usersRepo.findById(userId);if(!user)thrownewAppError('User not found',404);if(user.status==='banned')thrownewAppError('Account suspended',403);// 2. Validate and price the itemslet total =0;const orderItems =[];for(const item of items){const product =await productsRepo.findById(item.productId);if(!product)thrownewAppError(`Product ${item.productId} not found`,404);if(product.stock< item.quantity){thrownewAppError(`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 orderconst 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 onconsole.error('Failed to send order confirmation:', err.message);});return order;}exportasyncfunctiongetOrderById(orderId, requestingUserId){const order =await ordersRepo.findById(orderId);if(!order)thrownewAppError('Order not found',404);// Business rule: users can only see their own ordersif(order.userId!== requestingUserId){thrownewAppError('Forbidden',403);}return order;}exportasyncfunctionlistOrdersByUser(userId,{ page, limit }){const offset =(page -1)* limit;return ordersRepo.findByUserId(userId,{ limit, offset });}