使用领域驱动模块与清晰的分层结构来构建 Node.js 服务
了解如何将 Node.js 代码库按领域划分成独立的组件,严格遵循三层架构,并通过简洁的公共 API 提供共享工具功能。
当你启动一个新的 Node.js 服务时,开发进度会迅速提升。Express、Fastify 和 NestJS 等框架能让你在几分钟内搭建起 REST 或 GraphQL 接口。但随着团队规模扩大以及业务逻辑日益复杂,这类代码库中往往会出现一个常见的问题:它们会变得错综复杂、难以理解,通常被称为“意大利面式后端”。
本文将分四部分探讨企业级 Node.js 开发实践,重点是为不断壮大的团队奠定稳固的架构基础。你将了解到如何围绕业务领域组织代码、保持各层之间的清晰分离,以及如何构建不会成为负担的共享模块。
1. 按业务组件构建结构(领域驱动型组织)
在 Node.js 项目中,一个常见的错误是在 src 文件夹的顶层仅按照技术职能来组织代码库。
反模式:在根目录进行技术分层
❌ 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
为何大规模项目中技术分层会失败
- 可访问性差:要实现某个功能——比如“订单退款”流程——就必须在不同且互不相关的文件夹之间来回切换。
- 边界模糊:由于所有技术相关内容都集中在一起,工程师不得不直接跨服务及模型进行操作,从而导致循环依赖和紧密耦合。
- 认知负担过重:一旦模型数量超过50个,庞大的
controllers/或models/目录就会变得极其难以导航。
解决方案:模块化的领域组件
更好的方法是以业务能力为核心进行组织——即领域驱动设计中所说的边界上下文。每个组件都会成为一个自给自足的领域,将自身的控制器、服务、数据存储和领域模型整合在一起。
✅ 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/
优势:
- 自我封闭性:与
orders相关的所有内容都保存在同一个文件夹中。 - 微服务适配性:如果
payments组件变得过于庞大或复杂,将其拆分为独立的微服务会容易得多,因为其依赖项已经与应用程序的其他部分隔离开来。
2. 在组件内部严格遵循三层架构
在每个业务组件中,必须严格区分以下三层:
┌─────────────────────────────────────────────────────────┐
│ 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) │
└─────────────────────────────────────────────────────────┘
黄金法则:避免将 Web 对象引入领域逻辑
业务服务与数据存储层绝不应接收框架特定的对象——不能使用 Express 的 req 或 res,也不能使用 Fastify 的请求实例。
错误做法:泄露 HTTP 传输对象
// 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);
}
为何这会出问题:
createOrderService在 HTTP 上下文之外无法使用——因为 CLI 脚本、Kafka 或 RabbitMQ 消费者以及定时任务都无法提供req或res,因此无法调用该函数。- 测试现在需要模拟 HTTP 请求和响应对象,而非传递普通的 JavaScript 值。
正确做法:解耦的服务层
// 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. 模块化工具函数并确保公共 API 的整洁性
随着应用程序的不断发展,许多不同的组件都会需要诸如日志记录、身份验证、数据库连接辅助工具以及外部 API 客户端之类的共享功能。为了避免每个使用方都直接访问复杂的内部文件路径,应当将这些共享工具封装为具有明确公共入口点的内部模块。
深度导入的隐患
// ❌ BAD: Tightly coupled to internal folder structures
const { formatLog } = require("../../shared/logger/utils/formatters/textFormatter.js");
如果负责日志记录功能的团队日后决定重新整理其内部文件夹结构,所有从该深层路径导入的组件都会立即失效。
解决方案一:通过 index.js 导出(CommonJS)
设置一个单一的入口文件,仅重新导出供外部使用的功能部分,将其他内容隐藏起来。
// shared/logger/index.js
const { logger } = require('./loggerCore');
const { auditLog } = require('./auditLogger');
// Expose ONLY public functions
module.exports = {
logger,
auditLog
};
这样,使用方就可以通过这个简洁的接口进行导入,而无需直接访问内部结构:
// ✅ GOOD: Clean import via public interface
const { logger } = require('../../shared/logger');
方案B:使用ESM导出包(Node.js工作区/现代ESM)
如果您正在处理现代Node.js ESM代码或单仓库中的包,可以使用package.json中的exports字段来明确限制哪些文件可以从包外部导入。
// shared/logger/package.json
{
"name": "@my-app/logger",
"version": "1.0.0",
"main": "./src/index.js",
"exports": {
".": "./src/index.js"
}
}
通过这种配置,任何尝试导入如@my-app/logger/src/internal/formatter.js这样的私有路径的操作都会因运行时错误而失败,从而为团队设置了一道明确的界限,避免意外地与实现细节产生耦合。
第一部分的架构检查清单
在继续处理错误处理和异步工作流之前,请使用此检查清单确认您的代码库遵循了上述原则:
- 域名结构:代码是否按功能组件(
orders、users)来组织,而非使用通用的技术文件夹(controllers、models)? - 纯粹的业务服务:服务函数是否不包含传输层相关的参数,如
req、res或next? - 明确的接口:共享模块是否通过
index.js文件或package.json的导出方式,呈现出有意识且受限的公共接口? - 正确的数据流:数据是否严格地从上往下流动,即从控制器到服务再到存储层,而不会让下层直接向上层导入数据?
相关阅读
- 分层式 Node.js API 设计:从臃肿的控制器到整洁架构 — 了解如何将 Node.js API 重构为控制器、服务层和数据访问层,从而解决业务逻辑混乱、错误处理不一致以及扩展困难等问题。
- 在 NestJS 应用中构建领域的六条 DDD 规则 — 学习六条实用的领域驱动设计规则,用于整理 NestJS 的模块、实体和事件,从而使各功能模块保持独立且易于维护。