自定义业务与分层扩展指南
在既有分层中组织业务动作,明确事务和多实体协作。
浏览全部手册
本页目录
文档来源与 Markdown 原文
权威正文:ineed-core/docs/project-development/custom-business-layering-guide.md。网站按工作区快照同步,原文中的历史日期和验证范围保留。
开始编码前核对同版本源码;跨仓文件引用可在源码定位目录查找。
下载 Markdown 原文 ↓适用范围
本指南面向已经沿 Ineed-Core 标准主路径接入业务模块的开发人员,适用于阻塞式 WebMvc + JPA/JDBC/MyBatis-Flex,也适用于响应式 WebFlux + R2DBC 的对等扩展。
当标准 CRUD / query 之外还需要增加自定义业务、跨表协作、状态切换后的补充动作、排序相关规则时,应该继续沿现有分层扩展,而不是临时发明平行层。
标准扩展主路径
Ineed-Core 当前推荐的自定义业务扩展主路径保持不变。
Controller -> Service -> Manager -> Repository/EntityManager
阻塞式与响应式链路均按 crud-query-guide.md 中各自运行时的接口矩阵执行。本文只补充“标准能力之外的自定义业务应该如何继续往下写”的通用分层规则。
共享扩展契约补充约定
当某个自定义能力由一个模块定义、另一个模块实现,并且需要同时支持 webmvc 与 webflux 两条线时,额外遵守:
- 共享接口定义优先放在发起方的
core共享层,而不是落在具体实现模块 - 阻塞式接口放
service/ - 响应式接口放
service/reactive/ webmvc/webflux模块只保留各自的适配实现,不重复定义另一套共享契约- 调用方如果已经持有当前会话对象,接口入参优先直接传
SecuritySession一类聚合上下文对象,而不是继续拆identityType、principalId、tenantKey - create / update / delete / status / account 一类副作用型 reactive 契约,和阻塞式语义保持一致;阻塞式为
void时,响应式统一定义为Mono<Void>,不要把影响行数暴露到上层扩展接口
账户认证型扩展补充约定
当某个业务模块需要接入统一认证 / 授权主链时,额外遵守:
- 共享认证桥接接口优先复用
IAccountAuthenticationService/IReactiveAccountAuthenticationService;权限快照统一复用ISecurityAuthoritiesService/IReactiveSecurityAuthoritiesService。 - 不要再生成已经废弃的
IAccountAuthenticationManager,也不要把共享认证能力重新收口到通用AccountManager。 - 主体模块自己的
Manager只补loadByUsername(...)、accountToSecurityUser(...)、模块私有异常映射和主体特有查询。 - 模块共享的
*SecurityUser统一放在该业务聚合的core子模块,供engine、engine-reactive、webmvc、webflux共用;不要在实现层重复定义多份。 - JDBC / R2DBC 仓储默认优先走接口组合;
Repository保持interface,直接组合 core fragment,并在接口内保留GetEntityClassOperation<T> + default getEntityClass() - 关系表或只承载少量动作的仓储,只组合必要 fragment,并直接在接口上声明模块私有查询 / 删除方法
- 不再生成模块内的
repository/support/*、Abstract*JdbcRepository、Abstract*R2dbcRepository或空转发RepositoryImpl - 只有存在真实自定义 SQL 或框架限制时,才额外创建具体
Impl类;对标样板优先参考ineed-security-authorization-system-identity-jdbc
转换层补充约定
当业务模块需要生成实体转换、AO/VO 转换或对象映射实现时,额外遵守:
- 转换层统一放在各数据栈实现模块下的
converter/包,不再生成mapstruct/ - 转换实现类名统一使用
XxxConverter - MyBatis-Flex 的
mapper/只保留持久化接口;对象转换实现必须放到converter/ - 普通
XxxConverter继承AoEntityConverter<E, AO>与EntityVoConverter<E, V>后,不要重复声明父接口已有的ao2Entity(...)、entityCopy(...)、appleAo(...)、entity2VO(...) - 只有需要方法级 MapStruct 配置时才显式声明对应转换方法,例如
@Mapping字典标签、派生字段、ignore 规则;无注解、无自定义逻辑的方法声明一律视为冗余 - 接入
CreateManager/UpdateManager/FindByIdManager/OrderManager一类抽象时,统一实现getCreateEntityConverter()、getUpdateEntityConverter()、getEntityVoConverter(),不要再沿用旧的get*Mapper()命名
各层职责边界
| 层次 | 负责什么 | 不负责什么 |
|---|---|---|
Controller |
暴露 API、接收参数、执行参数校验、记录请求入口日志、调用 Service、通过 ResultBuild 返回统一结果 |
不负责任务编排,不负责跨表事务,不直接操作 Manager / Repository |
Service |
作为事务边界和业务编排边界,组织一次业务用例,顺序调用多个 Manager,汇总业务结果;响应式侧在订阅期内完成依赖解析和编排 |
Service 不与 Service 互调,不直接承载底层持久化细节,不把所有实体规则都堆在本层 |
Manager |
承载单类业务能力和实体相关规则,例如 CRUD、查询、状态切换、排序、删除扩展 | 不作为默认的“总编排中心”,不负责跨接口的 Web 参数解析 |
Repository/EntityManager |
执行最终持久化、查询、排序、批量更新等数据访问动作 | 不承接业务流程判断,不承担事务编排,不直接处理 Web 返回结构 |
代码生成模板中的类级 Javadoc 必须与这张职责表保持一致。模板可以生成通用职责说明,但生成后的业务代码如果已经有明确业务规则,应继续补充具体的校验条件、生命周期约束和异常边界,不能只停留在“某某管理器”“某某服务类”这类空泛描述。
子表持久化动作落层规则
子表、关系表、从表围绕主对象外键做计数、存在性判断、查询或物理删除时,持久化动作必须落在对应 Repository / MyBatis-Flex Mapper,不能把表名、列名、通用 SQL 或通用 BaseMapper<?> 条件留在 Manager。
Manager 可以决定“什么时候检查”“什么时候级联删除”“检查失败抛什么业务异常”,但不能承接“如何按外键 count/delete/find”的底层访问细节。
强制写法:
- JDBC / JPA:在子表
Repository上声明 Spring Data 派生方法,例如long countByCategoryIdAndDeleted(String categoryId, Byte deleted)、void deleteByCategoryId(String categoryId) - R2DBC:在子表
Repository上声明响应式派生方法,例如Mono<Long> countByCategoryIdAndDeleted(String categoryId, Byte deleted)、Mono<Void> deleteByCategoryId(String categoryId) - MyBatis-Flex:在具体子表
Mapper上声明明确的默认方法,例如countByCategoryIdAndDeleted(...)、deleteByCategoryId(...),方法内部使用QueryWrapper或 processor 生成的TableDef
禁行写法:
- JDBC / R2DBC
Manager中用QuerySqlSupport或字符串拼 SQL 来做子表 count/delete - JPA
Manager中为了简单外键计数或级联物理删除手写 QueryDSLselect count/delete - MyBatis-Flex
Manager中写countActiveValue(BaseMapper<?> mapper, String columnName, String columnValue)、deleteValue(BaseMapper<?> mapper, String columnName, String columnValue)这类通用 helper
示例:
// Manager:只表达业务生命周期和业务异常
final long valueCount = valueRepository.countByDefinitionIdAndDeleted(entity.getId(), CoreYesNo.NO.getByte());
if (valueCount > 0) {
throw StatefulException.of(ErrorCode.DATA_IN_USE, "配置项");
}
// Repository / Mapper:表达具体外键访问
long countByDefinitionIdAndDeleted(String definitionId, Byte deleted);
void deleteByDefinitionId(String definitionId);
物理删除级联时同样遵守该规则:beforePhysicsDelete 中可以调用多个子表 Repository / Mapper 的 deleteBy... 方法,但每个 deleteBy... 的持久化条件必须由对应数据访问层封装。
Controller 生成补充约定
- 标准支持接口生成时,
webmvc与webflux的方法顺序、URL、权限表达式、参数名、日志语义统一对齐,不要一边叫common、另一边叫commonParameters getService()一类桥接方法统一放在接口最上面,方便实现类与代码生成器稳定复用ValidationUtils只负责生成ValidationResult;Web 层异常抛出分别收口到webmvc/webflux的ValidationExceptionSupportwebflux走统一返回体时,集合结果统一包装为Mono<ResponseResults<Iterable<T>>>;Flux只保留在内部链路或真正的流式接口中- 无返回体动作型 API 统一使用
.then(ResultBuild.success());不要生成ResultBuild.<Void>success()
Service 生成补充约定
- 标准支持接口生成时,阻塞式与响应式
Service的方法顺序、命名、Javadoc、日志语义统一对齐;响应式只保留必要的返回类型差异 - 不再额外生成只做能力聚合/转发的顶层别名接口,例如
ICrudService、IDataStatusService、ICrudManager、IDataStatusManager - 业务模块如果需要同时具备多种标准能力,直接
implements/extends真实能力接口组合,例如ICreateService + IUpdateService + IEnableService,不要再包一层无约束价值的壳接口 getManager()一类桥接方法统一放在接口最上面;响应式默认方法继续通过ServiceSupport.requireManager(...)在链内获取下游Manager- 阻塞式
Service默认方法统一使用try/catch + ServiceSupport.logError(log, "...", e)记录异常后原样抛出;不要再手写重复的if (log.isErrorEnabled()) { log.error(...) }模板 - reactive
Service默认方法统一使用Mono.defer(...)/Flux.defer(...)装配调用链,把日志、requireManager(...)、组合查询、上下文读取都收进订阅期执行 - reactive
Service异常日志统一挂在链尾doOnError(...),不要再生成链外try/catch、同步throw,也不要额外包一层等价的onErrorMap(...)/Mono.error(...) - create / update / delete / status / account 一类副作用型 reactive 契约,若阻塞式语义为
void,则统一定义为Mono<Void>;不要在Service扩展契约层暴露影响行数 - 查询型 reactive
Service只做必要的响应式封装,不额外引入与阻塞式不一致的空值分支、泛型边界或日志命名 - 遗留阻塞式别名接口完成下游切换后删除;新增或改造模块统一直接依赖真实能力接口,不再引用过期别名
阻塞式 Manager / ORM 补充约定
- 阻塞式
Manager如果需要访问可选下游能力,例如OrderOperation、TreeOperation、额外的FindByIdOperation,统一优先通过OperationSupport.getOperation(Class<T>)/requireOperation(Class<T>)获取 typed operation;不要在默认方法里反复散落instanceof + 强转 CreateManager/UpdateManager这一类抽象在处理排序、树路径、父节点查询等扩展能力时,统一沿上述 typed helper 收口,保持与 reactive 侧一致的能力解析方式- 具体持久化
Manager的方法布局固定按能力分组:先放实体元信息方法,例如 JPAgetEntityPath()、JDBCgetEntityClass();再放getOperation()/getSqlTablePath()等持久化能力入口;随后放getCreateEntityConverter()、getUpdateEntityConverter()、getEntityVoConverter();再放 save / update 生命周期扩展、delete / recover 生命周期扩展,最后放 search 相关方法,例如getKeySearchPath()、getKeySearchColumns()、buildDefaultOrderBy()、appendWhereOfQuery()、自定义 search / find 方法。R2DBC / MyBatis-FlexManager只保留getOperation()这一个持久化入口,不再实现getEntityClass();MyBatis-Flex 也不再实现getMapper()。 - JDBC / JPA / MyBatis-Flex 的
searchDictionary(...)默认实现统一先执行handleQueryParameters(queryParameters),再强制dataStatus.enabled = true;不要先写死默认DataStatusFilter,否则基础查询参数可能把enabled=true覆盖掉 - MyBatis-Flex 字典默认标签列统一对齐为
label_value,读取实体属性时按labelValue解析;取不到标签时再 fallback 到id - JPA 与 MyBatis-Flex 是自定义查询的优先优化路径:JPA 优先使用框架约定查询 / QueryDSL,MyBatis-Flex 优先使用
QueryWrapper+mybatis-flex-processor生成的entity.table.*TableDef。需要生成 TableDef 的*-mybatis-flex模块必须像 JPA 模块配置 QueryDSL APT 一样独立接入mybatis-flex-processor;不要因为缺少生成类而退回手写@Select或在业务代码里散落表名、列名字符串。 - JDBC / R2DBC 暂不作为查询 DSL 改造主路径;当前继续保留 core 支撑层和集中 SQL 实现,只做必要维护。不要为了追求和 JPA / MyBatis-Flex 的写法一致,主动引入 Spring Data
Criteria、jOOQ 或 Spring Data JDBC 的 MyBatis 集成。 - R2DBC 查询条件和关键字查询列统一通过
getQualifiedColumn("column_name")构建;不需要 join 时不要覆盖buildFrom()、buildSelect()、getDefaultTableAlias(),需要 join 时主表列仍通过getQualifiedColumn(...)引用。 - JDBC manager 只要需要组装 Querydsl SQL 搜索、排序、过滤或 join,就必须使用模块内手写
querydsl/QXxx extends QuerydslSqlTable表路径,并覆盖getSqlTablePath()返回该静态单例;不要在 manager 中继续用列名字符串或临时stringColumn(...)拼字段。
多对多关系处理
多对多关系默认按“写关系、查已分配对象、认证/鉴权轻量查询”三类场景拆开处理,不再把所有逻辑堆在 Service 或关系 Manager 里。
- 分配关系的
Service只负责业务入口、事务、主对象状态校验和入参归一化,例如使用IdSetUtil.normalizeIdSet(...)、授权模式 normalize;不要为了保存关系先批量加载被分配对象再逐个过滤。 - 获取对象和校验对象有效性的职责下沉到对应
Manager或数据库查询中;前端需要展示“已分配对象列表”时,由主对象Manager直接通过关系表 join 目标对象表,并直接返回 VO。 - 关系
Manager只保留确实被业务使用的关系写入、删除和轻量 ID 查询;不要保留已经被 join 查询替代的“先查 ID 集合再让 Service 回查对象”的旧方法。 - 认证、鉴权、权限快照这类高频链路可以保留轻量 ID 查询,例如“主体 -> 角色 ID”“角色 -> 权限 ID”;这类方法服务于权限计算,不等同于前端分配列表查询。
- JPA 已经进入 ORM 实现时,自定义关系查询优先使用框架约定查询或 QueryDSL join,不写 native SQL;查询已分配对象时同时剔除逻辑删除数据,并按业务需要过滤启用状态。
- MyBatis-Flex 优先使用
QueryWrapper和生成的TableDef表达 join / where / orderBy;不要因为缺少 processor 生成类就退回散落的字符串 SQL。 - JDBC / R2DBC 当前保持集中 SQL 实现,只做必要维护;字段名、表名和过滤条件应集中在对应 manager / repository 方法内,不向
Service泄漏。 - 阻塞式与响应式两条线的方法命名、职责和过滤语义必须对齐;差异只体现在返回类型,例如
Collection<VO>与Flux<VO>。
按业务键创建或刷新
当业务对象天然具备稳定业务键,并且重复提交应该刷新旧数据而不是报重复错误时,例如监控节点注册、应用实例上报、外部系统同步,可在具体 Manager 上显式实现 CreateOrRefreshManager:
- 对外方法继续保留业务命名,例如
register(ao)、sync(ao)、importOne(ao);方法体直接调用save(ao) - 具体 Manager 只实现
findEntityByBusinessKey(ao)表达业务键查找,不手写重复的查旧值、拷贝 AO、更新实体、转 VO 流程 CreateOrRefreshManager会复用 core 创建与更新生命周期,统一处理租户校验、数据状态校验、重复校验、before/after update hook 和更新事件发布- 不新增
saveOrUpdate(...)或upsert(...)通用入口;这不是数据库 upsert,而是业务键 create-or-refresh - 如果 JDBC 或特殊数据栈需要显式按业务键更新,只覆盖
persistUpdatedEntity(beSave, oldEntity, ao),不要复制整套更新生命周期 - 仓储层按数据栈补齐业务键查询:JPA / JDBC 返回
Optional<Entity>,R2DBC 返回Mono<Entity>,MyBatis-Flex 可由 mapper 查询实体后在 Manager 包装Optional
Reactive Manager / Repository 补充约定
- reactive
Manager默认方法与Service保持同一口径:依赖解析、能力判断、上下文读取都收进订阅期执行,不保留链外同步throw OperationSupport.requireOperation(...)一类桥接入口统一返回Mono<T>;下游默认方法通过flatMap(...)/flatMapMany(...)在链内取operation- reactive
Manager不要再先同步取operation/support再拼装Mono/Flux,缺失依赖时应在订阅时发出错误信号 - reactive 查询默认方法如果需要补
QueryParameters默认值,例如回收站查询补deleted=true、字典查询补enabled=true,也必须放进Mono.defer(...)或链内准备步骤;不要在返回 Publisher 前同步改写入参 - R2DBC 自定义
RepositoryImpl中,entityClass、tableName、rowMapper等解析统一延迟到订阅期;优先复用getEntityClassMono()、getTableNameMono()或Mono.defer(...) - R2DBC 实现中不要生成
Mono.just(getEntityClass())、Mono.just(getTableName())、final Class<?> entityClass = getEntityClass()这类装配期求值代码;否则会把“不支持该操作”提前成同步异常 - R2DBC
Manager不手写getEntityClass();实体类型只在Repository/ operation 层声明,Manager通过getOperation().getEntityClass()走 core 默认实现 - reactive 链路对应测试统一按“创建 Publisher 不抛错,订阅时收到异常”编写;不要再用旧的同步
assertThrows(...)去断言装配期异常
Manager 方法顺序
具体 Manager 类的方法顺序必须稳定,便于对照阻塞式 / 响应式实现,也便于审查生命周期钩子是否落在正确位置。不要按写代码时的临时先后顺序随意插入方法。
接入标准 CreateManager / UpdateManager / FindByIdManager / SearchManager / LogicDeleteManager / PhysicsDeleteManager 等能力的实体 Manager,类体推荐顺序如下:
static final常量。- 构造注入的
final依赖字段,先 converter,再 repository / mapper / operation,再其它 manager。 - 框架依赖入口:
getOperation()、getEntityClass()、getSqlTablePath()、getTableDef()等。 - Converter 入口:
getCreateEntityConverter()、getUpdateEntityConverter()、getEntityVoConverter()。 - 业务键与查询辅助:
findEntityByBusinessKey(...)、getKeySearchPath()、appendWhereOfQuery(...)、appendWhereOfSearch(...)等。 - 创建 / 更新生命周期:
validate(...)、beforeSave(...)、afterSave(...)、beforeUpdate(...)、persistUpdatedEntity(...)、afterUpdate(...)。 - 状态、排序、发布、默认、主数据等标准能力覆盖方法。
- 删除与恢复生命周期:
checkCustomCanDelete(...)、beforeLogicDelete(...)、afterLogicDelete(...)、afterRecovery(...)、beforePhysicsDelete(...)、afterPhysicsDelete(...)。 - 当前业务接口额外暴露的 public / protected 领域方法。
- 当前类私有 helper 方法,按调用链路或业务主题分组;不要把 private helper 插到标准生命周期方法中间。
纯领域编排型 Manager 如果不接入标准数据能力,例如运行时生成、分配、同步器一类,方法顺序按“公开业务入口在前,私有 helper 在后”组织:
- 常量。
- 依赖字段。
@Override的业务接口方法,按接口声明和业务流程顺序排列。- 支撑这些入口的 private / protected helper,优先按首次调用顺序排列。
阻塞式和响应式两个 Manager 实现同名能力时,方法顺序、命名、Javadoc 摘要和日志语义应尽量一一对应;响应式只保留返回类型、链式写法和订阅期语义差异。
新增自定义业务时的推荐步骤
- 先确认这个需求是否仍然属于现有
Controller -> Service -> Manager -> Repository/EntityManager主路径中的某一层扩展,而不是额外增加新层。 - 在
Controller层定义对外入口,完成参数接收、参数校验和统一返回。 - 如果入口已经拿到当前会话对象,优先把
SecuritySession这类聚合上下文继续往后传,不再拆成多个基础字段。 - 在
Service层定义业务入口,作为事务边界承接一次完整业务用例。 - 在
Service内通过ServiceSupport.requireManager(...)获取所需Manager,按业务顺序组织调用。 - 将单类业务能力下沉到对应
Manager,例如状态切换规则、排序规则、删除前后扩展、单表业务校验。 - 将最终的数据访问落到
Repository/EntityManager,并沿当前数据栈选择 JPA、JDBC 或 MyBatis-Flex 的实现。 - 如果一个业务需要多张表协作,保持“一个业务
Service+ 多个职责清晰的Manager”的结构,不拆成多个Service横向互调。
分层职责判定
| 事项 | 推荐落层 | 说明 |
|---|---|---|
| 参数校验 | Controller |
入口参数、ID、请求体合法性校验优先放在 Web 入口;AO 字段校验由框架手动调度 Jakarta Validation,不在方法参数上加 @Valid |
| 事务编排 | Service |
一次业务用例的开始、提交、回滚边界由 Service 控制 |
| 跨表协作 | Service |
多表流程由一个 Service 组合多个 Manager 完成 |
| 实体规则 | Manager |
单类实体的创建、删除、排序、状态流转和局部约束落在 Manager;业务校验优先覆盖 validate(ao),不要为校验覆盖 save(ao) / update(ao);AO 已表达的字段级校验不要在 Manager 再写一遍 |
| 数据访问 | Repository/EntityManager |
查询、持久化、排序字段落库、批量更新等最终执行动作落在数据层 |
多表业务协作
一个业务如果同时操作多张表,默认结构是“一个业务 Service 负责事务与编排,再组合多个 Manager 完成落地”。
推荐顺序如下:
Controller只暴露业务入口。Service作为业务入口方法,开启事务并组织完整业务流程。Service通过ServiceSupport.requireManager(...)依次获取并调用多个Manager。- 每个
Manager只处理自己负责的一类业务能力或实体规则。 Repository/EntityManager只处理本实体或本查询动作的最终持久化。
这意味着跨表业务的主编排层是 Service,不是 Controller,也不是默认的大而全组合型 Manager。
多对多关系和删除级联虽然会触碰多张表,但不应该因此把职责混成一个“大而全 Manager”:
- 关系分配属于一个业务入口,由
Service控制事务并调用对应关系Manager覆盖保存。 - 已分配对象列表属于主对象的查询能力,由主对象
Manager通过 join 返回 VO。 - 删除主对象时必须清理的关系表、从表或强依赖数据,属于该实体的物理删除生命周期,由该实体
Manager在删除钩子中处理。 - 关系表、从表按主对象外键做
count/delete/findBy/exists时,具体查询和删除方法必须定义在关系表、从表自己的Repository/Mapper中,Manager只负责调用和业务判断。
推荐模式
推荐把一次业务写成“一个入口 Service + 多个职责稳定的 Manager”:
Controller负责收口 API 和参数。Service负责事务、步骤顺序和失败回滚。Manager A负责主实体处理。Manager B负责关联实体处理。Manager C负责状态切换、排序或删除后清理等扩展。
当 Service 需要调用 Manager 时,统一使用 ServiceSupport.requireManager(...) 作为桥接入口。这样可以保持与现有 ICreateService、ILogicDeleteService 等默认实现一致。
反模式与禁行规则
1. Service 互调
禁行规则:Service 不与 Service 互调。
原因是 Service 已经是事务与业务编排边界。如果一个 Service 再去调用另一个 Service,事务边界、日志入口和职责归属都会变得不清晰。
正确做法是由当前业务 Service 直接组合多个 Manager。
2. 在 Controller 中编排多表业务
禁行规则:不要在 Controller 中编排多表业务。
Controller 只负责参数校验、请求入口日志和统一返回,不应该把跨表协作、状态切换顺序、删除后清理这些流程塞进 Web 层。
3. 组合型 Manager 取代 Service 编排
不推荐把“大而全的组合型 Manager”作为默认主编排层。
如果某个 Manager 同时负责多个实体、多类流程和事务顺序,它实际上已经在扮演 Service 的角色,会破坏 Service -> Manager 的职责边界。
落地检查清单
- 自定义业务是否仍然走
Controller -> Service -> Manager -> Repository/EntityManager Controller是否只处理入口、校验和统一返回Service是否承担事务与编排,而不是横向调用其他Service- 多表业务是否通过组合多个 Manager 落地
Manager是否聚焦单类业务能力,而不是成为新的总控层- 多对多关系是否避免了 Service 先查 ID 再批量回查对象的旧模式
- 物理删除级联是否落在对应实体 Manager 的删除生命周期中,而不是散落在 Controller 或无关 Service 中
最后更新:2026-05-18
仍有疑问?按反馈清单整理复现信息 →