disgare 的博客
首页
博客
分类
标签
首页
博客
分类
标签
  • 网络

    • 计算机网络学习笔记
    • 网络安全相关
    • 域名和子网掩码
    • CORS 跨域资源共享
    • DNS、HTTP 与 HTTPS
    • Server-Sent Events (SSE)
    • WebSocket 长连接
  • 计算机基础

    • 操作系统 IO 相关知识
    • 操作系统学习笔记
    • 程序的机器级表示
    • 音频文件基础
    • 正则表达式相关概念
    • ffmpeg 的安装以及实现音频切分功能
    • Hex 和 Base64 编码
    • XML 的使用
  • 数据结构与算法

    • 动态规划算法学习笔记
    • 基于比较的排序算法的最坏情况下的最优下界为什么是O(nlogn)
    • 集合与数据结构学习笔记
    • 面试常见算法总结
    • 算法导论第二部分排序学习笔记
    • 算法导论第一部分学习笔记
  • Java

    • 对象之间的映射与转换
    • 反射学习笔记
    • 泛型相关概念
    • 关于 boolean 类型的坑
    • 如何使用 lambda 表达式实现排序
    • CompletableFuture 相关用法
    • CompletableFuture 源码浅要阅读
    • FutureTask 源码阅读
    • Guava 常用 API
    • Guava 源码阅读:Multimap 相关
    • Jackson 的各种使用
    • Java 的 Excel 相关操作
    • java 的常见性能问题分析以及出现场景
    • java 基础知识
    • JAVA 枚举的基础和原理
    • Java 图片文件上传下载处理
    • Java 序列化
    • Java 异常
    • Java 语法糖
    • Java 中关于字符串处理的常用方法
    • Java 中强、软、弱、虚引用
    • JAVA 注解小结
    • Java Http 访问框架
    • Java Stream 的使用
    • Java8 新特性
    • netty 学习笔记
    • Scanner 的各种用法
    • Servlet 学习笔记
    • String、StringBuffer、StringBuilder 学习笔记
  • JVM

    • 虚拟机执行子系统
    • JVM 自动内存管理
    • Linux 中 JVM 常用工具以及常见问题解决思路
  • Linux

    • crontab 表达式
    • Linux 常见命令
    • Linux 文件系统
  • 中间件

    • 关于定时任务原理
    • 详解 kafka
    • ES 搜索引擎
    • flink 提交流程
    • Grape-RAG
    • Hadoop 基础原理
  • 多线程

    • 多线程基础学习笔记
    • 简单了解并发集合
    • 如何手写单例
    • 深入理解 java 多线程安全
    • 生产者消费者问题
    • 线程池作用、用法以及原理
    • AQS 组件
    • ThreadLocal 原理以及使用
  • 非关系型数据库

    • Redis 集群
    • Redis 数据结构、对象与数据库
    • Redis 学习笔记
  • 关系型数据库

    • B+ 树的插入、删除和数据页分裂机制
    • MySQL 的 binglog、redolog、undolog
    • MySQL 的记录存储结构、存储引擎与 Buffer Pool
    • MySQL 基本的特性
    • MySQL 开发规范
    • MySQL 事务与锁与 MVCC
    • MySQL 数据类型、字符集相关内容
    • MySQL 索引与索引优化
    • PostgreSQL 更新数据时 HOT优化
    • PostgreSQL 相关用法
  • Python

    • Python 基础语法
    • Python 学习
  • Spring 项目

    • Lombok 的常用注解
    • maven 小结
    • MyBatis 框架的使用
    • MyBatis 重要知识点总结
    • MybatisPlus 的使用
    • Spring 框架基础使用
    • Spring 事务相关
    • Spring IOC 的原理及源码
    • Spring AOP 的使用和原理
    • SpringBoot 的原理
    • SpringBoot 基础使用
    • SpringWeb 重要知识点
  • 分布式

    • 初步了解 docker
    • 从 ACID 到 BASE 事务处理的实现
    • 访问远程服务
    • 分布式 id
    • 分布式缓存相关问题
    • 分布式集群理论和分布式事务协议
    • 分布式架构的观测
    • 分布式一致性算法
    • 负载均衡 Load Balancing
    • 关于分布式系统 RPC 中高可用功能的实现
    • 集群间数据同步的目的
    • 三高问题下的系统优化
    • 数据库分库分表
    • 详解 Spring Cloud
    • Dubbo 基础概念
    • Gossip 协议
    • nginx 学习笔记
    • Protobuf 通信协议
    • Zookeeper 基础学习
  • 架构设计

    • 参数校验与异常处理
    • 抽象方法与设计模式
    • 代码整洁之道
    • 权限系统设计
    • 用低内存处理大量数据
    • 设计模式——策略模式
    • 设计模式——过滤器模式在 Spring 中的实践
    • 状态模式
    • 统一结果返回
    • 为什么要打日志?怎么打日志?打什么日志?
    • 运维监控常见指标含义
    • 资深研发进阶
    • DDD 架构学习笔记
      • Domain Primitive
        • 定义
        • 为什么会出现 DP
        • DP 的使用
        • 优雅的使用 Domain Primitive
      • 应用架构
        • 贫血模型和充血模型
        • 为什么要六边形架构
        • 六边形架构与 DDD
        • 领域事件
        • 领域
      • DDD 模块设计
        • Presentation(Web、Interfaces)模块
        • Application 模块
        • Application 层的几个核心类
        • Domain 模块
        • Infrastructure 模块
        • 架构示例
      • 模型以及模型之间的转换
        • VO、DTO、BO、PO
        • DDD 中的3种模型
      • 参考
    • Java 常用的规则引擎
    • MVC 架构学习笔记
  • AI

    • 如何编写 Prompt
    • Agent 工程架构
    • LLM 相关内容
    • NLP 相关知识
    • vibe coding 最佳实践
    • windows 下 ollama 迁移到 D 盘
  • 开发工具

    • 如何画时序图、流程图、状态流转图
    • excel 关于 =vlookup 的用法
    • git 的学习以及使用
    • IDEA 插件推荐
    • IDEA 常用快捷键以及调试
    • Shell 脚本
    • swagger 的使用
  • 前端

    • 简单了解前端页面开发
    • 伪静态是什么
    • GitHub Pages 部署教程
    • Vercel 部署教程
    • vue-admin-template 简单使用
    • VuePress 博客搭建指南
  • 项目

    • 面试刷题网——技术方案
    • 影视资源聚合站——技术方案
  • 问题记录

    • 定时任务单线程消费 redis 中数据导致消费能力不足
    • 提供可传递的易受攻击的依赖项
    • Liteflow 在 SpringBoot 启动时无法注入组件问题 couldn‘t find chain with the id[THEN(NodeComponent)]
  • 金融

    • 股票分析——关于电力
    • 股票技术面——量价关系
    • 股票技术面——盘口
    • 股票技术面——基础
    • 基础的金融知识
    • 基金与股票
    • 韭菜的自我总结
    • 聊聊价值投资
  • 其他

    • 程序员职场工作需要注意什么
    • 创业全链路SOP:从灵光一现到系统化增长的实战指南
    • 观罗翔讲刑法随笔
    • 价格和价值
    • 立直麻将牌效益理论
    • 梅花易数学习笔记
    • 压力管理
