This article is published in English.
Structuring Node.js Services with Domain-Driven Modules and Clean Layers
Learn how to organize a Node.js codebase into domain-based components, enforce a strict 3-tier architecture, and expose shared utilities through clean public APIs.
When you kick off a new Node.js service, momentum builds quickly. Frameworks like Express, Fastify, and NestJS let you have a REST or GraphQL endpoint running in minutes. But as teams scale and business logic multiplies, a familiar problem tends to surface in these codebases: they degenerate into a tangled, hard-to-follow mess often nicknamed the "spaghetti backend."
This piece opens a four-part look at enterprise-grade Node.js practices, focused on laying down an architectural foundation that can survive contact with a growing team. You'll see how to organize code around business domains, keep clear separation between layers, and build shared modules that don't turn into a liability.
1. Structure by Business Components (Domain-Driven Organization)
One recurring misstep in Node.js projects is arranging the codebase purely by technical role at the top level of the src folder.
The Anti-Pattern: Technical Layering at Root
❌ AVOID: Layered by technical type
src/
├── controllers/
│ ├── userController.js
│ ├── orderController.js
│ └── paymentController.js
├── models/
│ ├── userModel.js
│ ├── orderModel.js
│ └── paymentModel.js
├── services/
│ ├── userService.js
│ ├── orderService.js
│ └── paymentService.js
└── routes/
├── userRoutes.js
├── orderRoutes.js
└── paymentRoutes.js
Why Technical Layering Fails at Scale
- Poor locality: Building a single feature — say, an "Order Refund" workflow — forces you to jump between four or more unrelated folders.
- Fuzzy boundaries: Because everything technical sits together, engineers end up reaching directly across services and models, which breeds circular dependencies and tight coupling.
- Mounting cognitive load: Once you're past 50 models, a monolithic
controllers/ormodels/directory becomes genuinely hard to navigate.
The Solution: Modular Domain Components
A better approach is to organize around business capabilities — what Domain-Driven Design calls Bounded Contexts. Each component becomes a self-sufficient domain, bundling its own controllers, services, repositories, and domain models together.
✅ PREFER: Component-based architecture
src/
├── components/
│ ├── users/
│ │ ├── users.controller.js
│ │ ├── users.service.js
│ │ ├── users.repository.js
│ │ └── users.routes.js
│ ├── orders/
│ │ ├── orders.controller.js
│ │ ├── orders.service.js
│ │ ├── orders.repository.js
│ │ └── orders.routes.js
│ └── payments/
│ ├── payments.controller.js
│ ├── payments.service.js
│ └── payments.repository.js
└── shared/
├── logger/
└── database/
Benefits:
- Self-containment: Anything tied to
ordersstays inside a single folder. - Microservice readiness: If the
paymentscomponent becomes too large or complex, pulling it out into its own microservice is far easier, since its dependencies are already isolated from the rest of the app.
2. Enforce a Strict 3-Tier Architecture Inside Components
Within every business component, keep a firm separation between three layers:
┌─────────────────────────────────────────────────────────┐
│ 1. Entry Point / Transport Layer │
│ (Controllers, Event Subscribers, Route Handlers) │
└───────────────────────────┬─────────────────────────────┘
│ Calls with plain DTOs
▼
┌─────────────────────────────────────────────────────────┐
│ 2. Domain / Business Layer │
│ (Services, Business Logic, Validation Rules) │
└───────────────────────────┬─────────────────────────────┘
│ Calls repository methods
▼
┌─────────────────────────────────────────────────────────┐
│ 3. Data Access Layer │
│ (Repositories, ORMs, Database Queries) │
└─────────────────────────────────────────────────────────┘
The Golden Rule: Keep Web Objects Out of Domain Logic
Business services and repositories should never receive framework-specific objects — no Express req or res, no Fastify request instance.
The Bad Way: Leaking HTTP Transport Objects
// orders.service.js
async function createOrderService(req, res) {
// BAD: Service knows about HTTP headers, status codes, and req.body
const userId = req.headers['x-user-id'];
const orderData = req.body;
if (!orderData.items || orderData.items.length === 0) {
return res.status(400).json({ error: "Cart cannot be empty" });
}
const newOrder = await db.orders.insert({ userId, ...orderData });
return res.status(201).json(newOrder);
}
Why this breaks:
createOrderServicebecomes unusable outside an HTTP context — you can't call it from a CLI script, a Kafka or RabbitMQ consumer, or a cron job, since none of those provide areqorres.- Tests now need to mock HTTP request and response objects instead of passing ordinary JavaScript values.
The Right Way: Decoupled Service Layer
// orders.controller.js (Transport Layer)
const orderService = require('./orders.service');
async function handleCreateOrder(req, res, next) {
try {
// 1. Extract values from web context
const userId = req.headers['x-user-id'];
const { items, shippingAddress } = req.body;
// 2. Call domain service with pure primitives/DTOs
const order = await orderService.createOrder({
userId,
items,
shippingAddress
});
// 3. Format HTTP response
return res.status(201).json({ status: 'success', data: order });
} catch (err) {
next(err); // Defer error handling to central middleware
}
}
// orders.service.js (Domain Layer)
const orderRepo = require('./orders.repository');
async function createOrder({ userId, items, shippingAddress }) {
// 1. Pure business logic validation
if (!items || items.length === 0) {
throw new ValidationError('Order must contain at least one item.');
}
// 2. Business calculation
const totalAmount = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
// 3. Persist via repository
const createdOrder = await orderRepo.saveOrder({
userId,
items,
shippingAddress,
totalAmount,
status: 'PENDING'
});
return createdOrder;
}
3. Modularize Utilities and Enforce Clean Public APIs
As an application grows, shared concerns such as logging, authentication, database connection helpers, and clients for external APIs end up needed by many different components. Rather than letting every consumer reach directly into deep internal file paths, package these shared utilities as internal modules with a clearly defined public entry point.
The Danger of Deep Importing
// ❌ BAD: Tightly coupled to internal folder structures
const { formatLog } = require("../../shared/logger/utils/formatters/textFormatter.js");
If the team responsible for the logger later decides to reorganize its internal folder layout, every consumer that imported from that deep path breaks immediately.
Solution A: Exporting via index.js (CommonJS)
Set up a single entry file that re-exports only the pieces meant for external use, hiding everything else.
// shared/logger/index.js
const { logger } = require('./loggerCore');
const { auditLog } = require('./auditLogger');
// Expose ONLY public functions
module.exports = {
logger,
auditLog
};
Consumers can then import through that clean interface instead of reaching into internals:
// ✅ GOOD: Clean import via public interface
const { logger } = require('../../shared/logger');
Solution B: Package Exports with ESM (Node.js Workspaces / Modern ESM)
If you're working with modern Node.js ESM code or packages inside a monorepo, use the exports field in package.json to explicitly restrict which files can be imported from outside the package.
// shared/logger/package.json
{
"name": "@my-app/logger",
"version": "1.0.0",
"main": "./src/index.js",
"exports": {
".": "./src/index.js"
}
}
With this configuration in place, any attempt to import a private path such as @my-app/logger/src/internal/formatter.js fails with a runtime error, giving your team a hard boundary that prevents accidental coupling to implementation details.
Architecture Checklist for Part 1
Before moving on to error handling and asynchronous workflows, use this checklist to confirm your codebase follows the principles above:
- Domain organization: Is the code arranged by feature components (
orders,users) instead of generic technical folders (controllers,models)? - Pure business services: Do your service functions stay free of transport-layer arguments such as
req,res, ornext? - Explicit interfaces: Do shared modules present a deliberate, restricted public surface, whether through an
index.jsfile orpackage.jsonexports? - Correct data flow: Does data move strictly downward, from controller to service to repository, without lower layers reaching back up to import from higher ones?