紫矜社 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
           ┌──────────────┐
│ OHS (北网关) │ ← 对外暴露的接口层
│ ohs/remote/ │
└──────┬───────┘

┌──────┴───────┐
│ App (应用层) │ ← 薄协调层,CQRS 读写分离
│ ohs/local/ │
└──────┬───────┘

┌────────────┼────────────┐
│ │ │
┌────┴────┐ ┌────┴────┐ ┌────┴────┐
│ Domain │ │ Event │ │ Value │ ← 核心:纯业务逻辑
│Aggregate│ │ │ │ Object │ 无框架注解
└────┬────┘ └────┬────┘ └────┬────┘
│ │ │
└────────────┼────────────┘

┌──────┴───────┐
│ ACL (南网关) │ ← 防腐层:隔离外部系统,供给内部系统使用
│ acl/ │
└──────────────┘

一句话概括:

domain 放在正中央作为唯一业务核心;上面用 ohs 作为 对外提供能力 的北向网关,下面用 acl 作为 对外部依赖做隔离 的南向网关;app 则夹在 ohsdomain 之间,承担 用例编排、事务和权限等 的应用协调职责。

它不是传统三层 MVC 那种 Controller → Service → DAO 的纵向流水线,而是一个以领域为中心的对称结构,越北越朝外,越南越对内:

  • 北边ohs —— 负责“别人怎么调用我”
  • 中上app —— 负责“一个业务用例怎么被编排起来”
  • 中心domain —— 负责“业务规则、业务不变量、领域模型”
  • 南边acl —— 负责“我怎么访问数据库、缓存、MQ、第三方服务、远程系统”

但是实际上,从上到下从北到南的说法并不关键,实际上:

  • domain 是价值中心,而不是普通中间层

    domain菱形中心,而且domain 不依赖任何外层,所以说,所有外层都是围绕 domain 服务的,且依赖方向必须单向向内

  • ohsacl 是一对对称网关,它们的对称性非常重要:

    • 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 可能会做:

      1. 校验当前操作者是否有菜单维护权限
      2. 开启事务
      3. 调用某个领域服务创建菜单聚合
      4. 保存聚合
      5. 记录应用日志或发布应用事件
      6. 返回结果

      但它不应该自己写出“如果 type=1 就必须有 routeMeta、如果 name 变更就要更新所有子孙 namePath”这种规则。 这些规则应该在 domain 内部。

      也就是文档说的那句很关键的话:

      如果 app 层开始变得“智能”,意味着领域模型正在“失血”。

  • ohs 是唯一对客入口,不允许绕过它直接暴露内部结构ohs 在这套架构里是北向网关,它负责一切 系统如何被外部调用 的事情。

    • 它的职责是隔离外部协议与内部领域模型。所以,HTTP 请求体、Feign DTO、返回 View,都应该放在 ohs.pl,那么有了这些,Controller 就不应该直接返回领域对象或者领域对象对内的DTO
    • 查询和命令的 DTO 是分开的,实际上也是一种 CQRS
  • 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:内部 / 微服务 API
      • pl.command/query/view:对外契约 DTO
      • converter:领域对象转 View
    • ACL 层内部
      • ports:对外依赖的抽象契约
      • adapters:技术实现
      • handlers:领域事件处理器
      • services:领域服务接口的实现
      • converter:领域对象和外部格式之间转换
  • 这套架构天然绑定 CQRS 倾向:实际上在其中,写操作通过 domain.service 编排复杂业务,读操作直接调用 repository / 端口完成查询读取,遵循 CQRS 分离原则

    • 为什么?因为写模型偏重领域对象和不变量,读模型偏重视图对象,两种在设计上通常是不同的

依赖方向

单向向内,不可逆:

1
2
3
Controller → AppService → DomainService(接口) → Domain Aggregate
↓ ↓
Repository/Dao ←────────────────────── (通过端口访问)