2023-06-27
架构设计
目录

DDD 架构学习笔记

因为公司的包结构参考借鉴了 DDD 的思想,被迫无奈学习了一下 DDD 相关知识

每一种架构都是为了解决实际工程中的问题,就像设计模式看起来什么用都没有,但是其实是解决了现实工程中遇到的各种问题,主要是为了降低代码的维护与修改的代价

但是 DDD 有些时候确实不好用,它只是一种代码编程思想,一种高内聚低耦合的可行解法,在一些场景中其指导思想往往会将简单的问题复杂化,需要酌情考虑

# Domain Primitive

# 定义

DP 可以将实体类的属性进行显示化,我们一般对有限制的数据类型以及复杂的数据类型使用 DP,但是使用 DP 会增加代码膨胀,除去 PO、DTO 转化为 BO 的成本,在使用到属性的时候还需要进行转换

DP 是一个在特定领域里,拥有精准定义的、可自我验证的、拥有行为的 Value Object

那什么是 Value Object 呢?这里贴一些 DDD 的基础的前置性内容:

  • 实体(Entity):表示业务中的核心对象,具有唯一标识符(主键 ID)。映射一个 po,如订单(Order)、用户(User)等
  • 值对象(Value Object):不可变的对象,用于表示业务中的概念。值对象是没有唯一标识符的对象,OrderQuery、OrderCommend 或者 OrderMoney 可以被视为一个值对象,因为它表示一个查询条件、保存请求、订单内的值,而不是一个具体的实体
  • 聚合(Aggregate):聚合是一组相关对象的集合,这些对象作为一个整体被对待。聚合的主要目的是确保业务规则的一致性和完整性
  • 聚合根(Aggregate Root):聚合通过聚合根来管理这些对象的生命周期,聚合根是聚合的主体
  • DP(领域原语):对领域中的基础值对象的封装,代表了领域中不可再分的基本概念。领域原语通常用于替换基本类型(如String、Integer),以便在类型层面表达业务约束。可以是金额(Money)、地址(Address)等

