NestJS 架构设计:5 分钟抓住 DDD 的命脉

背景

NestJS 是目前最流行的 Node 企业级框架。如果你是一位前端开发,又想学习后端开发,我建议你去学习 NestJS,为什么这么说,虽然其他 Node 框架也能搭建后端服务,但是我感觉这些框架缺乏后端的灵魂,后端最重要的东西其实是架构和思想,而 NestJS 的 IOCAOP 等架构能力开箱即用,其设计理念与 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,包含字段有 idprojectIdcreatorId。在某个领域服务中,通过传递 entity 参数触发领域事件:

js 复制代码
await this.domainEventPublisher.publishSync(
  new RpcModuleCreateEvent(entity)
);

通过领域事件,我们可以得到以下优势:

  • 解耦:发布者不需要知道订阅者;
  • 异步处理:支持同步和异步两种模式;
  • 可扩展:新增功能只需订阅事件,无需修改现有代码。

总结

DDD 不是万能的,并非适合所有项目,但它能让你的代码更贴近业务、更好维护。通过分层架构、领域事件机制、数据转换等实践,你能把系统搭建得更清晰。最后给一些实用建议:

  • 先想清业务,再写代码:从领域层开始设计,先把业务里的核心角色、行为理顺,再去考虑应用层怎么调用;
  • 用领域事件来解耦:模块之间别互相硬调用,用事件通知的方式,让各自更独立;
  • 让领域层保持干净:领域层只关心业务逻辑,不掺杂框架、数据库这些细节;
  • Assembler 做数据转换 :不要直接把领域对象返回给外层,用 Assembler 把它们转换成需要的结构。

记住一句话:DDD 的核心就是让代码讲业务语言,而不是讲数据库语言。本篇文章由于篇幅所限,所以很多细节没有做深入探讨,如果你觉得有什么地方遗漏、疑惑,欢迎评论讨论。

相关推荐
8***J1822 小时前
node.js内置模块之---crypto 模块
node.js
u***28473 小时前
nvm下载安装教程(node.js 下载安装教程)
node.js
q***04634 小时前
2024最新版Node.js下载安装及环境配置教程【保姆级】
node.js
程序员爱钓鱼4 小时前
Node.js 的应用场景:为什么越来越多企业选择它?
前端·node.js·trae
程序员爱钓鱼4 小时前
为什么选择 Node.js?一文深入理解
前端·node.js·trae
g***55755 小时前
TypeScript 与后端开发Node.js
javascript·typescript·node.js
r***18645 小时前
Node.js卸载超详细步骤(附图文讲解)
node.js
前端fighter5 小时前
全栈项目:校友论坛系统
vue.js·mongodb·node.js