首页 / 文章 / 使用领域驱动模块与清晰的分层结构来构建 Node.js 服务

使用领域驱动模块与清晰的分层结构来构建 Node.js 服务

了解如何将 Node.js 代码库按领域划分成独立的组件,严格遵循三层架构,并通过简洁的公共 API 提供共享工具功能。

1300 词

当你启动一个新的 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

为何大规模项目中技术分层会失败

  1. 可访问性差:要实现某个功能——比如“订单退款”流程——就必须在不同且互不相关的文件夹之间来回切换。
  2. 边界模糊:由于所有技术相关内容都集中在一起,工程师不得不直接跨服务及模型进行操作,从而导致循环依赖和紧密耦合。
  3. 认知负担过重:一旦模型数量超过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 的 reqres,也不能使用 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 消费者以及定时任务都无法提供 reqres,因此无法调用该函数。
  • 测试现在需要模拟 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这样的私有路径的操作都会因运行时错误而失败,从而为团队设置了一道明确的界限,避免意外地与实现细节产生耦合。

第一部分的架构检查清单

在继续处理错误处理和异步工作流之前,请使用此检查清单确认您的代码库遵循了上述原则:

  • 域名结构:代码是否按功能组件(ordersusers)来组织,而非使用通用的技术文件夹(controllersmodels)?
  • 纯粹的业务服务:服务函数是否不包含传输层相关的参数,如reqresnext
  • 明确的接口:共享模块是否通过index.js文件或package.json的导出方式,呈现出有意识且受限的公共接口?
  • 正确的数据流:数据是否严格地从上往下流动,即从控制器到服务再到存储层,而不会让下层直接向上层导入数据?

相关阅读

  • 为何将缓冲区数据解码为文本会破坏文件上传 — 阐述了为何将二进制缓冲区数据视为UTF-8文本会悄无声息地损坏上传的文件,并介绍了正确的字节级处理方法以避免这一问题。