# 为什么会出现 DP

以下列举的四个原因,是大家在 MVC 架构中检查出现的几种问题(严格来说不算是问题,虽然写不好容易埋雷,但是这就是 service 层需要干的事)

数据验证和错误处理:每个入参都需要方法校验,就算前端已经校验过了,后端为了程序健壮性以及规范,还是要校验一次。虽然现在可以用注解来简化校验过程,但是还有一些需要业务校验的情况在代码中经常出现,在每个方法里这段校验逻辑还是会被重复

在需要新增校验规则与维护原来的校验规则时,会比较麻烦,有没有一种方法,能够一劳永逸的解决所有校验的问题以及降低后续的维护成本和异常处理成本呢?(推荐 javax.validation)

大量的工具类:问题时从一些入参里抽取一部分数据,然后调用一个外部依赖获取更多的数据,然后通常从新的数据中再抽取部分数据用作其他的作用。这种代码通常被称作“胶水代码”,其本质是由于外部依赖的服务的入参并不符合我们原始的入参导致的。为了解决这个问题,一个常见的办法是将这段代码抽离出来,变成独立的一个或多个方法

可测试性:假如一个方法有 N 个参数,每个参数有 M 个校验逻辑,至少要有 N * M 个 TC,要如何降低测试成本呢?

接口的清晰度:在Java代码中,对于一个方法来说所有的参数名在编译时丢失,留下的仅仅是一个参数类型的列表,那么入参为三个 str 的函数就会变成这样

service.register("殷浩", "浙江省杭州市余杭区文三西路969号", "0571-12345678");
1

第三个参数需要传入电话,如果格式不对或者传入了姓名什么的,在真实代码中运行时会报错,但这种 bug 是在运行时被发现的,而不是在编译时

普通的 Code Review 也很难发现这种问题,很有可能是代码上线后才会被暴露出来。这里的思考是,有没有办法在编码时就避免这种可能会出现的问题

# DP 的使用

DP 最重要的使用,是将隐性的概念显性化

原来 username 仅仅是 TestEntityPo 的一个参数,属于隐形概念,如果此时 username 参与了真正的业务逻辑,为了减少维护成本我们需要将 username 的概念显性化

@Data
public class UserName {
	String name;
	public String getName() {
        return name;
    }
    public UserName (String name) {
        if (name == null) {
            throw new ValidationException("number不能为空");
        }
        this.name = name;
    }
	public isEnglish() {
		// 业务校验逻辑
		...
	}
	public isUser() {
		// 业务校验逻辑
		...
	}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

我们将之前的 username 写成一个类,此时:

  • 校验逻辑都放在了 UserName 里面,确保只要该类被创建出来后,一定是校验通过的,数据验证和错误处理都在类中处理
  • 只对该属性操作的方法变成了 UserName 类里的方法
  • 刨除了数据验证代码、胶水代码,在业务层剩下的都是核心业务逻辑
  • 对 entity 中会封装多对象行为

这样做完之后,其实是生成了一个 Type(数据类型)和一个 Class(类):

  • Type 指我们在今后的代码里可以通过 username 去显性的标识电话号这个概念
  • Class 指我们可以把所有跟电话号相关的逻辑完整的收集到一个文件里

这两个概念加起来,构造成了 Domain Primitive(DP)

DP 同样可以封装多对象行为,但是需要封装的属性有足够的关联性,附上一个 DP 与 DTO 比较的图 在这里插入图片描述

# 优雅的使用 Domain Primitive

常见的 DP 的使用场景包括:

  • 有业务限制的 String 或者 Integer:比如 Name,PhoneNumber,OrderNumber,ZipCode,Address 等。一般来说,在从 DTO 转成 VO 时,在 DP 的构造方法里做校验实现这些限制即可
  • 枚举以及可枚举的属性:比如 Status
  • Double 或 BigDecimal:一般用到的 Double 或 BigDecimal 都是有业务含义的,比如 Temperature、Money、Amount、ExchangeRate、Rating 等
  • 复杂的数据结构:比如 Map,尽量能把 Map 的所有操作包装掉,仅暴露必要行为

注意不要这么写:

