背景
NestJS 是目前最流行的 Node 企业级框架。如果你是一位前端开发,又想学习后端开发,我建议你去学习 NestJS,为什么这么说,虽然其他 Node 框架也能搭建后端服务,但是我感觉这些框架缺乏后端的灵魂,后端最重要的东西其实是架构和思想,而 NestJS 的 IOC 、AOP 等架构能力开箱即用,其设计理念与 Java 中的 Spring 框架高度对标。如果你打算开发一个结构复杂、可维护性强的 Node 服务,同时想去接触前端很少接触到的概念,那就用 NestJS 吧。
本篇文章是 NestJS 架构设计系列的开篇文章,提到 DDD(Domain-Driven Design,领域驱动设计) ,很多人都会觉得它难以理解,网上也有很多文章介绍,但往往充斥着通用语言 、限界上下文 、战略设计 、战术设计等抽象术语,读完后仍一头雾水。
本篇文章不会讲这些抽象术语,而是聚焦实战。我们将以一个真实的 NestJS 中大型企业级项目为背景,用 5 分钟时间带你抓住 DDD 的核心命脉。
其实,DDD 的本质思想非常简单:让代码结构真实反映业务逻辑,而不是围绕数据库表结构来组织代码。
DDD 的核心:分层架构
DDD 最核心的概念就是分层架构,理解它你就能完全抓取 DDD 的命脉。在 NestJS 企业级项目中,某个业务模块代码通常被划分为三层:
js
packages/app/src/modules/
├── domain/ # 领域层:业务的核心逻辑
│ ├── entity/ # 领域实体(Entity)
│ ├── service/ # 领域服务(Domain Service)
│ └── repository/ # 仓储接口(Repository Interface)
├── app/ # 应用层:协调用例与外部交互
│ ├── usecase/ # 应用用例(Use Case)
│ └── adapter/ # 适配器(如 Controller、外部服务代理等)
└── client/ # 客户端层:面向外部的数据契约
├── dto/ # 数据传输对象(DTO)
└── event/ # 领域事件(Domain Event)
我们逐一解释下以上每层的概念:
领域层:业务的核心逻辑
领域层是业务逻辑的核心,它不依赖任何外部框架、数据库或外部服务,只关注业务本身。领域层包括领域实体(Entity)、领域服务(Domain Service)和仓储(Repository)。
领域实体(Entity)
领域实体是业务的核心对象,是具有唯一标识的对象,它包含业务属性 和业务方法,以 User 实体为例:
js
// 领域实体示例:User
@Entity({
tableName: 'users',
customRepository: () => UserRepository,
})
@SoftDeletable()
export class User extends BaseEntity {
// 业务属性
@PrimaryKey()
id: number;
@Property({ type: StringType })
name: string;
// 业务方法
setPassword(password: string) {
this._password = bcryptHash(password, 10);
}
getPassword() {
return this._password;
}
}
实体不仅包含业务属性,更重要的是封装了业务方法。例如以上 User 实体中的 setPassword 方法体现了领域逻辑的内聚性,密码加密逻辑被封装在实体内部,而不是暴露给外部。
领域服务(Domain Service)
当某个业务逻辑跨越多个实体,或不适合归属到单一实体时,就应将其提取为领域服务:
js
@Injectable()
export class CreateRpcModuleService {
@Inject()
private readonly rpcModuleRepository: RpcModuleRepository;
@Inject()
private readonly domainEventPublisher: DomainEventPublisher;
async execute(projectId: number, userId: number, createRpcModuleDto: CreateRpcModuleDto): Promise<RpcModule> {
const createData: PartialEntityDto<RpcModule> = {
...createRpcModuleDto,
projectId,
creatorId: userId,
editorId: userId,
};
// 通过 Repository 操作数据
const entity = await this.rpcModuleRepository.create(createData);
// 通过发布领域事件,通知其他业务模块
await this.domainEventPublisher.publishSync(new RpcModuleCreateEvent(entity);
return entity;
}
}
领域服务通过依赖注入 获取仓库 rpcModuleRepository、领域事件发布方法 domainEventPublisher 执行复杂的业务逻辑,确保业务规则的一致性。这里我们要注意如下关键点:
- 领域服务只关注业务逻辑,不关心数据如何存储;
- 通过
Repository操作数据,而不是直接调用 ORM 或数据库; - 通过发布领域事件,通知其他业务模块,解耦业务副作用,如通知、日志、缓存更新等。
仓储(Repository):数据访问的抽象
仓储是领域层 与数据访问层之间的桥梁,其核心价值在于:
- 封装数据访问逻辑:领域层无需关心底层存储细节(如 MySQL、MongoDB 等),实现解耦;
- 提供具有业务语义的查询方法 :方法命名体现业务意图(如
findOneByIdAndProjectId),而非暴露原始 SQL 或查询构造器; - 保护领域模型的完整性:确保对聚合根或实体的访问必须通过仓储,避免绕过业务规则直接操作数据。
看一个例子:
js
export class RpcModuleRepository extends EntityRepository<RpcModule> {
async findOneByIdAndProjectId(id: number, projectId: number): Promise<RpcModule> {
if (!id) {
throw ExceptionFactory.bizException(ErrorCode.NOT_FOUND);
}
const entity = await this.findOne({
id,
projectId,
} as FilterQuery<RpcModule>);
if (!entity) {
throw ExceptionFactory.bizException(ErrorCode.NOT_FOUND);
}
return entity;
}
}
在这个例子中,仓储不仅封装了数据查询,还内嵌了业务方法:
- 自动校验
projectId,确保项目级的数据隔离; - 在未找到实体时抛出业务异常,而非返回
null,避免上游调用者处理空值逻辑; - 隐藏了底层 ORM 或数据库的具体实现。
领域服务只需调用 repository.findOneByIdAndProjectId(id, projectId),既无需了解底层是 MySQL 还是 MongoDB,也无需编写任何 SQL 或查询语句。
应用层:用例编排
应用层负责编排领域服务,处理用例级别的逻辑,看个例子:
js
async execute(requestState: RequestState, createRpcModuleDto: CreateRpcModuleDto): Promise<ResponseDtoType> {
const { projectId, userId } = requestState;
// 调用领域服务
const entity = await this.createRpcModuleService.execute(projectId, userId, createRpcModuleDto);
// 转换为 DTO
const result = await this.dataAssembler.assemble({
domainObject: entity,
});
// 统一响应格式
return SingleResponse.of(result);
}
通过以上的代码示例,我们总结出应用层的核心职责:
- 接收用户请求,如 HTTP、消息队列等;
- 调用领域服务完成业务逻辑;
- 使用 DataAssembler 数据组装器将领域对象转换为 DTO,避免污染领域模型;
- 统一响应格式,保障接口一致性。
客户端层:对外接口的契约
客户端层定义了模块与外部世界交互的数据契约,主要包括 DTO(数据传输对象) 和 领域事件(Domain Events)。
DTO:数据传输对象
DTO 负责描述外部请求或响应的数据格式,并集成验证、转换与字段控制能力:
js
export class CreateRpcModuleDto {
@Expose()
@IsNotEmpty()
@MaxLength(255)
readonly name: string;
@Expose()
@IsNotEmpty()
@Transform(({ value }) => safeToInteger(value))
readonly dataSourceId: number;
// 其余字段
// ...
}
这个 DTO 做了几件事:
- 验证 :通过
class-validator(如@IsNotEmpty()、@MaxLength())确保输入合法; - 转换 :通过
class-transformer的@Transform()将原始值(如字符串"123")转为目标类型(如number); - 暴露 :
@Expose()控制哪些字段可以接收。
领域事件(Domain Events)
前面在介绍领域服务时有提到领域事件,它是在客户端层进行定义模块对外发布的领域事件,用于通知系统内其他部分某件业务已发生:
js
@DomainEvent({ async: true })
export class RpcModuleCreateEvent extends BaseDomainEvent {
static eventName = Symbol(RpcModuleCreateEvent.name);
eventName: symbol = RpcModuleCreateEvent.eventName;
public readonly id: number;
public readonly projectId: number;
public readonly creatorId: number;
constructor(entity: { id: number; projectId: number; creatorId: number }) {
super();
this.id = entity.id;
this.projectId = entity.projectId;
this.creatorId = entity.creatorId;
}
}
该领域事件接收了参数 entity,包含字段有 id、projectId、creatorId。在某个领域服务中,通过传递 entity 参数触发领域事件:
js
await this.domainEventPublisher.publishSync(
new RpcModuleCreateEvent(entity)
);
通过领域事件,我们可以得到以下优势:
- 解耦:发布者不需要知道订阅者;
- 异步处理:支持同步和异步两种模式;
- 可扩展:新增功能只需订阅事件,无需修改现有代码。
总结
DDD 不是万能的,并非适合所有项目,但它能让你的代码更贴近业务、更好维护。通过分层架构、领域事件机制、数据转换等实践,你能把系统搭建得更清晰。最后给一些实用建议:
- 先想清业务,再写代码:从领域层开始设计,先把业务里的核心角色、行为理顺,再去考虑应用层怎么调用;
- 用领域事件来解耦:模块之间别互相硬调用,用事件通知的方式,让各自更独立;
- 让领域层保持干净:领域层只关心业务逻辑,不掺杂框架、数据库这些细节;
- 用
Assembler做数据转换 :不要直接把领域对象返回给外层,用Assembler把它们转换成需要的结构。
记住一句话:DDD 的核心就是让代码讲业务语言,而不是讲数据库语言。本篇文章由于篇幅所限,所以很多细节没有做深入探讨,如果你觉得有什么地方遗漏、疑惑,欢迎评论讨论。