紫矜社 Gladiolus 项目架构全景解析
项目概览与架构哲学
这是一个 多租户 SaaS 教育平台,采用 Java 17 + Spring Boot 3.5.13 + Spring Cloud 2025.0.2 技术栈。项目包含 16 个 Maven 模块(10 个 common-* 通用库 + 5 个业务微服务 + 2 个 Feign 契约包),支持四种业务模式:C1(线上自营 ToC)、S1/S2(渠道进校 ToB2S)、B1(课后托管 ToB2C)、C2(校外借阅 O2O)。
架构的核心理念是 实用主义领域驱动设计(Pragmatic DDD),所有取舍都以可维护性、开发效率和团队认知负荷为度量标准。
实际上,对内的内容应该尽量充血设计,最大程度的允许 DDD 的最佳实践,少依赖,以DDD的事实依据来设计,而对外的内容也应该以性能,安全性,可用性为准的基础上,努力去进行 DDD 的最佳实践,但是,允许项目的情况和业务的情况进行失血设计和反 DDD 范式的设计,这是实际情况的必要取舍,也就是何谓实用主义领域驱动设计。
菱形对称架构(Diamond Symmetric Architecture)
这是项目最核心的架构创新,基于张逸《解构领域驱动设计》中的理论。架构呈菱形:
1 | ┌──────────────┐ |
一句话概括:
把
domain放在正中央作为唯一业务核心;上面用ohs作为 对外提供能力 的北向网关,下面用acl作为 对外部依赖做隔离 的南向网关;app则夹在ohs和domain之间,承担 用例编排、事务和权限等 的应用协调职责。
它不是传统三层 MVC 那种 Controller → Service → DAO 的纵向流水线,而是一个以领域为中心的对称结构,越北越朝外,越南越对内:
- 北边:
ohs—— 负责“别人怎么调用我” - 中上:
app—— 负责“一个业务用例怎么被编排起来” - 中心:
domain—— 负责“业务规则、业务不变量、领域模型” - 南边:
acl—— 负责“我怎么访问数据库、缓存、MQ、第三方服务、远程系统”
但是实际上,从上到下从北到南的说法并不关键,实际上:
domain是价值中心,而不是普通中间层domain是菱形中心,而且domain不依赖任何外层,所以说,所有外层都是围绕domain服务的,且依赖方向必须单向向内ohs和acl是一对对称网关,它们的对称性非常重要:- OHS(Open Host Service):向上暴露系统能力,是北向网关
- ACL(Anti-Corruption Layer):向下隔离外部依赖,是南向网关
菱形对称架构的本质就是,把”业务是什么”和”技术怎么做”彻底分开,然后在两者之间建立标准化的翻译层
关键架构决策(ADR)与架构基本事实
domain是唯一业务核心,不能依赖外层:domain可以有如下内容,聚合根(aggregate)、领域模型 / 实体、值对象(value object)、领域服务接口、领域事件、业务不变量、业务规则、状态转换逻辑等。domain不该依赖Controller、ORM内容、第三方的具体实现等任何外部系统接入细节领域层应该表达“业务是什么”,而不是“技术怎么做”。
这是务实 DDD”,不是教科书洁癖式 DDD:承认为开发效率做了一些理论妥协
领域模型和持久化模型融合
Domain 不单独建
entity包,domain层里的模型/聚合可以直接用@Entity、@Table,也就是说,领域对象本身兼任 JPA 持久化实体也就是说,它既是领域聚合根,也是 ORM 实体。
被驱动端口不放在 Domain,而放在 ACL
经典六边形架构经常把 Repository/Gateway 之类端口放到 Domain 内部,但是这里,被驱动端口统一归属 ACL 层的
acl.ports。这意味着领域层完全不知道外部世界的存在,所有 I/O 契约都在 ACL 中定义。
渐进式 DDD:项目不追求一步到位的充血模型,而是从贫血模型开始(通过事件风暴识别),在持续重构中逐步向充血模型演进。
app不是业务层,它是应用协调层app层绝对不能被理解成传统的 Service 层,实际上传统的 Service 层,它不含业务规则,负责编排领域对象和领域服务,负责事务边界、权限校验、发布事件,写操作编排domain.service,读操作直接通过 repository / 端口完成,实现读写分离比如“创建菜单”这个用例,
app service可能会做:- 校验当前操作者是否有菜单维护权限
- 开启事务
- 调用某个领域服务创建菜单聚合
- 保存聚合
- 记录应用日志或发布应用事件
- 返回结果
但它不应该自己写出“如果 type=1 就必须有 routeMeta、如果 name 变更就要更新所有子孙 namePath”这种规则。 这些规则应该在
domain内部。也就是文档说的那句很关键的话:
如果
app层开始变得“智能”,意味着领域模型正在“失血”。
ohs是唯一对客入口,不允许绕过它直接暴露内部结构:ohs在这套架构里是北向网关,它负责一切 系统如何被外部调用 的事情。- 它的职责是隔离外部协议与内部领域模型。所以,HTTP
请求体、Feign DTO、返回 View,都应该放在
ohs.pl,那么有了这些,Controller 就不应该直接返回领域对象或者领域对象对内的DTO - 查询和命令的 DTO 是分开的,实际上也是一种 CQRS
- 它的职责是隔离外部协议与内部领域模型。所以,HTTP
请求体、Feign DTO、返回 View,都应该放在
acl是唯一南向出口,所有外部依赖都要经它隔离:领域之外的基础设施依赖最终都应该被 ACL 包起来,不要把技术细节渗透到 Domain。务实 DDD 把端口放在
acl.ports:在经典六边形架构里,经常会说领域层定义 Repository 接口,基础设施层去实现它。所有被驱动端口(如 Repository、Gateway)必须定义在acl.ports包下,而不是domain.port。为什么要这么做
- 符合菱形对称架构:在这套架构里,南向端口就是 ACL 网关的一部分。也就是对外的抽象,也属于南向网关,不属于领域核心。
- 避免领域核心沾上技术细节,而且北边
ohs进,南边acl出,这对团队协作非常直观。 - 端口和适配器高内聚:接口和实现都在 ACL 一侧,更容易管理。
职责被切得非常细:最关键的细分是
- Domain 层内部
aggregate:聚合根model:普通领域模型valueobject:值对象service:领域服务接口event:领域事件
- OHS 层内部
controllers:外部 HTTP 入口apis:内部 / 微服务 APIpl.command/query/view:对外契约 DTOconverter:领域对象转 View
- ACL 层内部
ports:对外依赖的抽象契约adapters:技术实现handlers:领域事件处理器services:领域服务接口的实现converter:领域对象和外部格式之间转换
- Domain 层内部
这套架构天然绑定 CQRS 倾向:实际上在其中,写操作通过
domain.service编排复杂业务,读操作直接调用 repository / 端口完成查询读取,遵循 CQRS 分离原则- 为什么?因为写模型偏重领域对象和不变量,读模型偏重视图对象,两种在设计上通常是不同的
依赖方向
单向向内,不可逆:
1 | Controller → AppService → DomainService(接口) → Domain Aggregate |
严禁:Domain 层引用 Spring 注解(JPA/Hibernate 除外)、Domain 层反向依赖 App 层、跨层跳跃调用。
以实际项目中的一个模块为例子剖析
domain 层
在典型的严格六边形架构中,领域层是完全不依赖任何框架的纯 Java 对象 POJO
在 domain 包下,会频繁和这些个内容打交道:
聚合根
聚合根 (Aggregate Root):业务核心对象,一致性边界的持有者
以
SysMenu为例,它不仅是一个数据载体,更是业务规则的执行者。这是典型的充血模型。可以把他考虑成数据修改的唯一入口。它承载核心业务规则(不变式),并负责发布领域事件。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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289package com.zijinshe.system.menu.domain.aggregate;
import cn.hutool.core.lang.Assert;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.zijinshe.common.domain.aggregate.BaseAggregateRoot;
import com.zijinshe.common.jpa.entity.BaseAuditInfo;
import com.zijinshe.system.menu.acl.ports.pl.SysMenuBasicInfoDTO;
import com.zijinshe.system.menu.domain.valueobject.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.apache.commons.collections4.CollectionUtils;
import org.hibernate.annotations.DynamicInsert;
import org.hibernate.annotations.DynamicUpdate;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import jakarta.persistence.*;
import java.io.*;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
public class SysMenu extends BaseAggregateRoot<SysMenu> implements Serializable {
private static final long serialVersionUID = 1L;
private Long id;
/**
* 父ID
*/
private Long parentId;
/**
* 层级
*/
private Integer level;
/**
* id路径,所有parentId(含根),用"/"连接
* 用于快速查找父级子级
*/
private String idPath;
/**
* 名称路径,所有parentName(不含根)+自己,用"/"连接
* 用于模糊检索
*/
private String namePath;
/**
* 同级排序
*/
private Integer sort;
/**
* 名称
*/
private String name;
/**
* 图标
*/
private String icon;
/**
* 类型:0-虚拟节点,1-内部系统,2-三方链接
* 不影响菜单实际行为,只作为维护分类标识
*/
private Integer type;
/**
* 路由元数据
*/
private MenuRouteMeta routeMeta;
/**
* 跳转链接
*/
private MenuRedirectMeta redirectMeta;
/**
* 可见权限列表-hasAny
*/
private List<String> visiblePermissions;
/**
* 页面按钮授权映射
*/
private List<PageButtonPermissionGrant> buttonPermissionGrantList;
/**
* 逻辑删除:0-否,1-是
*/
private Integer deleted;
/**
* 乐观锁
*/
private Long revision;
/**
* 审计相关信息
*/
private BaseAuditInfo audit = new BaseAuditInfo();
public static SysMenu newInstance(SysMenuBasicInfoDTO parentInfo, MenuName menuName) {
SysMenu menu = new SysMenu();
menu.parentId = parentInfo.getId();
menu.level = parentInfo.getLevel() + 1;
// id路径只包含父ID
menu.idPath = (parentInfo.getIdPath() == null ? "" : (parentInfo.getIdPath() + "/")) + parentInfo.getId();
// name路径包含父名称+自身名称
menu.namePath = (parentInfo.getNamePath() == null ? "" : (parentInfo.getNamePath() + "/")) + menuName.getName();
menu.name = menuName.getName();
menu.icon = menuName.getIcon();
menu.type = 0;
int childrenMaxSort = parentInfo.getChildrenMaxSort() == null ? 0 : parentInfo.getChildrenMaxSort();
menu.sort = childrenMaxSort + 1;
menu.deleted = 0;
return menu;
}
/**
* 创建内部菜单-跳转系统路由
*
* @param visiblePermissions
* @param routeMeta
* @param buttonPermissionGrantList
* @return
*/
public SysMenu createInsideMenu(List<String> visiblePermissions, MenuRouteMeta routeMeta, List<PageButtonPermissionGrant> buttonPermissionGrantList) {
Assert.isTrue(id != null, "id 不能为空");
Assert.notNull(routeMeta, "routeMeta 不能为空");
Assert.notNull(visiblePermissions, "visiblePermissions 不能为空");
this.type = 1;
this.routeMeta = routeMeta;
this.visiblePermissions = visiblePermissions;
this.buttonPermissionGrantList = CollectionUtils.isEmpty(buttonPermissionGrantList) ? Collections.emptyList() : buttonPermissionGrantList;
return this;
}
/**
* 创建外部菜单-跳转外部链接
*
* @param visiblePermissions
* @param redirectMeta
* @return
*/
public SysMenu createOutsideMenu(List<String> visiblePermissions, MenuRedirectMeta redirectMeta) {
Assert.isTrue(id != null, "id 不能为空");
Assert.notNull(redirectMeta, "redirectMeta 不能为空");
this.type = 2;
this.redirectMeta = redirectMeta;
this.visiblePermissions = visiblePermissions;
return this;
}
public SysMenu updateBasicInfo(MenuName menuName, List<SysMenu> allChildrenMenus) {
Assert.isTrue(id != null, "id 不能为空");
Assert.notNull(menuName, "menuName 不能为空");
this.icon = menuName.getIcon();
// 如果菜单名称有更新,则需要更新全部子节点
if (!Objects.equals(this.name, menuName.getName())) {
this.name = menuName.getName();
this.namePath = this.fixNamePath(this.idPath, this.namePath, menuName.getName());
if (CollectionUtils.isNotEmpty(allChildrenMenus)) {
allChildrenMenus.forEach(menu -> {
menu.namePath = this.fixNamePath(this.idPath, menu.namePath, menuName.getName());
});
}
}
return this;
}
public SysMenu updateInsideMenuInfo(MenuName menuName, List<SysMenu> allChildrenMenus, List<String> visiblePermissions, MenuRouteMeta routeMeta, List<PageButtonPermissionGrant> buttonPermissionGrantList) {
Assert.isTrue(id != null, "id 不能为空");
Assert.notNull(menuName, "menuName 不能为空");
Assert.notNull(visiblePermissions, "visiblePermissions 不能为空");
Assert.notNull(routeMeta, "routeMeta 不能为空");
this.updateBasicInfo(menuName, allChildrenMenus);
this.visiblePermissions = visiblePermissions;
this.routeMeta = routeMeta;
this.buttonPermissionGrantList = CollectionUtils.isEmpty(buttonPermissionGrantList) ? Collections.emptyList() : buttonPermissionGrantList;
return this;
}
public SysMenu updateOutsideMenuInfo(MenuName menuName, List<SysMenu> allChildrenMenus, List<String> visiblePermissions, MenuRedirectMeta redirectMeta) {
Assert.isTrue(id != null, "id 不能为空");
Assert.notNull(menuName, "menuName 不能为空");
Assert.notNull(visiblePermissions, "visiblePermissions 不能为空");
Assert.notNull(redirectMeta, "redirectMeta 不能为空");
this.updateBasicInfo(menuName, allChildrenMenus);
this.visiblePermissions = visiblePermissions;
this.redirectMeta = redirectMeta;
return this;
}
private String fixNamePath(String updateIdPath, String oldNamePath, String newName) {
Assert.notNull(updateIdPath, "idPath 不能为空");
Assert.notNull(oldNamePath, "oldNamePath 不能为空");
Assert.notNull(newName, "newName 不能为空");
Integer updateNodeIndex = updateIdPath.split("/").length - 1;
String[] nodeNameArr = oldNamePath.split("/");
Assert.isTrue(updateNodeIndex < nodeNameArr.length, "updateIdPath 节点数不能大于 oldNamePath");
nodeNameArr[updateNodeIndex] = newName;
return String.join("/", nodeNameArr);
}
public List<SysMenu> moveToSameParent(List<SysMenu> targetParentAllChildrenMenus, SysMenuBasicInfoDTO besideMenu, boolean isBeforeBeside) {
Assert.isTrue(id != null, "id 不能为空");
Assert.isTrue(CollectionUtils.isNotEmpty(targetParentAllChildrenMenus), "parentAllMenus 不能为空");
Assert.isTrue(targetParentAllChildrenMenus.size() >= 2, "parentAllMenus 至少包含两个元素");
List<Long> parentIds = targetParentAllChildrenMenus.stream()
.map(menu -> menu.getParentId())
.filter(Objects::nonNull)
.distinct()
.toList();
Assert.isTrue(parentIds.size() == 1, "parentAllMenus 父ID 不一致");
// 如果是移动到某个菜单之前,则这个菜单不能为空
// 否则直接移动到末尾即可
if (isBeforeBeside) {
Assert.isTrue(besideMenu != null, "besideMenu 不能为空");
Assert.isTrue(!this.id.equals(besideMenu.getId()), "无法移动到自己旁边");
Assert.isTrue(this.parentId.equals(besideMenu.getParentId()), "不能移动到不同父节点下");
}
// 按父节点下所有菜单查询列表的顺序,初始化排序列表
// 将当前菜单从排序列表中移除后,按besideMenu的排序位置插入
List<MenuSort> parentAllMenuSorts = targetParentAllChildrenMenus.stream()
.filter(menu -> menu.getId() != null)
.map(menu -> MenuSort.of(menu.getId(), 0))
.distinct()
.toList();
MenuSort thisMenuSort = MenuSort.of(this.getId(), this.getSort());
parentAllMenuSorts.remove(thisMenuSort);
if (isBeforeBeside) {
MenuSort besideMenuSort = MenuSort.of(besideMenu.getId(), besideMenu.getSort());
int besideMenuIndex = parentAllMenuSorts.indexOf(besideMenuSort);
parentAllMenuSorts.add(besideMenuIndex + (isBeforeBeside ? 0 : 1), thisMenuSort);
} else {
parentAllMenuSorts.add(thisMenuSort);
}
// 按修改后的排序列表,更新所有菜单的排序
List<MenuSort> fixedParentAllMenuSorts = parentAllMenuSorts.stream()
.map(menu -> MenuSort.of(menu.getId(), parentAllMenuSorts.indexOf(menu) + 1))
.toList();
Map<Long, Integer> sortMap = fixedParentAllMenuSorts.stream()
.collect(Collectors.toMap(MenuSort::getId, MenuSort::getSort));
targetParentAllChildrenMenus.forEach(menu -> {
menu.sort = sortMap.get(menu.id);
});
return targetParentAllChildrenMenus;
}
public List<SysMenu> moveToOtherParent(List<SysMenu> allChildrenMenus, SysMenuBasicInfoDTO targetParentInfo) {
return allChildrenMenus;
}
}- 拒绝无脑 Setter: 你会发现
SysMenu中充满了具有强业务语义的方法,比如createInsideMenu()和moveToSameParent()。更新数据时,不是在 Service 层去menu.setName().setIcon(),而是调用聚合根内部的方法。 - 自我状态保护: 在修改方法(如
updateBasicInfo)中,有大量的Assert.notNull()和状态校验。聚合根必须保证自己在任何时刻都是合法、一致的。比如名称变更时,它会自行驱动namePath的级联修复:this.fixNamePath(...)。 - JSON 字段拥抱敏捷: 对于
MenuRouteMeta(路由元数据)这类结构多变且不需要数据库强关联查询的字段,项目直接使用了@JdbcTypeCode(SqlTypes.JSON)映射为 JSON 格式存储。这避免了建立大量零碎的关联表
- 拒绝无脑 Setter: 你会发现
领域模型
领域模型(Model):通常是 有业务含义,但不是聚合根 的对象
它也是
@Entity,通常它不是聚合根。它没有独立的生命周期,例如如下的 Model 必须依附于SysDepartment或SysUser存在。实际上,可以在一些时候把它也堪称聚合根,但是通常情况下,它表达的是一个业务关系
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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134package com.zijinshe.system.tenant.domain.model;
import cn.hutool.core.lang.Assert;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.zijinshe.common.jpa.entity.BaseAuditCreator;
import com.zijinshe.system.tenant.domain.valueobject.TenantEnums;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.hibernate.annotations.DynamicInsert;
import org.hibernate.annotations.DynamicUpdate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import jakarta.persistence.*;
import java.io.*;
public class SysUserDepartment implements Serializable {
private static final long serialVersionUID = 1L;
private Long id;
/**
* 租户ID
*/
private Long tenantId;
/**
* 用户ID
*/
private Long userId;
/**
* 部门ID
*/
private Long departmentId;
/**
* 部门内排序
*/
private Long sort;
/**
* 是否部门主管
*/
private Integer areManager;
/**
* 同步来源,DingTalk, WeWork
*/
private String syncSource;
/**
* 外部系统用户ID(如钉钉userid)
*/
private String syncExternalUserid;
/**
* 外部系统部门ID(如钉钉deptId)
*/
private String syncExternalDeptid;
/**
* 同步日志ID
*/
private Long syncLogId;
/**
* 是否删除
*/
private Integer deleted;
/**
* 审计相关信息
*/
private BaseAuditCreator audit = new BaseAuditCreator();
public static SysUserDepartment fromDingTalk(Long syncLogId, Long tenantId, SysTenantSyncResultDO.UserDepartment syncUserDepartment) {
Assert.isTrue(syncUserDepartment != null, "用户部门不能为空");
Assert.isTrue(tenantId != null, "租户ID不能为空");
Assert.isTrue(syncLogId != null, "同步日志ID不能为空");
SysUserDepartment sysUserDepartment = new SysUserDepartment();
sysUserDepartment.tenantId = tenantId;
sysUserDepartment.sort = syncUserDepartment.getSort();
sysUserDepartment.areManager = syncUserDepartment.getAreManager();
sysUserDepartment.syncSource = TenantEnums.OAProvider.DingTalk.name();
sysUserDepartment.syncExternalUserid = syncUserDepartment.getDingTalkUserid();
sysUserDepartment.syncExternalDeptid = syncUserDepartment.getDingTalkDeptId();
sysUserDepartment.syncLogId = syncLogId;
sysUserDepartment.deleted = 0;
return sysUserDepartment;
}
public SysUserDepartment updateFromDingTalk(Long syncLogId, SysTenantSyncResultDO.UserDepartment syncUserDepartment) {
Assert.isTrue(syncUserDepartment != null, "用户部门不能为空");
Assert.isTrue(syncLogId != null, "同步日志ID不能为空");
this.sort = syncUserDepartment.getSort();
this.areManager = syncUserDepartment.getAreManager();
this.syncSource = TenantEnums.OAProvider.DingTalk.name();
this.syncExternalUserid = syncUserDepartment.getDingTalkUserid();
this.syncExternalDeptid = syncUserDepartment.getDingTalkDeptId();
this.syncLogId = syncLogId;
this.deleted = 0;
return this;
}
public SysUserDepartment deleteFromDingTalk(Long syncLogId) {
Assert.isTrue(syncLogId != null, "同步日志ID不能为空");
this.syncLogId = syncLogId;
this.deleted = 1;
return this;
}
public String getSyncExternalId() {
return syncExternalUserid + "@" + syncExternalDeptid;
}
}不设立独立 entity 包之下的设计下,这里的
model就是承接这个普通实体的地方。如果它被提升为聚合,会有独立的仓库;否则,它通常由聚合根的仓库级联管理,或作为只读模型。而且它通常不能发布领域事件,修改完全由所属聚合根的业务方法来驱动
值对象
值对象 (Value Object):表达一个有业务含义、不可随意裸字符串化的概念。
值对象用来描述事物的特征,它们是不可变的,而且时刻是无副作用的(自验证),最大的特征是它没有 id。项目规范要求严格把控值对象的封装。它没有独立身份,替换即新建。
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
30
31
32
33
34
35
36
37
38package com.zijinshe.system.menu.domain.valueobject;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springframework.util.Assert;
public class MenuName {
private String name;
private String icon;
public static MenuName of(String name, String icon) {
MenuName orgName = new MenuName();
orgName.name = orgName.cleanName(name);
orgName.icon = icon;
return orgName;
}
private String cleanName(String name) {
Assert.isTrue(StringUtils.isNotBlank(name), "name is blank");
// 去除空格和换行符
String cleanedName = StringUtils.deleteWhitespace(name);
// 统一括号为半角
cleanedName = cleanedName.replace("(", "(").replace(")", ")");
cleanedName = cleanedName.replace("<", "(").replace(">", ")");
cleanedName = cleanedName.replace("《", "(").replace("》", ")");
cleanedName = cleanedName.replace("_", "-").replace("—", "-");
// 过滤非法字符(仅保留汉字、字母、数字、括号、短横线)
cleanedName = cleanedName.replaceAll("[^\\u4e00-\\u9fa5a-zA-Z0-9()\\-]", "");
Assert.isTrue(StringUtils.isNotBlank(cleanedName), "cleanedName is blank");
return cleanedName;
}
}- 以
MenuName为例,它不仅仅包含name和icon两个字符串,它还在静态工厂方法of()和私有方法cleanName()中封装了清洗逻辑。
- 以
领域服务
领域服务 (Domain Service)
当一个业务逻辑跨越多个聚合,或者涉及极其复杂的算法时,就交给领域服务。
处理不适合放在单个聚合根里的业务规则,比如下面中的
amendTreePathByExternal可能涉及跨多级部门的树形路径重算,而它的实现类必须放在acl.adapters.services放在南向网关的原因是,因为实现可能依赖具体的
Repository或外部 RPC。将实现放在 ACL,符合南向网关防腐思想,Domain 只依赖抽象接口(接口算 Domain 的知识),具体实现(Repository 组装)被隔离在 ACL。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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102/**
* 菜单领域服务接口
*/
public interface ISysMenuService {
/**
* 创建菜单
*
* @param parentId 父菜单ID
* @param menuName 菜单名称
* @param type 菜单类型
* @param visiblePermissions 菜单可见权限编码列表(hasAny 语义,可空)
* @param routeMeta 路由元数据值对象(可空)
* @param buttonPermissionGrantList 按钮授权列表(可空)
* @param redirectMeta 跳转元数据值对象(可空)
* @return 已持久化的菜单聚合根
*/
SysMenu createMenu(Long parentId, MenuName menuName, Integer type, List<String> visiblePermissions,
MenuRouteMeta routeMeta, List<PageButtonPermissionGrant> buttonPermissionGrantList,
MenuRedirectMeta redirectMeta);
/**
* 更新菜单基础信息(部分更新)
* <p>
* 业务规则:
* - 任一参数为 null 表示该字段不更新;
* - name 变更时,必须级联刷新所有子孙节点的 namePath(保持物化路径一致性);
* - 不变更 idPath/level/parentId(移动操作请使用 moveMenu)。
*
* @param id 菜单ID
* @param menuName 菜单名称
* @param type 菜单类型
* @param visiblePermissions 菜单可见权限编码列表(hasAny 语义,可空)
* @param routeMeta 路由元数据值对象(可空)
* @param buttonPermissionGrantList 按钮授权列表(可空)
* @param redirectMeta 跳转元数据值对象(可空)
* @return 已持久化的菜单聚合根
*/
int updateMenuInfo(Long id, MenuName menuName, Integer type, List<String> visiblePermissions,
MenuRouteMeta routeMeta, List<PageButtonPermissionGrant> buttonPermissionGrantList,
MenuRedirectMeta redirectMeta);
/**
* 删除菜单(逻辑删除)
* <p>
* 业务规则:存在子菜单(deleted=0)时禁止删除,需先迁移或删除子菜单。
*
* @param menuId 菜单ID
*/
void deleteMenu(Long menuId);
/**
* 移动菜单到新父节点
* <p>
* 业务规则:
* - targetParentId=0 移动到根节点;
* - 不允许移动到自身(id == targetParentId);
* - 不允许移动到自身的子孙节点下(防环检测:基于 idPath 前缀比对);
* - 子树内所有节点 idPath/namePath/level 按前缀替换 + 批量保存;
* - besideId 为空则移动到 targetParentId 下的最后一个子节点之后
*
* @param id 被移动的菜单ID
* @param targetParentId 新父菜单ID(0=根)
* @param besideId 放到哪个菜单之前
* @return 影响行数
*/
int moveMenu(Long id, Long targetParentId, Long besideId);
// ======================== 二段式动态剪枝(领域核心算法) ========================
/**
* 二段式动态剪枝:菜单全树 ∩ 租户权限池 ∩ 用户权限并集(hasAny 语义)
* <p>
* 算法步骤:
* 1. 加载未删除的菜单全集(按 level/sort 升序);
* TODO: 优先从 menu:tree:full Redis 缓存读取(TTL 15min)。
* 2. 第一/二段剪枝:对每个菜单的 visiblePermissions 执行 hasAny 命中检查
* (要求权限码同时位于租户权限池与用户权限并集);
* 3. 祖先链补全:避免"孤岛"——子菜单可见但父菜单不可见时无法挂载,
* 沿 parentId 向上补全所有祖先;
* 4. 返回扁平菜单列表(保持 level/sort 顺序),由调用方组装树形视图。
*
* @param tenantPermissionPool 租户权限池(第一段剪枝输入;为空集合时所有菜单的可见权限均不命中)
* @param userPermissions 用户权限并集(第二段剪枝输入;为空集合时同上)
* @return 满足两段剪枝 + 祖先链补全后的菜单扁平列表
*/
List<SysMenu> pruneUserMenuTree(Set<String> tenantPermissionPool, Set<String> userPermissions);
/**
* 计算用户在某菜单下实际拥有的按钮编码列表
* <p>
* 业务规则:
* - patternType=1(跟随菜单):菜单可见即按钮可见;
* - patternType=2(独立授权)或缺省:要求该按钮 permission 位于用户权限并集中;
* - 缺省(patternType=null)以独立授权为安全默认(最小权限原则)。
*
* @param menu 目标菜单
* @param userPermissions 用户权限并集
* @return 用户实际可见的按钮编码列表(不含 null)
*/
List<String> filterButtonCodes(SysMenu menu, Set<String> userPermissions);
}
领域事件
领域事件 (Domain Event)
记录领域内发生的、对其他地方有影响的事实。目的是解耦跨聚合
可以将领域内造成后续影响的内容,打包发布成一个事件,交给 ACL 层的处理器,去处理对应的事件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23package com.zijinshe.system.tenant.domain.event.department;
import com.zijinshe.common.domain.event.DomainEvent;
import lombok.Getter;
import lombok.ToString;
import java.io.Serial;
public class SysDefaultDepartmentCreatedEvent extends DomainEvent<Long> {
private static final long serialVersionUID = 1L;
private Long departmentId;
private Long tenantId;
public SysDefaultDepartmentCreatedEvent(Long departmentId, Long tenantId) {
super(departmentId);
this.departmentId = departmentId;
this.tenantId = tenantId;
}
}
acl 层
ACL层作为适配器/防腐层,包含 Ports(端口) 和 Adapters(适配器)。它是领域与外部世界(数据库、前端、第三方服务)沟通的桥梁,负责将技术细节隔离在领域之外。
“防腐”的含义是:防止外部系统的概念、数据格式、API 细节”污染”领域核心。
举个例子:领域层只关心
SysUser(员工)这个业务概念,它不需要知道这个数据是存在
MySQL 里、还是 Redis 里、还是通过钉钉 API 拉取来的。ACL
层负责把外部系统的数据格式”翻译”成领域对象,反之亦然。
端口
端口是由领域层 主动定义 的接口,但放在
acl 包下作为契约集合。
repositories是仓储端口,实际上也就是数据访问接口
端口必须定义在 acl.ports 包下,而非 domain.port。这样:
- 端口和适配器同属 ACL 层,高内聚
- 领域层完全不需要知道技术实现细节
- 南北对称:
ohs(北向)对外暴露,acl(南向)对外调用,结构清晰
这里有两类接口:
第一类:自定义 DAO 接口(
ISysTenantDao,ISysUserDao等)1
2
3
4
5// ISysTenantDao.java — 定义业务语义的数据访问契约
public interface ISysTenantDao extends IBaseDao<SysTenant, Long> {
Optional<SysTenant> findAvailableTenant(Long tenantId); // 查找可用租户
boolean existsByName(String name); // 按名称去重
}- 接口方法名反映业务意图(
findAvailableTenant),而不是技术细节(select * from ... where deleted=0)。 - 这部分的实现写道适配器的包里。
第二类:JPA Repository 接口(
SysTenantRepository,SysUserRepository等)1
2
3
4// SysTenantRepository.java — JPA 标准 CRUD
public interface SysTenantRepository extends BaseRepository<SysTenant, Long> {
}为什么不合并?——这是 CQRS 分离的体现:
ISysTenantDao用于写操作和复杂查询(带业务语义的方法)SysTenantRepository用于简单读操作(标准的 CRUD),让 app 层可以直接调用
pl表示层契约 / DTO,是南向数据传输对象
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15// SysRoleGrantDTO.java — ACL 专用的数据传输对象
public class SysRoleGrantDTO implements Serializable {
private Long grantId;
private Long roleId;
private String roleCode;
private String roleName;
public boolean areAdmin() {
return Objects.equals(TenantEnums.BuiltinRole.of(this.roleCode),
TenantEnums.BuiltinRole.ADMIN);
}
}这里注意区分两个
pl层:ohs.pl— 北向 PL,对外 API 的数据契约acl.ports.pl— 南向 PL,与外部系统、数据库交互的数据桥梁
这两个 pl 层在下面会详细讲解为什么要这样
适配器
这里是端口的具体实现
converters数据转换器,将外部系统的数据格式转换为领域对象能理解的格式,反之亦然。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21// SysTenantSyncConverter.java — 外部系统数据 ↔ 领域对象的翻译官
public abstract class SysTenantSyncConverter {
// 将钉钉部门 DTO 转为同步结果对象
public SysTenantSyncResultDO.Department toDepartment(DingTalkDepartmentDTO syncItem) {
// ...
}
// 将领域对象转为同步结果对象
public SysTenantSyncResultDO.Department toDepartment(SysDepartment sysDepartment) {
// ...
}
// Diff 比对:钉钉数据 vs 本地数据 → 增删改三组任务
public SysTenantSyncResultDO comparedToDingTalk(
List<DingTalkDepartmentDTO> departmentSyncItems,
List<SysDepartment> sysDepartments, ...) {
// 新增、修改、删除的 diff 逻辑
}
}handlers事件处理器是领域事件的消费者,按业务对象分子包
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16// SysTenantCreatedHandler.java
public class SysTenantCreatedHandler {
public void handle(SysTenantCreatedEvent event) {
Long tenantId = event.getPayload();
// 1. 创建默认部门(根部门)
SysDepartment defaultDept = this.sysDepartmentService.createDefaultDepartment(tenantId);
// 2. 创建内置角色(管理员、审计员等)
this.sysRoleService.createBuiltinRole(tenantId);
}
}Handler 解决了什么问题? —— 实现了跨聚合、跨上下文的最终一致性。比如:
- 租户创建后 → 自动创建默认部门 + 内置角色(跨聚合协调)
- 员工绑定后 → 自动计算权限快照(跨聚合协调)
- 权限池收缩后 → 级联清洗角色脏权限(事件驱动链式反应)
services领域服务实现,这个包是领域服务接口的具体实现,也就是之前写到 domain.server 包下的接口在这边实现
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// SysTenantServiceImpl.java
public class SysTenantServiceImpl implements ISysTenantService {
public SysTenant createTenant(OrganizationName orgName) {
// 1. 校验:名称唯一性
boolean existName = this.sysTenantDao.existsByName(orgName.getName());
Assert.isFalse(existName, "租户名称已存在");
// 2. 核心:领域对象创建 + 事件发布
SysTenant result = this.domainEventPublishHelper.executeWithEvents(
() -> { // 阶段1:创建
SysTenant tenant = SysTenant.newInstance(orgName, "系统租户");
tenant = this.sysTenantDao.save(tenant);
return tenant;
},
(tenant) -> { // 阶段2:注册事件
tenant.registerTenantCreatedEvent();
return this.sysTenantDao.save(tenant);
}
);
return result;
}
}为什么领域服务实现放在
acl.adapters.services而不是domain层?因为领域服务接口
ISysTenantService定义在domain.service包,只声明方法签名;而实现类需要依赖 DAO、外部 API 客户端等技术细节,因此放在 ACL 适配器层。这是依赖倒置的体现——领域层定义契约,ACL 层提供实现。repositories仓储接口的实现
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24// SysTenantDaoImpl.java — ISysTenantDao 的具体实现
public class SysTenantDaoImpl extends BaseDao<SysTenant, Long> implements ISysTenantDao {
// 写操作覆盖类级只读事务
public void delete(Long id) {
SysTenant entity = super.findOne(id).orElse(null);
if (entity == null) {
throw new DataNotFoundException(id.toString());
}
entity.setDeleted(1); // 逻辑删除,而非物理删除
super.save(entity);
}
public Optional<SysTenant> findAvailableTenant(Long tenantId) {
Map<String, Object> params = new HashMap<>();
params.put("EQ_id", tenantId);
params.put("EQ_deleted", 0); // 排除已删除
return super.findOne(params);
}
}
新增一个外部依赖时怎么做?
假设你要接入企业微信,步骤如下:
acl.ports— 定义接口IWechatWorkDao.java— 数据访问端口IWechatWorkClient.java— API 调用端口
acl.adapters.services— 实现领域服务WechatWorkServiceImpl.javaimplementsIWechatWorkService
acl.adapters.repositories— 实现数据访问WechatWorkDaoImpl.javaimplementsIWechatWorkDao
acl.adapters.converters— 数据转换WechatWorkSyncConverter.java
acl.adapters.handlers— 事件消费(如有需要)WechatWorkContactSyncedHandler.java
ohs 层
OHS(Open Host Service)层的本质:以标准、友好的方式将系统能力暴露给外部世界。
local 本地或进程内
appservices这是业务用例的编排层
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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46在这个项目中,AppService 被视为 OHS 层的一部分——它是北向网关的"内部协调层"。请求从 Controller(remote)进来,经过 AppService(local)编排,再进入领域层。
// 默认只读
public class SysTenantAppService {
private ISysTenantDao sysTenantDao; // 读操作直接走 DAO
private ISysTenantService sysTenantService; // 写操作走领域服务
private AesGcmFieldEncryptor fieldEncryptor; // 技术关注点
// ========== 写操作:编排领域服务 ==========
// 覆盖类级只读
public Long createTenant(CreateSysTenantCommand command) {
// ① DTO → 值对象(协议转换)
OrganizationName orgName = OrganizationName.of(command.getName(), command.getShortName());
// ② 调用领域服务(业务逻辑在 domain 层)
SysTenant tenant = this.sysTenantService.createTenant(orgName);
// ③ 返回原始类型(不暴露领域对象)
return tenant.getId();
}
public void configureOA(Long tenantId, ConfigureOACommand command) {
// 技术关注点:加密敏感字段
String encryptedAppSecret = this.fieldEncryptor.encrypt(command.getAppSecret());
TenantOaConfigInfo config = TenantOaConfigInfo.ofDingTalk(...);
this.sysTenantService.configureOA(tenantId, config);
}
// ========== 读操作:直接走 DAO(CQRS 分离) ==========
public SysTenantDetailView getDetail(Long tenantId) {
SysTenant tenant = sysTenantDao.findAvailableTenant(tenantId)
.orElseThrow(() -> new BaseException(404, "租户不存在"));
// 通过 Converter 将领域对象转为 View
return SysTenantConverter.INSTANCE.toDetailView(tenant);
}
public Page<SysTenantBriefView> page(SysTenantPageQuery query) {
// 查询参数组装 → DAO 分页查询 → Converter 转换
Page<SysTenant> entityPage = sysTenantDao.findAll(params, pageRequest);
// ...
return new PageImpl<>(views, pageRequest, entityPage.getTotalElements());
}
}在这个项目中,
AppService被视为 OHS 层的一部分——它是北向网关的”内部协调层”。请求从 Controller(remote)进来,经过 AppService(local)编排,再进入领域层。实际上,我们把它看成 MVC 架构中的 Service 层即可,因此来说约束相对较少,相对开放一些
converter这是 领域对象 ↔︎ 北向 DTO 的转换器,实际上,我们处理数据的 DTO 和展示数据的 DTO(VO),在很多情况下,不能一概而论,因此南北网关需要各自使用各自的 DTO,来与领域对象交互,因此需要各自的转换器
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public abstract class SysTenantConverter {
public static final SysTenantConverter INSTANCE = Mappers.getMapper(SysTenantConverter.class);
// ① MapStruct 自动映射基础字段
public abstract SysTenantBriefView toBriefView0(SysTenant entity);
// ② 手写代码展开嵌套对象 + 脱敏
public SysTenantDetailView toDetailView(SysTenant entity) {
SysTenantDetailView view = toDetailView0(entity);
TenantOaConfigInfo oaConfig = entity.getOaConfig();
if (oaConfig != null) {
view.setOaCorpId(oaConfig.getCorpId());
view.setOaCorpName(oaConfig.getCorpName());
// 设计决策:appSecret 永不返回给前端!
}
// 从 BaseAuditInfo 展开审计字段
if (entity.getAudit() != null) {
view.setCreateTime(entity.getAudit().getCreateTime());
view.setUpdateTime(entity.getAudit().getUpdateTime());
}
return view;
}
}pl北向数据传输对象
这是 OHS 层最精细的分包设计,按业务对象分子包,每个业务对象下按 CQRS 模式三分:
1
2
3
4
5
6pl/
└── tenant/ # 按业务对象分包
├── command/ # 命令(写操作入参)
├── query/ # 查询条件(读操作入参)
│ └── SysTenantPageQuery.java
└── view/ # 视图对象(出参)command/— 命令对象1
2
3
4
5
6
7
public class CreateSysTenantCommand implements Serializable {
private String name;
private String shortName;
}Command 的特点:
不可变意图(setter 是给框架反序列化用的)
携带 Swagger 注解,作为 API 文档的 Schema
字段是原始类型(String, Long),不含领域概念
query/— 查询对象1
2
3
4
5
6
7
public class SysTenantPageQuery implements Serializable {
private Integer pageNo; // 分页参数
private Integer pageSize; // 分页大小
private String keyword; // 业务筛选条件
private Integer status; // 状态筛选
}Query 的特点:
- 封装查询条件 + 分页参数
- CQRS 分离的体现:读操作不走领域模型,直接用 Query → DAO → View 的路径
view/admin/— 视图对象1
2
3
4
5
6
7
8
9
10
11
12
public class SysTenantDetailView implements Serializable {
private Long id;
private String name;
private Integer status;
private List<String> permissionPool; // 权限池展开
private String oaCorpId; // 从 oaConfig 嵌套对象展开
private String oaCorpName;
// 设计决策:appSecret 不在 View 中暴露
private LocalDateTime createTime; // 从 BaseAuditInfo 展开
private LocalDateTime updateTime;
}View 的特点:
- 面向消费者设计:后台管理端看到什么字段,View 就定义什么字段
- 嵌套对象展开:领域对象的
oaConfig值对象 → View 中的扁平字段 - 安全脱敏:
appSecret不出现在 View 中 admin/子包表示这是后台管理端视图——预留了将来h5/、miniapp/等不同消费者视图的空间
remote 远程或对外暴露
controllers这是直接面向前端/客户端的 HTTP API 层
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class SysTenantController {
private SysTenantAppService sysTenantAppService; // 只依赖 AppService
public HttpResponse<Long> create( CreateSysTenantCommand command) {
Long tenantId = this.sysTenantAppService.createTenant(command);
return HttpResponse.success(tenantId);
}
public HttpResponse<SysTenantDetailView> getDetail( Long tenantId) {
return HttpResponse.success(this.sysTenantAppService.getDetail(tenantId));
}
public HttpResponse<Page<SysTenantBriefView>> page( SysTenantPageQuery query) {
return HttpResponse.success(this.sysTenantAppService.page(query));
}
}apis内部 API,供微服务间 Feign 调用,这是供其他微服务通过 Feign 调用的内部端点
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class InternalSysUserController {
private SysUserAppService sysUserAppService;
public HttpResponse<UnifiedUserGetResponse> bindUnifiedUserToSysUser(
BindUnifiedUserByMobileCommand request) {
UnifiedUserGetResponse response = this.sysUserAppService.bindUnifiedUserByMobile(request);
return HttpResponse.success(response);
}
public HttpResponse<UnifiedUserGetResponse> getUnifiedBindSysUserByMobile(
String mobile) {
UnifiedUserGetResponse response =
this.sysUserAppService.getUserByMobileForBindUnifiedUser(mobile);
return HttpResponse.success(response);
}
}
如何上手开发
开发完整流程
假设你要为 user-server
新增一个”通知模板”功能,以下是标准开发顺序:
领域层(domain)
先想行为,再想数据。先思考这个业务概念有哪些状态?它允许执行什么操作?
尽量把参数校验、格式清洗、状态机扭转等逻辑,写在值对象或者聚合根的内部方法里。让 App 层和 Service 层变“薄”。
创建顺序:值对象 → 领域事件 → 聚合根 → 领域服务接口
值对象
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// 路径: system/notification/domain/valueobject/TemplateTitle.java
package com.zijinshe.system.notification.domain.valueobject;
import cn.hutool.core.util.StrUtil;
import jakarta.persistence.Embeddable;
import lombok.*;
public class TemplateTitle {
private String title;
public static TemplateTitle of(String title) {
// 用 Hutool Assert 校验,不用 @NotBlank
StrUtil.notBlank(title, "模板标题不能为空");
Assert.isTrue(title.length() <= 100, "模板标题长度不能超过100");
TemplateTitle vo = new TemplateTitle();
vo.title = title;
return vo;
}
}领域事件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24// 路径: system/notification/domain/event/TemplateCreatedEvent.java
package com.zijinshe.system.notification.domain.event;
import com.zijinshe.common.domain.DomainEvent;
import lombok.*;
public class TemplateCreatedEvent extends DomainEvent<Long> {
private Long templateId;
private String title;
private Long tenantId;
public static TemplateCreatedEvent of(Long templateId, String title, Long tenantId) {
TemplateCreatedEvent event = new TemplateCreatedEvent();
event.setAggregateId(templateId); // DomainEvent 基类方法
event.templateId = templateId;
event.title = title;
event.tenantId = tenantId;
return event;
}
}聚合根(aggregate)
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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88// 路径: system/notification/domain/aggregate/NotificationTemplate.java
package com.zijinshe.system.notification.domain.aggregate;
import com.zijinshe.common.domain.BaseAggregateRoot;
import com.zijinshe.common.jpa.entity.BaseAuditInfo;
import com.zijinshe.system.notification.domain.event.TemplateCreatedEvent;
import com.zijinshe.system.notification.domain.valueobject.TemplateTitle;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.DynamicInsert;
import org.hibernate.annotations.DynamicUpdate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.io.Serial;
import java.io.Serializable;
// 仅为框架反序列化,业务代码禁止直接 set
// 仅为 JPA 反射,业务代码必须用工厂方法
public class NotificationTemplate
extends BaseAggregateRoot<NotificationTemplate>
implements Serializable {
private static final long serialVersionUID = 1L;
private String title; // 标题
private String content; // 模板内容
private Integer status; // 0-启用 1-禁用
private Long tenantId; // 所属租户
private BaseAuditInfo auditInfo; // creator/updater/createTime/updateTime
private Integer deleted = 0;
// ========== 工厂方法 ==========
/**
* 创建新模板
*/
public static NotificationTemplate newInstance(
TemplateTitle title, String content, Long tenantId) {
NotificationTemplate template = new NotificationTemplate();
template.title = title.getTitle(); // 值对象已校验
template.content = content;
template.status = 0;
template.tenantId = tenantId;
return template;
}
// ========== 业务方法 ==========
/**
* 启用模板
*/
public NotificationTemplate enable() {
this.status = 0;
return this;
}
/**
* 禁用模板
*/
public NotificationTemplate disable() {
this.status = 1;
return this;
}
/**
* 注册领域事件(聚合根内部调用)
*/
public TemplateCreatedEvent registerCreatedEvent() {
return TemplateCreatedEvent.of(this.getId(), this.title, this.tenantId);
}
// ========== 框架方法 ==========
public Long getId() {
return id;
}
}领域服务接口(service)
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
30// 路径: system/notification/domain/service/INotificationTemplateService.java
package com.zijinshe.system.notification.domain.service;
import com.zijinshe.system.notification.domain.aggregate.NotificationTemplate;
import com.zijinshe.system.notification.domain.valueobject.TemplateTitle;
import java.util.Optional;
/**
* 通知模板领域服务接口
* 只定义接口,不含实现
* 不提供查询方法(CQRS:读走 Dao)
*/
public interface INotificationTemplateService {
/**
* 创建模板
*/
NotificationTemplate create(TemplateTitle title, String content, Long tenantId);
/**
* 启用模板
*/
void enable(Long templateId);
/**
* 禁用模板
*/
void disable(Long templateId);
}
ACL 层(acl)— 对内业务层: 端口 + 适配器
端口 — Repository + Dao 接口
1
2
3
4
5
6
7
8
9
10
11
12// 路径: system/notification/acl/ports/repositories/NotificationTemplateRepository.java
package com.zijinshe.system.notification.acl.ports.repositories;
import com.zijinshe.common.jpa.repository.BaseRepository;
import com.zijinshe.system.notification.domain.aggregate.NotificationTemplate;
import org.springframework.stereotype.Repository;
public interface NotificationTemplateRepository
extends BaseRepository<NotificationTemplate, Long> {
// 保持轻量,仅 JPA 标准 CRUD
}1
2
3
4
5
6
7
8
9
10
11
12
13
14// 路径: system/notification/acl/ports/repositories/INotificationTemplateDao.java
package com.zijinshe.system.notification.acl.ports.repositories;
import com.zijinshe.common.jpa.repository.IBaseDao;
import com.zijinshe.system.notification.domain.aggregate.NotificationTemplate;
import java.util.Optional;
public interface INotificationTemplateDao
extends IBaseDao<NotificationTemplate, Long> {
// ✅ 业务语义命名:getBy... / existsBy... / page / listTopN
Optional<NotificationTemplate> getByTitleAndTenantId(String title, Long tenantId);
}适配器 — Dao 实现
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
30
31
32
33
34// 路径: system/notification/acl/adapters/repositories/NotificationTemplateDaoImpl.java
package com.zijinshe.system.notification.acl.adapters.repositories;
import com.zijinshe.common.jpa.repository.BaseDao;
import com.zijinshe.common.jpa.repository.BaseJdbcTemplate;
import com.zijinshe.system.notification.acl.ports.repositories.INotificationTemplateDao;
import com.zijinshe.system.notification.acl.ports.repositories.NotificationTemplateRepository;
import com.zijinshe.system.notification.domain.aggregate.NotificationTemplate;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
// 类级只读事务
public class NotificationTemplateDaoImpl
extends BaseDao<NotificationTemplate, Long>
implements INotificationTemplateDao {
private BaseJdbcTemplate baseJdbcTemplate;
public Optional<NotificationTemplate> getByTitleAndTenantId(
String title, Long tenantId) {
Map<String, Object> params = new HashMap<>();
params.put("EQ_title", title); // DynamicSpecification 前缀
params.put("EQ_tenantId", tenantId);
params.put("EQ_deleted", 0);
return Optional.ofNullable(super.findOne(params).orElse(null));
}
}适配器 — 领域服务实现
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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55// 路径: system/notification/acl/adapters/services/NotificationTemplateServiceImpl.java
package com.zijinshe.system.notification.acl.adapters.services;
import cn.hutool.core.util.StrUtil;
import com.zijinshe.system.notification.acl.ports.repositories.INotificationTemplateDao;
import com.zijinshe.system.notification.domain.aggregate.NotificationTemplate;
import com.zijinshe.system.notification.domain.service.INotificationTemplateService;
import com.zijinshe.system.notification.domain.valueobject.TemplateTitle;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
public class NotificationTemplateServiceImpl
implements INotificationTemplateService {
private INotificationTemplateDao templateDao;
// 不注入 Repository(BaseDao 已提供 JPA 能力)
// 写操作覆写为可写事务
public NotificationTemplate create(
TemplateTitle title, String content, Long tenantId) {
// 用 Hutool Assert 校验
StrUtil.notBlank(content, "模板内容不能为空");
// 用工厂方法创建,不用 new
NotificationTemplate template =
NotificationTemplate.newInstance(title, content, tenantId);
return this.templateDao.save(template);
}
public void enable(Long templateId) {
NotificationTemplate template = this.templateDao.findOne(templateId)
.orElseThrow(() -> new IllegalArgumentException("模板不存在"));
template.enable(); // 聚合根自己的业务方法
this.templateDao.save(template);
}
public void disable(Long templateId) {
NotificationTemplate template = this.templateDao.findOne(templateId)
.orElseThrow(() -> new IllegalArgumentException("模板不存在"));
template.disable();
this.templateDao.save(template);
}
}事件处理器(按需)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22// 路径: system/notification/acl/adapters/handlers/TemplateCreatedHandler.java
package com.zijinshe.system.notification.acl.adapters.handlers;
import com.zijinshe.system.notification.domain.event.TemplateCreatedEvent;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
public class TemplateCreatedHandler {
// 非关键副作用异步执行
public void handle(TemplateCreatedEvent event) {
log.info("通知模板已创建: templateId={}, title={}, tenantId={}",
event.getTemplateId(), event.getTitle(), event.getTenantId());
// 执行副作用:发送通知、记录日志、同步数据等
}
}
OHS 层(ohs)— 对外接口
PL 层 — Command / Query / View
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// 路径: ohs/local/converters/NotificationTemplateConverter.java
package com.zijinshe.system.notification.ohs.local.converters;
import com.zijinshe.system.notification.domain.aggregate.NotificationTemplate;
import com.zijinshe.system.notification.ohs.local.pl.view.admin.TemplateDetailView;
import org.mapstruct.Mapper;
import org.mapstruct.NullValuePropertyMappingStrategy;
import org.mapstruct.factory.Mappers;
public abstract class NotificationTemplateConverter {
public static final NotificationTemplateConverter INSTANCE =
Mappers.getMapper(NotificationTemplateConverter.class);
// ✅ toXxx0 由 MapStruct 自动生成
abstract TemplateDetailView toDetailView0(NotificationTemplate template);
// ✅ toXxx 对外公开,预留扩展点
public TemplateDetailView toDetailView(NotificationTemplate template) {
return toDetailView0(template);
}
}AppService
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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90// 路径: ohs/local/appservices/NotificationTemplateAppService.java
package com.zijinshe.system.notification.ohs.local.appservices;
import cn.hutool.core.util.StrUtil;
import com.zijinshe.common.helper.DomainEventPublishHelper;
import com.zijinshe.system.notification.acl.ports.repositories.INotificationTemplateDao;
import com.zijinshe.system.notification.domain.aggregate.NotificationTemplate;
import com.zijinshe.system.notification.domain.service.INotificationTemplateService;
import com.zijinshe.system.notification.domain.valueobject.TemplateTitle;
import com.zijinshe.system.notification.ohs.local.converters.NotificationTemplateConverter;
import com.zijinshe.system.notification.ohs.local.pl.command.CreateTemplateCommand;
import com.zijinshe.system.notification.ohs.local.pl.query.TemplatePageQuery;
import com.zijinshe.system.notification.ohs.local.pl.view.admin.TemplateDetailView;
import com.zijinshe.system.notification.ohs.local.pl.view.admin.TemplateBriefView;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
public class NotificationTemplateAppService {
private INotificationTemplateDao templateDao;
private INotificationTemplateService templateService;
// 写操作走 DomainService
private DomainEventPublishHelper domainEventPublishHelper;
// ========== 读操作:直接走 Dao ==========
public Optional<TemplateDetailView> getById(Long id) {
return this.templateDao.findOne(id)
.map(NotificationTemplateConverter.INSTANCE::toDetailView);
}
public Page<TemplateBriefView> page(TemplatePageQuery query, Pageable pageable) {
Map<String, Object> params = new HashMap<>();
if (query.getKeyword() != null) {
params.put("LIKE_title", query.getKeyword());
}
if (query.getStatus() != null) {
params.put("EQ_status", query.getStatus());
}
params.put("EQ_deleted", 0);
return this.templateDao.findAll(params, pageable);
}
// ========== 写操作:走 DomainService + 发布领域事件 ==========
public TemplateDetailView create(CreateTemplateCommand cmd) {
// 前置校验
StrUtil.notBlank(cmd.getTitle(), "标题不能为空");
StrUtil.notBlank(cmd.getContent(), "内容不能为空");
// 通过 DomainEventPublishHelper 执行业务 + 注册事件
NotificationTemplate template = this.domainEventPublishHelper
.executeWithEvents(
() -> this.templateService.create(
TemplateTitle.of(cmd.getTitle()),
cmd.getContent(),
// tenantId 从上下文获取
SecurityContextHolder.getCurrentTenantId()
),
NotificationTemplate::registerCreatedEvent // 注册事件
);
return NotificationTemplateConverter.INSTANCE.toDetailView(template);
}
public void enable(Long id) {
this.templateService.enable(id);
}
public void disable(Long id) {
this.templateService.disable(id);
}
}Controller
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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60// 路径: ohs/remote/controllers/NotificationTemplateController.java
package com.zijinshe.system.notification.ohs.remote.controllers;
import com.zijinshe.common.response.HttpResponse;
import com.zijinshe.system.notification.ohs.local.appservices.NotificationTemplateAppService;
import com.zijinshe.system.notification.ohs.local.pl.command.CreateTemplateCommand;
import com.zijinshe.system.notification.ohs.local.pl.query.TemplatePageQuery;
import com.zijinshe.system.notification.ohs.local.pl.view.admin.TemplateDetailView;
import com.zijinshe.system.notification.ohs.local.pl.view.admin.TemplateBriefView;
import com.github.xiaoymin.knife4j.annotations.ApiSupport;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.web.bind.annotation.*;
public class NotificationTemplateController {
private NotificationTemplateAppService templateAppService;
// ✅ 只注入 AppService,不注入 DomainService/Repository
public HttpResponse<Page<TemplateBriefView>> page(
TemplatePageQuery query,
Pageable pageable) {
return HttpResponse.success(this.templateAppService.page(query, pageable));
}
public HttpResponse<TemplateDetailView> getById( Long id) {
return this.templateAppService.getById(id)
.map(HttpResponse::success)
.orElse(HttpResponse.fail("模板不存在"));
}
public HttpResponse<TemplateDetailView> create(
CreateTemplateCommand cmd) {
return HttpResponse.success(this.templateAppService.create(cmd));
}
public HttpResponse<Void> enable( Long id) {
this.templateAppService.enable(id);
return HttpResponse.success(null);
}
}
调用链
1 | HTTP 请求 |
| 层 | 一句话 |
|---|---|
| Domain Aggregate | “我是业务规则的唯一持有者” |
| Domain Service 接口 | “我定义跨聚合的业务契约,但不知道数据库长什么样” |
| Domain Event | “我记录已发生的业务事实,不可变” |
| ACL Ports | “我声明需要什么数据,但不关心从哪来” |
| ACL Adapters | “我用 JDBC/Redis/Feign 把数据取回来” |
| ACL Handler | “我监听事件,在事务提交后做副作用” |
| AppService | “我编排流程,但我很薄——只是指挥” |
| OHS Converter | “我把领域对象翻译成对外契约” |
| Controller | “我接收 HTTP 请求、校验参数、包装响应,仅此而已” |
五层架构
用一个公司的组织架构来类比:
1 | ┌──────────────────────────────────────────────────────┐ |
谁认识谁
1 | ┌──────────┐ |
读写分离(CQRS),数据流的不同路径中,为什么读和走的路不一样?
1 | ┌─────────────┐ |
读操作不需要业务规则。例如,查 “状态=1 的租户” 只是一个过滤条件,不需要经过状态机判断。让读绕开 DomainService 的好处很明显:
- 读可以直接用 BriefDTO 做字段裁剪(只查 id + name,不查大字段)
- 读可以用 JDBC 原生 SQL 优化(join、聚合、分页)
- DomainService 保持纯粹——它的每个方法都对应一个业务动作
1 |
|
Ports & Adapters
这是 ACL 层内部的精妙设计
1 | ┌─────────────────────────────────────────────┐ |
为什么要有两层?—— 双重接口模式
1 | // ====== Repository:JPA 标准 CRUD,保持轻量 ====== |
谁是调用方?怎么选择?
1 | AppService 中: |
领域事件 —— 聚合之间的”广播”机制
领域事件是项目中最精妙的设计之一,解决了一个关键问题:租户创建后,自动创建默认部门,但不让”租户”聚合知道”部门”的存在。
1 | // ❌ 坏设计:租户聚合直接知道部门 |
完整的事件流转:
1 | AppService |
值对象 —— 业务规则的”守门员”
值对象是领域层中最容易被低估的角色。它的核心价值是:把校验逻辑内聚到离数据最近的地方。
1 | // ❌ 坏设计:校验散落在各处 |
值对象在调用链中的位置:
1 | Controller 接收 raw String " 北京分公司 " |
值对象与 Converter 的关系:
1 | 规则:值对象自己负责构造/转换,Converter 不负责值对象转换 |
完整调用链:一次 创建租户 的全过程
把所有关系串联起来,看一个完整的请求生命线:
1 | POST /sys/tenant |
DDD中的常见的一些规范
关键编码规范
- 注入:必须
@Resource,禁止@Autowired - 事务:类级
@Transactional(readOnly = true),写方法覆写@Transactional - 校验:禁止 Jakarta Bean
Validation,使用 Hutool
Assert在值对象工厂方法中校验 - 命名:
- Repository:无
I前缀,继承BaseRepository - DAO 接口:
I前缀 +Impl实现 - 领域服务接口:
I前缀,仅定义接口 - 应用服务:无接口,直接类
- 事件:
{名词}{过去分词}Event - 工厂方法:
newInstance()、of()、from() - 业务方法:
{动词}{名词},禁止裸动词
- Repository:无
- Lombok:仅允许
@Getter、@Setter、@NoArgsConstructor、@ToString
核心原则
在动手之前,记住这几条铁律:
| 原则 | 说明 |
|---|---|
| 依赖单向向内 | Controller → AppService → DomainService接口 → Domain Aggregate(不可逆) |
| Domain 纯净 | 领域层禁止 Spring 注解(JPA/Lombok/Hutool 除外) |
| CQRS 读写分离 | 写走 DomainService,读直接走 Dao/Repository |
| 谁使用谁转换 | OHS Converter 转 View,ACL Converter 转 PO/DO |
| 注入用 @Resource | 全项目禁止 @Autowired |
| 校验用 Hutool Assert | 禁止 Jakarta Bean Validation |
| 类级只读事务 | @Transactional(readOnly = true)
在类上,写方法单独 @Transactional |
关键决策
| 场景 | 正确做法 | 错误做法 |
|---|---|---|
| 创建对象 | Xxx.newInstance() /
Xxx.of(...) |
new Xxx() |
| 参数校验 | StrUtil.notBlank(...) /
Assert.isTrue(...) |
@NotBlank /
@Valid |
| 注入依赖 | @Resource |
@Autowired |
| 状态变更 | aggregate.enable()
(聚合根方法) |
aggregate.setStatus(1) |
| 查询数据 | xxxDao.findOne(id) /
xxxDao.findAll(params, page) |
Controller 里写 SQL |
| 返回响应 | HttpResponse.success(view) |
直接返回领域对象 |
| 写操作 | appService → domainService.create() |
appService → dao.save()
跳过领域服务 |
| 发布事件 | domainEventPublishHelper.executeWithEvents(...) |
Controller 里 new Event 然后发布 |
| 写事务 | 方法上 @Transactional |
类级 @Transactional 不加
readOnly |
| 跨聚合协调 | 领域事件 + Handler | 直接在 Service 里注入另一个聚合的 Dao |
| 跨微服务调用 | Feign 在 AppService 中调用 | Controller 直接调 Feign |
SQL DDL规范
写完领域对象后,在
user-server/sql/{domain}/{subdomain}.sql 中创建对应的
DDL:
1 | -- user-server/sql/system/notification.sql |