  • 不要把 DP 的方法写到 DTO 或者 POJO 里。你见过 5000 行的 Order 类吗,我见过。把 DP 当成一个内部类写到 POJO 里我都能接受

# 应用架构

# 贫血模型和充血模型

贫血模型是指领域对象里只有 get 和 set 方法(POJO),所有的业务逻辑都不包含在内而是放在 Service 层,实体缺乏行为和业务逻辑。在这种模型中,实体更像是数据载体,而不包含任何业务规则或复杂逻辑。这通常会导致服务层变得庞大和复杂,难以维护。比如传统的 MVC 架构就是贫血模型

充血模型是指数据和对应的业务逻辑被封装到同一个类中。因此,这种充血模型满足面向对象的封装特性,是典型的面向对象编程风格。在充血模型中,实体不仅持有数据,还持有对数据进行操作的方法,包括验证、计算和其他业务相关的行为。这种方式可以减少服务层的复杂度,提高代码的可读性和可维护性,因为它遵循了高内聚,低耦合(业务逻辑与业务实体强绑定,业务逻辑不与其他逻辑接触)的原则。使用了 DP、聚合就是充血模型了

选择贫血模型还是充血模型,取决于项目的需求、团队的偏好和系统的复杂度。在一些场景下,如简单的 CRUD 操作,贫血模型可能是足够且合适的;而在复杂的业务场景中,采用充血模型,将业务逻辑封装在实体中,往往能更好地反映业务领域,提高代码质量和可维护性

# 为什么要六边形架构

如果忽略应用内部的架构设计,很容易导致代码逻辑混乱,很难维护,容易产生 bug 而且很难发现

一个应用最大的成本一般都不是来自于开发阶段,而是应用整个生命周期的总维护成本,所以代码的可维护性代表了最终成本,强依赖其他三方组件与基层数据库的脚本式代码通常可维护性能差,它可能出现以下几个问题

  • 数据结构的不稳定性:数据库的表结构和设计是应用的外部依赖,都有可能会改变,如果改了 POJO 要改流程也要改
  • 第三方服务依赖的不确定性:第三方服务,比如 Yahoo 的汇率服务未来很有可能会有变化:轻则 API 签名变化,重则服务不可用需要寻找其他可替代的服务。在这些情况下改造和迁移成本都是巨大的。同时,外部依赖的兜底、限流、熔断等方案都需要随之改变
  • 中间件或者数据库更换:今天我们用 Kafka 发消息,明天如果要上阿里云用 RocketMQ 该怎么办?后天如果消息的序列化方式从 String 改为 Binary 该怎么办?

脚本式代码的第二大缺陷是:虽然写单个用例的代码非常高效简单,但是当用例多起来时,其扩展性会变得越来越差。可扩展性减少做新需求或改逻辑时,需要新增/修改多少代码