严禁: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
    289
    package 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;

    @Getter
    @Setter
    @NoArgsConstructor
    @Entity
    @Table(name = "t_sys_menu", schema = "sys")
    @DynamicInsert
    @DynamicUpdate
    @EntityListeners(AuditingEntityListener.class)
    @JsonIgnoreProperties(value = {"hibernateLazyInitializer", "handler"})
    public class SysMenu extends BaseAggregateRoot<SysMenu> implements Serializable {
    @Serial
    private static final long serialVersionUID = 1L;

    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "seq_sys_menu")
    @SequenceGenerator(name = "seq_sys_menu", sequenceName = "seq_sys_menu", schema = "sys", allocationSize = 1)
    @Column(name = "id", nullable = false)
    private Long id;
    /**
    * 父ID
    */
    @Column(name = "parent_id", nullable = false)
    private Long parentId;
    /**
    * 层级
    */
    @Column(name = "level", nullable = false)
    private Integer level;
    /**
    * id路径,所有parentId(含根),用"/"连接
    * 用于快速查找父级子级
    */
    @Column(name = "id_path", nullable = false)
    private String idPath;
    /**
    * 名称路径,所有parentName(不含根)+自己,用"/"连接
    * 用于模糊检索
    */
    @Column(name = "name_path", nullable = false)
    private String namePath;
    /**
    * 同级排序
    */
    @Column(name = "sort", nullable = false)
    private Integer sort;
    /**
    * 名称
    */
    @Column(name = "name", nullable = false)
    private String name;
    /**
    * 图标
    */
    @Column(name = "icon", nullable = true)
    private String icon;
    /**
    * 类型:0-虚拟节点,1-内部系统,2-三方链接
    * 不影响菜单实际行为,只作为维护分类标识
    */
    @Column(name = "type", nullable = false)
    private Integer type;
    /**
    * 路由元数据
    */
    @Column(name = "route_meta", nullable = true)
    @JdbcTypeCode(SqlTypes.JSON)
    private MenuRouteMeta routeMeta;
    /**
    * 跳转链接
    */
    @Column(name = "redirect_meta", nullable = true)
    @JdbcTypeCode(SqlTypes.JSON)
    private MenuRedirectMeta redirectMeta;
    /**
    * 可见权限列表-hasAny
    */
    @Column(name = "visible_permissions", nullable = true)
    @JdbcTypeCode(SqlTypes.JSON)
    private List<String> visiblePermissions;
    /**
    * 页面按钮授权映射
    */
    @Column(name = "button_permission_grant_list", nullable = true)
    @JdbcTypeCode(SqlTypes.JSON)
    private List<PageButtonPermissionGrant> buttonPermissionGrantList;
    /**
    * 逻辑删除:0-否,1-是
    */
    @JsonIgnore
    @Column(name = "deleted", nullable = false)
    private Integer deleted;
    /**
    * 乐观锁
    */
    @Version
    @Column(name = "revision", nullable = false)
    private Long revision;
    /**
    * 审计相关信息
    */
    @Embedded
    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 格式存储。这避免了建立大量零碎的关联表
领域模型
  • 领域模型(Model):通常是 有业务含义,但不是聚合根 的对象

    它也是 @Entity,通常它不是聚合根。它没有独立的生命周期,例如如下的 Model 必须依附于 SysDepartmentSysUser 存在。

    实际上,可以在一些时候把它也堪称聚合根,但是通常情况下,它表达的是一个业务关系

    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
    package 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.*;

    @Getter
    @Setter
    @NoArgsConstructor
    @Entity
    @Table(name = "t_sys_user_department", schema = "sys")
    @DynamicInsert
    @DynamicUpdate
    @EntityListeners(AuditingEntityListener.class)
    @JsonIgnoreProperties(value = {"hibernateLazyInitializer", "handler"})
    public class SysUserDepartment implements Serializable {
    @Serial
    private static final long serialVersionUID = 1L;

    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "seq_sys_user_department")
    @SequenceGenerator(name = "seq_sys_user_department", sequenceName = "seq_sys_user_department", schema = "sys", allocationSize = 1)
    @Column(name = "id", nullable = false)
    private Long id;
    /**
    * 租户ID
    */
    @Column(name = "tenant_id", nullable = false)
    private Long tenantId;
    /**
    * 用户ID
    */
    @Column(name = "user_id", nullable = false)
    private Long userId;
    /**
    * 部门ID
    */
    @Column(name = "department_id", nullable = false)
    private Long departmentId;
    /**
    * 部门内排序
    */
    @Column(name = "sort", nullable = false)
    private Long sort;
    /**
    * 是否部门主管
    */
    @Column(name = "are_manager", nullable = false)
    private Integer areManager;
    /**
    * 同步来源,DingTalk, WeWork
    */
    @Column(name = "sync_source", nullable = false)
    private String syncSource;
    /**
    * 外部系统用户ID(如钉钉userid)
    */
    @Column(name = "sync_external_userid", nullable = true)
    private String syncExternalUserid;
    /**
    * 外部系统部门ID(如钉钉deptId)
    */
    @Column(name = "sync_external_deptid", nullable = true)
    private String syncExternalDeptid;
    /**
    * 同步日志ID
    */
    @Column(name = "sync_log_id", nullable = true)
    private Long syncLogId;
    /**
    * 是否删除
    */
    @Column(name = "deleted", nullable = false)
    private Integer deleted;
    /**
    * 审计相关信息
    */
    @Embedded
    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
    38
    package 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;

    @Getter
    @NoArgsConstructor(access = AccessLevel.PRIVATE)
    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 为例,它不仅仅包含 nameicon 两个字符串,它还在静态工厂方法 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
    23
    package com.zijinshe.system.tenant.domain.event.department;

    import com.zijinshe.common.domain.event.DomainEvent;
    import lombok.Getter;
    import lombok.ToString;

    import java.io.Serial;

    @Getter
    @ToString
    public class SysDefaultDepartmentCreatedEvent extends DomainEvent<Long> {
    @Serial
    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
    @Repository
    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 专用的数据传输对象
    @Getter
    @Setter
    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 — 外部系统数据 ↔ 领域对象的翻译官
    @Mapper(config = CentralMapperConfig.class)
    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
    @Component
    public class SysTenantCreatedHandler {

    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    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
    @Service
    @Transactional(readOnly = true)
    public class SysTenantServiceImpl implements ISysTenantService {

    @Transactional
    @Override
    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 的具体实现
    @Repository
    @Transactional(readOnly = true)
    public class SysTenantDaoImpl extends BaseDao<SysTenant, Long> implements ISysTenantDao {

    @Transactional // 写操作覆盖类级只读事务
    @Override
    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);
    }

    @Override
    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);
    }
    }