  • 数据来源被固定、数据格式不兼容:原有的 AccountDO 是从本地获取的,而跨行转账的数据可能需要从一个第三方服务获取,而服务之间数据格式不太可能是兼容的,导致从数据校验、数据读写、到异常处理、金额计算等逻辑都要重写
  • 业务逻辑无法复用:数据格式不兼容的问题会导致核心业务逻辑无法复用。每个用例都是特殊逻辑的后果是最终会造成大量的 if-else 语句,而这种分支多的逻辑会让分析代码非常困难,容易错过边界情况,造成 bug
  • 逻辑和数据存储的相互依赖:当业务逻辑增加变得越来越复杂时,新加入的逻辑很有可能需要对数据库 schema 或消息格式做变更。而变更了数据格式后会导致原有的其他逻辑需要一起跟着动。在最极端的场景下,一个新功能的增加会导致所有原有功能的重构,成本巨大

设计模式六大原则给了我们不错的解决思路,依赖与抽象而不依赖与具体。调用每一个三方时都使用接口或者加防腐层,调用每一个底层组件时都使用抽象,同时按逻辑分离代码操作,使代码复用性增加 在这里插入图片描述

# 六边形架构与 DDD

六边形架构(Hexagonal Architecture)和领域驱动设计(Domain-Driven Design,简称 DDD)虽然都是软件架构和设计模式中的一部分,但它们关注的层面有所不同,并不是完全相同的概念

DDD 是一种软件开发方法论,侧重于解决复杂业务领域的软件设计问题。它的核心在于强调提炼出业务概念,并将其转化为软件中的模型,确保软件能够准确地反映现实世界的业务逻辑,而领域模型通常包含实体、值对象、聚合根等概念

六边形架构则是一种具体的架构模式,主要关注如何将应用的核心业务逻辑与外部基础设施和框架隔离开来,以便提高代码的可测试性和可移植性。它通过定义输入端口和输出端口,以及相应的适配器,来实现这一目标,他的重点也是这两个,核心领域(包含业务逻辑,不依赖于任何外部系统)和接口以及适配器(通过端口与外界通信,通过适配器与具体的技术栈或基础设施交互)

虽然六边形架构和 DDD 可以独立存在,但它们也可以很好地结合在一起,就行了说一下他们的好处是什么

原来的 MVC 架构非常容易理解,从 Controller 层接受前端数据,Service 层做处理,而数据层则对应数据库。一般来说 Controller 依赖 Service 层,而 Service 层依赖的东西过多,有可能是第三方组件与接口,比如消息队列、Dubbo 调用等;又有可能是数据层的东西,我们的惯性思维就是在 Service 层从数据库中取出数据的,此时数据库可能是 MySQL、PG、redis 等等

无论如何,这些三方与数据库都是有可能变动的,其他公司的烂代码可能会腐蚀我们自己写的代码,而数据库的表也可能会增减字段,此时牵一发而动全身。说了这么多,DDD 和六边形架构到底是如何解决这些问题的呢?

# 领域事件

在 DDD 中,领域事件的发布流程通常是这样的:

  1. 外部动作触发 :应用服务层接收到外部请求(如 API 调用、UI 操作)
  2. 调用领域方法 :应用服务调用领域模型中的方法
  3. 修改聚合状态 :领域方法修改聚合根的状态
  4. 持久化变更 :将修改后的聚合根保存到数据库
  5. 发布领域事件 :在事务完成后发布领域事件

领域事件的目的主要是为了跨上下文通信 ,通知其他限界上下文发生了某些业务事件。但是也可以做事件溯源 ,记录所有状态变更的历史,提供业务操作的完整记录,也就是说,领域事件是可以持久化的

@Transactional
public void assignLeadToSales(LeadId leadId, SalesRepId salesRepId) {
    // 1. 获取聚合根
    Lead lead = repository.findById(leadId);
    
    // 2. 调用领域方法
    lead.assignToSales(salesRepId);
    
    // 3. 保存聚合根
    repository.save(lead);
    
    // 4. 在同一事务内发布事件
    eventPublisher.publish(new LeadAssignedEvent(leadId, salesRepId));
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

# 领域

领域指某一专业或事物方面范围的涵盖,领域是有范围的,我们能够根据领域范围的不同来定义界限,定义边界

在研究和解决具体业务问题时,DDD 会按照⼀定的规则将业务领域进行细分,当领域细分到⼀定的程度后,DDD 会将问题范围限定在特定的边界内,在这个边界内建⽴领域模型,进而用代码实现该领域模型,解决相应的 业务问题。简言之,DDD 的领域就是这个边界内要解决的业务问题域

既然领域是用来限定业务边界和范围的,那么就会有大小之分,领域越大,业务范围就越大,反之则相反。一个领域可能有多个子域,子域就是领域拆开后的域,一般一个子域对应一个界限上下文

有以下概念:

1,核心域,指的是这个业务的核心功能,核心模块。比如,轿车主打的是动力充沛的话,那么发动机一定是核心域,比如说主打的是操控的话,那么变速箱、离合器一定是核心域

所以同一产品,在不同的商业角度中,核心重点,核心玩法是不一样的,比如对于电商来说,阿里和京东都是头部企业,那么阿里主要做的是联营模式,这种场景下,返利、租户等系统一定是核心,京东做的是自营模式,那么仓储、wms、供应链一定是核心域

2,通用域,对于汽车来说我们可以把内饰理解为通用域,因为比如说坐垫,比如说化妆镜,这些东西不一定是只能给某一辆单独型号的车来使用的,所以具有一些通用的属性。没有太多客制化的开发工作

对于系统来说的话,通用域则是你需要用到的通用系统,比如认证、权限等等,这类应用很容易买到,没有企业特点限制,不需要做太多的定制化。

3,支撑域,以汽车为例,我们可以把车轮和气囊作为支撑域来看待,因为对于车轮和气囊来说,它们的大小尺寸是严格和车辆保持一致的,也就是说不具备通用性,是极具有车厂风采的个性化产品

对于 DDD 来说,支撑域则具有企业特性,但不具有通用性,例如数据代码类的数据字典等系统

# DDD 模块设计

DDD 最直观的体现就是模块名跟 MVC 不一样,领域驱动设计的四层结构为:

  • 表现层(Presentation)
  • 应用层(Application)
  • 领域层(Domain)
  • 基础设施层(Infrastructure)

设计人员可以根据实际问题填充不同的模块到这四层中,填充的原则如下:

# Presentation(Web、Interfaces)模块

Web 模块包含 Controller 等相关代码,同时在该模块中可以为其他的项目提供统一的出入口,比如提供给外部的 dubbo、http、rpc、mq 的 api,就在这一层

我们单独会抽取出来 Interface 接口层,作为所有对外的门户,将网络协议和业务逻辑解耦,该层可以做以下这些事情:

  • 网络协议的转化:通常这个已经由各种框架给封装掉了,我们需要构建的类要么是被注解的 bean,要么是继承了某个接口的 bean
  • 统一鉴权:比如在一些需要 AppKey+Secret 的场景,需要针对某个租户做鉴权的,包括一些加密串的校验
  • 限流配置:对接口做限流避免大流量打到下游服务
  • 异常处理:通常在接口层要避免将异常直接暴露给调用端,所以需要在接口层做统一的异常捕获,转化为调用端可以理解的数据格式

# Application 模块

主要包含 Application Service,该模块依赖 Domain 模块与基础设施层。Application 层主要职责为组装 domain 层各个组件,完成具体的业务服务。Application 层可以理解为粘合各个组件的胶水,使得零散的组件组合在一起提供完整的业务服务

Application Service 是业务流程的封装,不处理业务逻辑。并且,ApplicationService 应该永远返回 DTO 而不是 Entity。该层的出参应该是标准的 DTO,并且不应该做任何逻辑处理,而入参则是 CQE 对象,一般入参的校验应该在这一层,这样就保证了非业务代码不会混杂在业务代码之间

注意此处的校验与上层 web 层的校验不一样,web 层主要用来校验不需要访问 dao 层即可校验的数据,比如权限,入参是否为 null 等,而 app 层的校验则是需要访问数据库来获取数据的情况。但是还有一个问题,就是在 app 层校验时,校验结果如何返回给用户,此处推荐增加一个 OperateResult 来返回操作结果,举个例子:

OperateResult:

/**
 * description 操作结果,主要用于单纯操作,记录操作日志
 */
@Data
public class OperateResult implements Serializable {

    private boolean ret;

    private String msg;

    public static OperateResult success() {
        OperateResult operateResult = new OperateResult();
        operateResult.setRet(true);
        return operateResult;
    }

    public static OperateResult error(String msg) {
        OperateResult operateResult = new OperateResult();
        operateResult.setRet(false);
        operateResult.setMsg(msg);
        return operateResult;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

web 层代码:

    @PostMapping("/transfer.json")
    public JsonResult<String> transfer(String clueId) {
        OperateResult opt = salesClueService.transfer(clueId);
        if (opt.isRet()) {
            return JsonResult.success("成功");
        }

        return JsonResult.error(opt.getMsg());
    }
1
2
3
4
5
6
7
8
9

app 层/service 层:

    @Override
    public OperateResult transfer(String clueId) {
        SalesClue salesClue = salesClueRouteService.selectById(clueId);
        if (salesClue == null) {
            return OperateResult.error("未查询到线索");
        }
        return OperateResult.success();
    }
1
2
3
4
5
6
7
8

# Application 层的几个核心类

  • ApplicationService 应用服务:最核心的类,负责业务流程的编排,但本身不负责任何业务逻辑
  • DTO Assembler:负责将内部领域模型转化为可对外的 DTO
  • Command、Query、Event 对象:作为 ApplicationService 的入参
  • 返回的 DTO:作为 ApplicationService 的出参

在这里插入图片描述 判断是否业务流程的几个点:

  • 不要有任何计算,基于对象的计算逻辑应该封装到实体里
  • for 循环一般为业务判断
  • 允许有 if 判断中断条件,一般如果条件不满足抛异常或者返回

以下是一些例子:

  • Command(命令):前端发送了一个创建用户的命令,后端将根据这些数据创建一个新的用户记录
  • Query(查询):前端发送了一个获取用户信息的查询,后端将根据用户ID返回相应的用户数据
  • Event(事件):后端在创建用户后生成了一个用户创建的事件,该事件可以被其他系统或组件订阅和处理。类似 mq 的 message

Application 层处理业务流程但是不处理业务逻辑,业务逻辑被封装在 domain 层,也就是我们系统最核心的模块,领域。Application 层的职责就是将各个领域拼接在一起实现业务功能

# Domain 模块

业务核心模块,包含有状态的 Entity、领域服务 Domain Service、Types、以及各种外部依赖的接口类,注意,只是接口类

有状态的 Entity 指对应原来 MVC 中的 DO,只不过加入了对 DO 中属性的一些操作(行为方法),被称为聚合根,里面封装了多个 PO 中的属性,里面的属性被称为 Object Value;Types 包的作用就是前文说的作用;Domain Service 则是核心的复合操作。以下是一个分组聚合根例子:

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Group extends AggregateRoot {
    private String name;//名称
    private String appId;//所在的app
    private List<String> managers;//管理员
    private List<String> members;//普通成员
    private boolean archived;//是否归档
    private String customId;//自定义编号
    private boolean active;//是否启用
    private String departmentId;//由哪个部门同步而来
    
     /**
     * Group的行为方法:开除一个员工
     */
    public void dismiss(String memberName) {
        // 去判断这个group是否已启用
        if (!active) {
            throw new GroupException("未启用");
        }
        if (Strings.isNull(memberName)) {
            throw new GroupException("输入有误");
        }
        members.remove(memberName);
        managers.remove(memberName);
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

外部依赖的接口类可以是查询接口,你可以在 domain 层中定义查询接口,在基础架构层实现

注意聚合内部的逻辑一定只涉及这个聚合,DDD 通过这种方式实现了高内聚

项目中有可能有多个聚合,聚合之间的相互交互就比较重要了。有些情况下,两个聚合的交互没有形成一个业务流程,但是在项目中又特别重要,这些逻辑我们可以放在 Domain Service 中

领域服务除去基础架构层的接口不依赖任何其他功能

额外提一点:在我见过的一些代码里,接口的返回值比较多样化,有些直接返回 DTO 甚至 DO,另一些返回 Result。接口层的核心价值是对外,所以如果只是返回 DTO 或 DO 会不可避免的面临异常和错误栈泄漏到使用方的情况,包括错误栈被序列化反序列化的消耗。所以,这里提出一个规范:

规范1:Interface 层的 HTTP 和 RPC 接口,返回值为 Result,捕捉所有异常 规范2:Application 层的所有接口返回值为 DTO,不负责处理异常

这两者都是最佳实践

# Infrastructure 模块

基础设施层,该层主要为 Domain 提供数据,包含数据库 DAO 的实现,包含外部依赖的接口类包括 Http 调用、dubbo 调用、中间件 redis、mq 等,我们把这些打包成防腐接口(一定要防腐接口),提供给 domain 层的业务使用。此外基础的配置,也需要放在这里

对于三方或者数据库的具体实现可以使用转换器模式。这里的 Repository 并非数据访问层,而是封装了数据访问层的仓储层,仓储处于数据访问层和业务逻辑层之间,你可以使用仓储层构建一些聚合或者实现保存聚合的方法

// 代码在Infrastructure层
@Repository // Spring的注解
public class OrderRepositoryImpl implements OrderRepository {
    private final OrderDAO dao; // 具体的DAO接口
    private final OrderDataConverter converter; // 转化器

    public OrderRepositoryImpl(OrderDAO dao) {
        this.dao = dao;
        this.converter = OrderDataConverter.INSTANCE;
    }
}
1
2
3
4
5
6
7
8
9
10
11

在这里插入图片描述 可以看到,该模块主要负责提供数据的来源以及储存数据,但是一些配置信息也需要放在这里

综上所述,考虑到最终的依赖关系,我们在写代码的时候可能先写 Domain 层的业务逻辑,然后再写 Application 层的组件编排,最后才写每个外部依赖的具体实现。这种架构思路和代码组织结构就叫做 Domain-Driven Design(领域驱动设计,或 DDD)

# 架构示例

综上,DDD 的包架构应该是下面这样 在这里插入图片描述 但是在 DDD 开发的过程中会有很多障碍,比如 mybtais 几乎不能满足 DDD 架构的需求,如果强制在 domain 层定义接口,代码会变的十分臃肿。同时市面上大多数 ORM 框架也有这个缺点

其次,type 的想法虽好,但是就算有了 mapper 的帮助也无法很好的转换代码,过程往往变的更加复杂

但是其思想是值得我们借鉴学习的,领域驱动设计的理论甚至可以用在非模块开发上

# 模型以及模型之间的转换

这里额外强调一下,对于一些简单的业务,使用 DDD 反而不好,因为内部又大量的数据转化逻辑,但是简单业务中,实体往往不需要怎么转化,有可能从 PO 到 VO 都是使用一模一样的字段的,使用 DDD 反而不好了。比如有一个特别简单的业务,他基本上未来十年内,使用的 DB 都是 PG,此时 DDD 在基础架构层提供的 CRUD 防腐接口转换就没有意义了,如果是之前 MVC 架构,写个 mapper 只需要:

  • CrudMapper

而使用了 DDD,在基础架构层的 mapper 则是:

  • CrudRouteService
  • CrudRouteServiceImpl
  • CrudMapper

此时多出来的两层就完全没有必要了

# VO、DTO、BO、PO

模型对象代码规范其实只有3种模型,Entity、Data Object (DO) 和 Data Transfer Object (DTO),不过思路都是类似的,先来看看包括了大众理解的模型 在这里插入图片描述

  • VO(View Object):视图对象,用于展示层,只要是这个东西是让人看到的就叫VO
  • DTO(Data Transfer Object):数据传输对象,泛指用于展示层与服务层之间的数据传输对象,即前后端之间的传输对象;在微服务盛行的现在,服务和服务之间调用的传输对象也可以叫 DTO
  • BO(Business Object):业务对象,就是从现实世界中抽象出来的有形或无形的业务实体。BO 就是 PO 的组合,比如 PO1 是交易记录,PO2 是登录记录,PO3 是商品浏览记录,PO4 是添加购物车记录,PO5 是搜索记录,BO 是个人网站行为对象。BO 是一个业务对象,一类业务就会对应一个 BO,数量上没有限制,而且 BO 会有很多业务操作,也就是说除了 get,set 方法以外,BO 会有很多针对自身数据进行计算的方法
  • PO(Persistent Object):持久化对象,它跟持久层(通常是关系型数据库)的数据结构形成一一对应的映射关系,如果持久层是关系型数据库,那么,数据表中的每个字段(或若干个)就对应 PO 的一个(或若干个)属性

# DDD 中的3种模型

  • Data Object (DO、数据对象):在DDD的规范里,DO 应该仅仅作为数据库物理表格的映射,不能参与到业务逻辑中。DO 的生命周期应该被限制在基础组件层,不能向 domain 层暴露
  • Entity(实体对象):实体对象是我们正常业务应该用的业务模型,它的字段和方法应该和业务语言保持一致,和持久化方式无关。也就是说,Entity 和 DO 很可能有着完全不一样的字段命名和字段类型,甚至嵌套关系。Entity 的生命周期应该仅存在于内存中,不需要可序列化和可持久化。等同于上图中的 BO
  • DTO(传输对象):主要作为 Application 层的入参和出参,在表现层,可以被看做 param 入参以及 VO 出参,应该避免让业务对象变成一个万能大对象

在实际开发中 DO、Entity 和 DTO 不一定是1:1:1的关系,一个 Entity 应该可以对应多个 DO,应该 DTO 又可以对应多个 Entity 在这里插入图片描述

# 参考

阿里技术专家详解DDD系列 第一讲- Domain Primitive (opens new window) 阿里技术专家详解DDD系列 第二讲 - 应用架构 (opens new window) 阿里技术专家详解DDD系列 第三讲 - Repository模式 (opens new window) 阿里技术专家详解DDD系列 第四讲:领域层设计规范 (opens new window) 阿里技术专家详解DDD系列 第五讲:聊聊如何避免写流水账代码 (opens new window)

#DDD
最后更新: 2/25/2026, 8:20:00 AM
资深研发进阶
Java 常用的规则引擎

← 资深研发进阶 Java 常用的规则引擎→

最近更新
01
vibe coding 最佳实践
02-24
02
立直麻将牌效益理论
02-23
03
伪静态是什么
02-08
更多文章>
Theme by Vdoing
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式