新增一个外部依赖时怎么做?

假设你要接入企业微信,步骤如下:

  1. acl.ports — 定义接口
    • IWechatWorkDao.java — 数据访问端口
    • IWechatWorkClient.java — API 调用端口
  2. acl.adapters.services — 实现领域服务
    • WechatWorkServiceImpl.java implements IWechatWorkService
  3. acl.adapters.repositories — 实现数据访问
    • WechatWorkDaoImpl.java implements IWechatWorkDao
  4. acl.adapters.converters — 数据转换
    • WechatWorkSyncConverter.java
  5. 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)编排,再进入领域层。@Service("sysTenantAppService")
    @Transactional(readOnly = true) // 默认只读
    public class SysTenantAppService {

    @Resource
    private ISysTenantDao sysTenantDao; // 读操作直接走 DAO
    @Resource
    private ISysTenantService sysTenantService; // 写操作走领域服务
    @Resource
    private AesGcmFieldEncryptor fieldEncryptor; // 技术关注点

    // ========== 写操作:编排领域服务 ==========

    @Transactional(rollbackFor = Exception.class) // 覆盖类级只读
    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
    @Mapper(config = CentralMapperConfig.class)
    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
    6
    pl/
    └── tenant/ # 按业务对象分包
    ├── command/ # 命令(写操作入参)
    ├── query/ # 查询条件(读操作入参)
    │ └── SysTenantPageQuery.java
    └── view/ # 视图对象(出参)

    command/ — 命令对象

    1
    2
    3
    4
    5
    6
    7
    @Schema(description = "创建租户指令")
    public class CreateSysTenantCommand implements Serializable {
    @Schema(description = "租户名称", requiredMode = REQUIRED)
    private String name;
    @Schema(description = "租户简称", requiredMode = REQUIRED)
    private String shortName;
    }

    Command 的特点:

    • 不可变意图(setter 是给框架反序列化用的)

    • 携带 Swagger 注解,作为 API 文档的 Schema

    • 字段是原始类型(String, Long),不含领域概念

    query/ — 查询对象

    1
    2
    3
    4
    5
    6
    7
    @Schema(description = "租户分页查询条件")
    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
    @Schema(description = "租户详情视图")
    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
    @RestController("sysTenantController")
    @RequestMapping("/sys/tenant")
    @Slf4j
    public class SysTenantController {

    @Resource
    private SysTenantAppService sysTenantAppService; // 只依赖 AppService

    @PostMapping("/create")
    public HttpResponse<Long> create(@RequestBody CreateSysTenantCommand command) {
    Long tenantId = this.sysTenantAppService.createTenant(command);
    return HttpResponse.success(tenantId);
    }

    @GetMapping("/{tenantId}")
    public HttpResponse<SysTenantDetailView> getDetail(@PathVariable Long tenantId) {
    return HttpResponse.success(this.sysTenantAppService.getDetail(tenantId));
    }

    @PostMapping("/page")
    public HttpResponse<Page<SysTenantBriefView>> page(@RequestBody 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
    @RestController("internalSysUserController")
    @RequestMapping("/internal/sys/user")
    @Slf4j
    public class InternalSysUserController {

    @Resource
    private SysUserAppService sysUserAppService;

    @PostMapping("/bindUnifiedUserToSysUser")
    public HttpResponse<UnifiedUserGetResponse> bindUnifiedUserToSysUser(
    @RequestBody BindUnifiedUserByMobileCommand request) {
    UnifiedUserGetResponse response = this.sysUserAppService.bindUnifiedUserByMobile(request);
    return HttpResponse.success(response);
    }

    @GetMapping("/getUnifiedBindUserByMobile")
    public HttpResponse<UnifiedUserGetResponse> getUnifiedBindSysUserByMobile(
    @RequestParam 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.*;

      @Embeddable
      @Getter
      @ToString
      @NoArgsConstructor(access = AccessLevel.PROTECTED)
      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.*;

      @Getter
      @ToString(callSuper = true)
      @NoArgsConstructor(access = AccessLevel.PRIVATE)
      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;

      @Entity
      @Table(name = "t_notification_template", schema = "sys")
      @DynamicInsert
      @DynamicUpdate
      @EntityListeners(AuditingEntityListener.class)
      @Getter
      @Setter // 仅为框架反序列化,业务代码禁止直接 set
      @NoArgsConstructor // 仅为 JPA 反射,业务代码必须用工厂方法
      public class NotificationTemplate
      extends BaseAggregateRoot<NotificationTemplate>
      implements Serializable {

      @Serial
      private static final long serialVersionUID = 1L;

      private String title; // 标题
      private String content; // 模板内容
      private Integer status; // 0-启用 1-禁用
      private Long tenantId; // 所属租户

      @Embedded
      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);
      }

      // ========== 框架方法 ==========

      @Override
      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;

      @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.*;

      @Repository
      @Transactional(readOnly = true) // 类级只读事务
      public class NotificationTemplateDaoImpl
      extends BaseDao<NotificationTemplate, Long>
      implements INotificationTemplateDao {

      @Resource
      private BaseJdbcTemplate baseJdbcTemplate;

      @Override
      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;

      @Service
      @Transactional(readOnly = true)
      @Slf4j
      public class NotificationTemplateServiceImpl
      implements INotificationTemplateService {

      @Resource
      private INotificationTemplateDao templateDao;
      // 不注入 Repository(BaseDao 已提供 JPA 能力)

      @Override
      @Transactional // 写操作覆写为可写事务
      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);
      }

      @Override
      @Transactional
      public void enable(Long templateId) {
      NotificationTemplate template = this.templateDao.findOne(templateId)
      .orElseThrow(() -> new IllegalArgumentException("模板不存在"));
      template.enable(); // 聚合根自己的业务方法
      this.templateDao.save(template);
      }

      @Override
      @Transactional
      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;

      @Component
      @Slf4j
      public class TemplateCreatedHandler {

      @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
      @Async // 非关键副作用异步执行
      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;

      @Mapper(
      componentModel = "spring",
      nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE
      )
      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.*;

      @Service("notificationTemplateAppService")
      @Transactional(readOnly = true)
      @Slf4j
      public class NotificationTemplateAppService {

      @Resource
      private INotificationTemplateDao templateDao;

      @Resource
      private INotificationTemplateService templateService;
      // 写操作走 DomainService

      @Resource
      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 + 发布领域事件 ==========

      @Transactional
      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);
      }

      @Transactional
      public void enable(Long id) {
      this.templateService.enable(id);
      }

      @Transactional
      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.*;

      @Tag(name = "通知-模板管理", description = "通知模板相关接口")
      @ApiSupport(authors = {"yourname"}, order = 2026070701)
      @RestController("notificationTemplateController")
      @RequestMapping("/sys/notification/template")
      @Slf4j
      public class NotificationTemplateController {

      @Resource
      private NotificationTemplateAppService templateAppService;
      // ✅ 只注入 AppService,不注入 DomainService/Repository

      @Operation(summary = "分页查询模板")
      @GetMapping("/page")
      public HttpResponse<Page<TemplateBriefView>> page(
      TemplatePageQuery query,
      @PageableDefault(size = 20) Pageable pageable) {
      return HttpResponse.success(this.templateAppService.page(query, pageable));
      }

      @Operation(summary = "查询模板详情")
      @GetMapping("/{id}")
      public HttpResponse<TemplateDetailView> getById(@PathVariable Long id) {
      return this.templateAppService.getById(id)
      .map(HttpResponse::success)
      .orElse(HttpResponse.fail("模板不存在"));
      }

      @Operation(summary = "创建模板")
      @PostMapping
      public HttpResponse<TemplateDetailView> create(
      @RequestBody CreateTemplateCommand cmd) {
      return HttpResponse.success(this.templateAppService.create(cmd));
      }

      @Operation(summary = "启用模板")
      @PutMapping("/{id}/enable")
      public HttpResponse<Void> enable(@PathVariable Long id) {
      this.templateAppService.enable(id);
      return HttpResponse.success(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
HTTP 请求


┌─────────────────────────────────────────────────────┐
│ Controller │
│ - 协议转换、参数绑定 │
│ - 仅注入 AppService │
│ - 返回 HttpResponse<View> │
└────────────────────────┬────────────────────────────┘


┌─────────────────────────────────────────────────────┐
│ AppService │
│ - 读:直接走 Dao.findOne / Dao.findAll │
│ - 写:走 DomainService + DomainEventPublishHelper │
│ - 不包含任何业务规则 │
│ - 通过 Converter 将 Domain 对象转为 View │
└──────┬──────────────────────────────┬───────────────┘
│ 写 │ 读
▼ ▼
┌──────────────────────┐ ┌──────────────────────┐
│ DomainService 接口 │ │ Dao 接口 / Repository │
│ (domain/service/) │ │ (acl/ports/) │
│ 只定义接口,无实现 │ │ 业务语义查询方法 │
└────────┬─────────────┘ └──────────┬───────────┘
│ │
▼ ▼
┌──────────────────────┐ ┌──────────────────────┐
│ Domain Aggregate │ │ DaoImpl (acl/adapters)│
│ - 工厂方法创建 │ │ - DynamicSpecification │
│ - 业务方法修改状态 │ │ - BaseJdbcTemplate │
│ - registerXxxEvent() │ └──────────────────────┘
└────────┬─────────────┘
│ 发布事件

┌──────────────────────┐
│ @TransactionalEvent │
│ Listener Handler │
│ (acl/adapters/handlers)│
│ - AFTER_COMMIT │
│ - @Async (按需) │
└──────────────────────┘

一句话
Domain Aggregate “我是业务规则的唯一持有者”
Domain Service 接口 “我定义跨聚合的业务契约,但不知道数据库长什么样”
Domain Event “我记录已发生的业务事实,不可变”
ACL Ports “我声明需要什么数据,但不关心从哪来”
ACL Adapters “我用 JDBC/Redis/Feign 把数据取回来”
ACL Handler “我监听事件,在事务提交后做副作用”
AppService “我编排流程,但我很薄——只是指挥”
OHS Converter “我把领域对象翻译成对外契约”
Controller “我接收 HTTP 请求、校验参数、包装响应,仅此而已”

五层架构

用一个公司的组织架构来类比:

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
┌──────────────────────────────────────────────────────┐
│ 📞 前台(OHS) │
│ "您好,这里是XX公司" │
│ 职责:接待外部来访、转接电话、不让闲杂人等进入 │
│ = Controller + AppService │
└────────────────────────┬─────────────────────────────┘
│ "张总,有客户找您"
│ 只传话,不做决策

┌──────────────────────────────────────────────────────┐
│ 🧠 决策层(Domain) │
│ "根据公司规定,新客户签约必须满足..." │
│ 职责:掌握所有业务规则,做所有业务判断 │
│ = Aggregate + ValueObject + DomainEvent │
│ = DomainService接口(只定义"能做什么") │
└────────┬─────────────────────────────────┬───────────┘
│ "给客户建档" │ "查客户资料"
│ 写操作 │ 读操作
▼ ▼
┌─────────────────────┐ ┌─────────────────────┐
│ 🗄️ 后勤部(ACL) │ │ 📋 档案室(ACL) │
│ "我去银行开户" │ │ "我帮您查档案" │
│ 职责:执行外部操作 │ │ 职责:提供数据查询 │
│ = DomainServiceImpl │ │ = Dao/Repository │
│ = Handler(副作用) │ │ │
│ = Feign Client │ │ │
└──────────┬───────────┘ └─────────────────────┘


┌──────────────┐
│ 🏦 外部世界 │
│ DB / Redis │
│ / 钉钉 / MQ │
└──────────────┘

谁认识谁

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
                ┌──────────┐
│ OHS │ ← 最外层,认识 AppService + PL
│Controller│
└────┬─────┘
│ 依赖(认识)

┌──────────┐
│ OHS │ ← 认识 DomainService接口 + Dao接口 + Converter
│AppService│
└────┬─────┘
│ 依赖(认识)
┌──────────────┼──────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Domain │ │ ACL │ │ ACL │
│ Service │ │ Ports │ │ Ports │
│ 接口 │ │ (Dao接口) │ │(Repository│
│ │ │ │ │ 接口) │
└────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │
│ │ │
▼ ▼ ▼
┌──────────────────────────────────────┐
│ DOMAIN 层 │
│ Aggregate / ValueObject / Event │
│ ← 这是整个系统的"中心", │
│ 不依赖任何外层 │
└──────────────────────────────────────┘

│ 实现(implements)
┌──────────────┼──────────────┐
│ │ │
┌─────┴──────┐ ┌────┴──────┐ ┌─────┴──────┐
│ ACL │ │ ACL │ │ ACL │
│ Adapters │ │ Adapters │ │ Adapters │
│ServiceImpl │ │ DaoImpl │ │ Handler │
└────────────┘ └───────────┘ └────────────┘
依赖方向:外层 → 内层(单向,不可逆)

读写分离(CQRS),数据流的不同路径中,为什么读和走的路不一样?

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
            ┌─────────────┐
│ Controller │
└──────┬──────┘

┌────────────┴────────────┐
│ │
读操作 写操作
"查租户列表" "创建新租户"
│ │
▼ ▼
┌──────────┐ ┌──────────┐
│AppService│ │AppService│
└────┬─────┘ └────┬─────┘
│ │
│ 直接走 Dao │ 走 DomainService
▼ ▼
┌──────────┐ ┌──────────────┐
│ Dao接口 │ │DomainService │
│findAll() │ │ 接口.create()│
└────┬─────┘ └──────┬───────┘
│ │
▼ ▼
┌──────────┐ ┌──────────────┐
│ DaoImpl │ │ Aggregate │
│(执行SQL) │ │ .newInstance()│
└──────────┘ │ .enable() │
└──────┬───────┘
│ 发布事件

┌──────────────┐
│ Handler │
│ 副作用处理 │
└──────────────┘

读操作不需要业务规则。例如,查 “状态=1 的租户” 只是一个过滤条件,不需要经过状态机判断。让读绕开 DomainService 的好处很明显:

  • 读可以直接用 BriefDTO 做字段裁剪(只查 id + name,不查大字段)
  • 读可以用 JDBC 原生 SQL 优化(join、聚合、分页)
  • DomainService 保持纯粹——它的每个方法都对应一个业务动作
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Service
public class SysTenantAppService {

// ============ 读操作:直接走 Dao ============
public Page<SysTenantBriefView> page(SysTenantPageQuery query, Pageable pageable) {
Map<String, Object> params = new HashMap<>();
params.put("LIKE_name", query.getKeyword()); // 动态条件
params.put("EQ_deleted", 0);
// ✅ 直接查,不经过 DomainService
return this.tenantDao.findAll(params, pageable);
}

// ============ 写操作:必须走 DomainService ============
@Transactional
public SysTenantDetailView create(CreateSysTenantCommand cmd) {
// ✅ 走 DomainService → Aggregate → 工厂方法 → 业务校验 → 发布事件
SysTenant tenant = this.domainEventPublishHelper.executeWithEvents(
() -> this.tenantService.create(OrganizationName.of(cmd.getName())),
SysTenant::registerCreatedEvent
);
return converter.toDetailView(tenant);
}
}

Ports & Adapters

这是 ACL 层内部的精妙设计

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
┌─────────────────────────────────────────────┐
│ ACL 层 │
│ │
│ Ports(端口 = 契约) │
│ ┌───────────────────────────────┐ │
│ │ ISysTenantDao │ │
│ │ "我需要按名称查租户" │ │
│ │ "我需要分页查租户" │ │
│ │ │ │
│ │ 这是"我想要什么"的声明 │ │
│ │ 不是"我怎么做到"的实现 │ │
│ └───────────────┬───────────────┘ │
│ │ implements │
│ ┌───────────────┴───────────────┐ │
│ │ SysTenantDaoImpl │ │
│ │ "我用 JPA Specification 查" │ │
│ │ "我用 JDBC 做复杂 join" │ │
│ │ │ │
│ │ 这是"我怎么做到"的实现 │ │
│ └───────────────────────────────┘ │
│ │
│ Adapters(适配器 = 实现) │
└─────────────────────────────────────────────┘

为什么要有两层?—— 双重接口模式

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
// ====== Repository:JPA 标准 CRUD,保持轻量 ======
@Repository
public interface SysTenantRepository extends BaseRepository<SysTenant, Long> {
// 只放 JPA 派生查询
Optional<SysTenant> findByNameAndDeleted(String name, Integer deleted);
}

// ====== Dao 接口:业务语义查询方法 ======
public interface ISysTenantDao extends IBaseDao<SysTenant, Long> {
// 业务语义命名:getBy..., page, listTopN, existsBy...
Optional<SysTenant> getByName(String name);
Page<SysTenant> pageByStatus(Integer status, Pageable pageable);
}

// ====== Dao 实现:混合 JPA + JDBC ======
@Repository
@Transactional(readOnly = true)
public class SysTenantDaoImpl
extends BaseDao<SysTenant, Long> // ← 继承 BaseDao,获得 JPA 能力
implements ISysTenantDao {

@Resource
private BaseJdbcTemplate baseJdbcTemplate; // ← JDBC 能力

@Override
public Optional<SysTenant> getByName(String name) {
Map<String, Object> params = new HashMap<>();
params.put("EQ_name", name); // ← DynamicSpecification
params.put("EQ_deleted", 0);
return Optional.ofNullable(super.findOne(params).orElse(null));
}

// 复杂查询用 JDBC
public List<SysTenantBriefDTO> findBriefWithDeptCount(Long tenantId) {
String sql = """
SELECT t.id, t.name, COUNT(d.id) as dept_count
FROM t_sys_tenant t
LEFT JOIN t_sys_department d ON d.tenant_id = t.id
WHERE t.id = ? AND t.deleted = 0
GROUP BY t.id, t.name
""";
return baseJdbcTemplate.findList(sql, new Object[]{tenantId},
SysTenantBriefDTO.class);
}
}

谁是调用方?怎么选择?

1
2
3
4
5
6
7
8
9
10
AppService 中:

读操作:
简单单表 → xxxDao.findOne(params) // Dao 接口
动态分页 → xxxDao.findAll(params, pageable) // Dao 接口(继承自 BaseDao)
复杂SQL → xxxDao.xxx() // Dao 接口(内部用 JDBC 实现)

写操作:
xxxRepository.save(entity) // Repository(JPA 代理)
xxxDao.save(entity)

领域事件 —— 聚合之间的”广播”机制

领域事件是项目中最精妙的设计之一,解决了一个关键问题:租户创建后,自动创建默认部门,但不让”租户”聚合知道”部门”的存在

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// ❌ 坏设计:租户聚合直接知道部门
public class SysTenant {
public void activate() {
this.status = ACTIVATED;
// ❌ 租户不应该知道"部门"的存在
departmentDao.save(Department.ofDefault(this.id));
}
}

// ✅ 好设计:通过事件解耦
public class SysTenant {
public void activate() {
this.status = ACTIVATED;
// ✅ 租户只发布一个事实,不管"谁来处理"
this.registerEvent(TenantActivatedEvent.of(this.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
AppService

│ 1. 调用 domainService.activate(tenantId)

DomainServiceImpl

│ 2. 从 DB 加载聚合根
│ 3. tenant.activate() ← 聚合根内部注册事件
│ 4. dao.save(tenant) ← 持久化到 DB

│ 5. DomainEventPublishHelper 从聚合根提取事件
│ 并通过 Spring ApplicationEventPublisher 发布

Spring ApplicationEventMulticaster

│ 6. 查找所有 @TransactionalEventListener 标注的方法
│ 等待事务提交(AFTER_COMMIT)

Handler 1: SysTenantCreatedHandler Handler 2: TenantPermissionPoolInitHandler
│ │
│ 7. @Async 异步执行 │
│ @Transactional(REQUIRES_NEW) │
▼ ▼
创建默认部门 + 内置角色 初始化权限池

值对象 —— 业务规则的”守门员”

值对象是领域层中最容易被低估的角色。它的核心价值是:把校验逻辑内聚到离数据最近的地方

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
// ❌ 坏设计:校验散落在各处
// Controller 里:
if (name.length() > 64) throw ...
// AppService 里:
if (StrUtil.isBlank(name)) throw ...
// DomainService 里:
if (name.contains("<script>")) throw ...

// ✅ 好设计:校验内聚在值对象中
@Embeddable
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED) // ← 保护空构造器
public class OrganizationName {

private String name;

public static OrganizationName of(String name) {
// ★ 所有校验在这里一次完成
StrUtil.notBlank(name, "组织名称不能为空");
Assert.isTrue(name.length() >= 2 && name.length() <= 64,
"组织名称长度需在 2-64 之间");
Assert.isTrue(!name.contains("<script>"),
"组织名称包含非法字符");

OrganizationName vo = new OrganizationName();
vo.name = StrUtil.cleanBlank(name); // 清洗
return vo;
}
}

值对象在调用链中的位置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Controller 接收 raw String " 北京分公司 "


AppService: 不校验,直接传给 DomainService


DomainServiceImpl:
OrganizationName name = OrganizationName.of(cmd.getName());
// ↑ 值对象在这里完成校验 + 清洗
// 此时 name 已经是"干净"的


Aggregate:
this.name = name.getName(); // 直接取值,无需再校验

值对象与 Converter 的关系

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
规则:值对象自己负责构造/转换,Converter 不负责值对象转换

// ❌ 不要在 Converter 中这样做:
public SysTenant toDomain(CreateSysTenantCommand cmd) {
SysTenant t = new SysTenant();
t.setName(cmd.getName()); // 绕过了值对象校验!
return t;
}

// ✅ Converter 内联调用值对象工厂方法:
public SysTenant toDomain(CreateSysTenantCommand cmd) {
SysTenant t = SysTenant.newInstance(
OrganizationName.of(cmd.getName()) // ← 走值对象校验
);
return t;
}

完整调用链:一次 创建租户 的全过程

把所有关系串联起来,看一个完整的请求生命线:

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
POST /sys/tenant
Body: { "name": "北京分公司" }

═══════════════════════════════════════════════════════════════
1 站:Controller(ohs/remote/controllers/SysTenantController.java)
═══════════════════════════════════════════════════════════════

@PostMapping
public HttpResponse<SysTenantDetailView> create(@RequestBody CreateSysTenantCommand cmd) {
// ① 协议转换:HTTP Body → Command 对象
// ② 调用 AppService
SysTenantDetailView view = this.tenantAppService.create(cmd);
// ⑤ 包装响应
return HttpResponse.success(view);
}

│ cmd = { name: "北京分公司" }


═══════════════════════════════════════════════════════════════
2 站:AppService(ohs/local/appservices/SysTenantAppService.java)
═══════════════════════════════════════════════════════════════

@Transactional // ← 事务开始
public SysTenantDetailView create(CreateSysTenantCommand cmd) {

// ③ 通过 DomainEventPublishHelper 执行业务
SysTenant tenant = this.domainEventPublishHelper.executeWithEvents(
// ---- 阶段 A:执行业务 ----
() -> {
// ④ 值对象校验
OrganizationName name = OrganizationName.of(cmd.getName());
// ⑤ 调用 DomainService 接口
return this.tenantService.create(name);
},
// ---- 阶段 B:注册事件 ----
(t) -> {
t.registerCreatedEvent(t.getId());
t.registerPermissionPoolInitEvent(t.getId());
return t;
}
);

// ⑫ DomainService 返回聚合根,Converter 转为 View
return SysTenantConverter.INSTANCE.toDetailView(tenant);
}

│ name = OrganizationName { name: "北京分公司" } ← 已校验 + 已清洗


═══════════════════════════════════════════════════════════════
3 站:DomainService接口(domain/service/ISysTenantService.java)
═══════════════════════════════════════════════════════════════

public interface ISysTenantService {
SysTenant create(OrganizationName name); // 只定义契约
}

│ Spring DI 注入实现类 SysTenantServiceImpl


═══════════════════════════════════════════════════════════════
4 站:DomainServiceImpl(acl/adapters/services/SysTenantServiceImpl.java)
═══════════════════════════════════════════════════════════════

@Override
@Transactional
public SysTenant create(OrganizationName name) {
// ⑥ 用工厂方法创建聚合根(不允许 new)
SysTenant tenant = SysTenant.newInstance(name);
// ⑦ 通过 Dao 持久化
return this.tenantDao.save(tenant);
}

│ tenant = SysTenant { name="北京分公司", status=UNACTIVATED, ... }


═══════════════════════════════════════════════════════════════
5 站:聚合根内部(domain/aggregate/SysTenant.java)
═══════════════════════════════════════════════════════════════

public static SysTenant newInstance(OrganizationName name) {
SysTenant tenant = new SysTenant(); // JPA 保留的空构造器
tenant.name = name.getName();
tenant.status = BusinessStatus.UNACTIVATED; // 初始状态
tenant.deleted = 0;
return tenant;
}

// 注册事件(在 AppService 的 executeWithEvents 的 Function 中调用)
public SysTenantCreatedEvent registerCreatedEvent(Long tenantId) {
SysTenantCreatedEvent event = SysTenantCreatedEvent.of(tenantId);
this.addEvent(event); // ← 收集到聚合根的 event 列表
return event;
}

│ tenantDao.save(tenant) → DB 插入 t_sys_tenant
│ 事务 COMMIT

│ ★ DomainEventPublishHelper 提取聚合根中的事件,发布到 Spring


═══════════════════════════════════════════════════════════════
6 站:Handler(acl/adapters/handlers/SysTenantCreatedHandler.java)
═══════════════════════════════════════════════════════════════

@TransactionalEventListener(phase = AFTER_COMMIT) // ← 事务提交后才触发
@Transactional(propagation = REQUIRES_NEW) // ← 独立事务
@Async // ← 异步
public void handle(SysTenantCreatedEvent event) {
// ⑧ 创建默认部门
this.deptService.createDefault(event.getAggregateId());
// ⑨ 创建内置角色(admin, security, audit)
this.roleService.createBuiltinRoles(event.getAggregateId());
}

═══════════════════════════════════════════════════════════════
结束:Controller 收到 View 对象,包装为 HttpResponse 返回
═══════════════════════════════════════════════════════════════

DDD中的常见的一些规范

关键编码规范

  • 注入:必须 @Resource,禁止 @Autowired
  • 事务:类级 @Transactional(readOnly = true),写方法覆写 @Transactional
  • 校验禁止 Jakarta Bean Validation,使用 Hutool Assert 在值对象工厂方法中校验
  • 命名
    • Repository:无 I 前缀,继承 BaseRepository
    • DAO 接口:I 前缀 + Impl 实现
    • 领域服务接口:I 前缀,仅定义接口
    • 应用服务:无接口,直接类
    • 事件:{名词}{过去分词}Event
    • 工厂方法:newInstance()of()from()
    • 业务方法:{动词}{名词},禁止裸动词
  • 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
-- user-server/sql/system/notification.sql
CREATE TABLE t_notification_template (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
title VARCHAR(100) NOT NULL,
content TEXT NOT NULL,
status SMALLINT NOT NULL DEFAULT 0,
tenant_id BIGINT NOT NULL,

-- 审计字段(对应 BaseAuditInfo)
creator BIGINT,
create_time TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
updater BIGINT,
update_time TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
deleted SMALLINT NOT NULL DEFAULT 0
);

-- 部分唯一索引(排除已删除数据)
CREATE UNIQUE INDEX uk_template_title_tenant
ON t_notification_template(title, tenant_id) WHERE deleted = 0;

-- 查询索引
CREATE INDEX idx_template_tenant_status
ON t_notification_template(tenant_id, status) WHERE deleted = 0;