开发约定反模式清单
对照常见反例定位分层、上下文、数据访问与验证问题。
浏览全部手册
本页目录
文档来源与 Markdown 原文
权威正文:ineed-core/docs/project-development/convention-anti-patterns.md。网站按工作区快照同步,原文中的历史日期和验证范围保留。
开始编码前核对同版本源码;跨仓文件引用可在源码定位目录查找。
下载 Markdown 原文 ↓本文档列出基于 Ineed-Core 开发时经常出现的错误写法。关于分层职责边界、多表协作、Service/Manager 编排的正确做法,详见 custom-business-layering-guide.md。
会话上下文拆散传参
错误方式:调用方已经拿到 SecuritySession,却继续把 identityType、principalId、tenantKey 等字段拆散后逐层传递。
问题:
- 接口参数越来越多,语义反而更不完整
- 会话字段一旦扩展,调用链会发生连锁改签名
- 很容易出现某些字段传了、某些字段忘了传的半残调用
正确方式:围绕当前登录上下文工作的扩展接口,优先直接接收 SecuritySession 或等价聚合上下文 DTO。
把登录状态重新塞回 JWT
错误方式:为了让前端少查接口,把完整用户、租户、权限快照、完整 Session 对象或服务端过期策略重新写进 JWT payload。
问题:
- JWT 一旦泄漏,暴露的信息面会变大
- 用户权限、租户或会话状态变化后,旧 JWT 很容易与服务端真实状态不一致
- 强制下线和会话续期会被 token 自身内容干扰,最终形成两套登录状态来源
正确方式:JWT 只作为传输凭证,payload 只保留 sid 等最小会话标识。服务端通过 sid -> SecuritySession 从 Redis 读取权威登录状态;前端不要解析 token 驱动页面身份、租户或权限。
在线会话继续使用用户维度命名
错误方式:把在线会话管理继续命名为用户维度的类名、路径或资源标识,或者用 principalId、用户名、token 全串作为强制下线主键。
问题:
- 一个用户可能存在多个设备、浏览器或登录实例,在线对象本质是会话而不是用户
- 用用户维度强制下线会误伤同账号的其他会话
- 旧命名会让前端权限点、接口路径和 Redis 索引语义继续漂移
正确方式:在线会话统一使用 OnlineSessionVO、online-session、security.online-session,查询和强制下线以 sid 为会话标识。
同步与响应式扩展契约不分线
错误方式:为同一扩展点继续保留未分线的旧接口,或者把阻塞式与响应式方法混在同一个契约里。
问题:
- 同步与响应式语义会互相污染
webmvc与webflux的适配关系不清楚- 后续很容易出现一侧改了签名、另一侧还挂在旧接口上的漂移
正确方式:同一扩展点按阻塞式与响应式分开定义;阻塞式放 service/,响应式放 service/reactive/。
共享扩展契约落在实现模块
错误方式:只在 webmvc 或 webflux 实现模块中定义接口,再让其他模块反向依赖这些实现层包名。
问题:
- 共享契约无法被两条 Web 线稳定复用
- 发起方与实现方的依赖方向会变得混乱
- 实现模块一旦调整包名,跨模块调用会直接断裂
正确方式:跨模块复用的共享扩展契约优先放在发起方 core 共享层;webmvc / webflux 模块只保留各自适配实现。
响应式契约乱放包
错误方式:响应式接口或响应式适配器继续散落在 service 根包或任意自定义包名下。
问题:
- 同一模块中同步与响应式目录结构不稳定
- 搜索和维护响应式扩展点时难以快速定位
- 后续修改很容易沿着旧样式继续生成错误包结构
正确方式:当同一代码模块中同时存在同步与响应式 service 契约时,响应式统一放在 service/reactive/。
响应式动作型方法暴露影响行数
错误方式:把创建、编辑、删除、状态切换、账户密码更新、登录成功回写这类副作用型 reactive 方法定义成 Mono<Integer>,并把影响行数继续向 service / manager / Web 扩展契约上传递。
问题:
- 会把底层持久化细节泄漏到上层业务契约
- 阻塞式与响应式同一能力的签名语义会发生漂移
- 上层调用者会错误依赖“影响行数”而不是动作是否完成
正确方式:与阻塞式语义保持一致。阻塞式为 void 的动作型契约,在响应式侧统一定义为 Mono<Void>;只有查询、统计一类需要返回结果的方法才返回具体值,例如 Mono<Integer>。
对象转换层混用 Mapper 命名
错误方式:继续把对象转换实现生成为 mapstruct/ 包、XxxMapper 类,或者把 MyBatis-Flex 持久化 mapper/ 与对象转换实现混在同一个包里。
问题:
mapper在当前约定中要留给 MyBatis 持久化层- 同一聚合里会同时出现两个语义完全不同的
XxxMapper - 后续修改很容易继续沿着旧命名扩散错误骨架
正确方式:对象转换实现统一放在各实现模块的 converter/ 包下,类名统一使用 XxxConverter;MyBatis-Flex 的 mapper/ 只保留持久化接口。
新代码继续生成 pojo 包
错误方式:新建模块或新增对象时,继续参考历史模块生成 pojo/ao、pojo/query、pojo/vo。
问题:
- 新模块会和当前
core/contract/*约定分裂 - 前端、自动化输入和需求模板会继续出现两套对象命名
- 后续迁移时容易在同一模块内混用
contract/*与pojo/*
正确方式:新模块和新增代码统一使用 contract/ao、contract/query、contract/vo、contract/dto。如果目标历史模块已经大量使用 pojo/*,先明确迁移策略,不默认继续生成。
压缩 Javadoc 和字段声明
错误方式:生成 /** 订单编号。 */ 这类单行 Javadoc,或者把 @Schema(...) protected String orderNo; 挤在同一行。
问题:
- 与当前源码格式约定不一致
- IDE 格式化后仍难以稳定恢复成统一风格
- 后续修改会沿着压缩格式继续扩散
正确方式:统一遵守 code-style-guide.md,类级、字段级、方法级 Javadoc 都使用标准多行格式;@Schema(...) 独占一行,字段声明另起一行。
普通 Converter 重复声明父接口方法
错误方式:每个 XxxConverter 都机械重写 ao2Entity(...)、entityCopy(...)、appleAo(...)、entity2VO(...),但方法上没有 @Mapping、@BeanMapping 等配置,也没有自定义逻辑。
问题:
AoEntityConverter与EntityVoConverter已经声明了这些抽象方法,MapStruct 会为继承方法生成实现- 重复声明会扩大代码量,后续批量修改更容易漏改或产生无用 import
- 后续生成或修改会误以为这些方法是每个 Converter 的必备模板,从而继续扩散冗余代码
正确方式:普通 Converter 只保留 @Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE)、继承关系和必要的 INSTANCE。只有需要方法级 @Mapping、@Mappings、@BeanMapping、@InheritConfiguration、@InheritInverseConfiguration 等配置时,才显式声明对应方法;带派生字段或字典标签的 entity2VO(...) 应保留。
把共享认证能力重新塞回 AccountManager
错误方式:为业务主体重新生成 IAccountAuthenticationManager,或者把共享认证逻辑重新收口到通用 AccountManager。
问题:
- 共享认证能力会与主体模块自身职责重新耦合
- 阻塞式与响应式两条线容易再次出现不一致
- 新模块会继续复制密码治理、锁定规则和权限快照处理
正确方式:统一复用 IAccountAuthenticationService / IReactiveAccountAuthenticationService 与 ISecurityAuthoritiesService / IReactiveSecurityAuthoritiesService;主体模块自己的 Manager 只保留 loadByUsername(...)、accountToSecurityUser(...)、主体特有查询和模块私有异常映射。
错误钩子命名
错误方式:直接在代码中凭空实现 afterDelete,或者让代码生成器生成不存在的删除后钩子名。
问题:
- 当前框架真实钩子名不是
afterDelete - 生成的代码会偏离
LogicDeleteManager现有接口
正确方式:把业务上的 afterDelete 语义明确映射到 afterLogicDelete;恢复后的补偿逻辑映射到 afterRecovery。
自造响应包装
错误方式:在业务层手工创建另一套响应对象,或者绕开统一返回体系直接拼装 ResponseResults。
问题:
- 会破坏统一异常映射
- 会让返回结构和错误码风格不一致
正确方式:成功结果在 Controller 侧继续通过 ResultBuild 返回,异常交给 GlobalExceptionHandler 处理。
权限清单字段语义混乱
错误方式:把 api 的 permissionUrl 写成前端路由,把 menu/page 的 component 写成后端路径,给 button 填 permissionUrl/component/icon,或者为了分组虚构一个 *.menu 节点再把多个独立入口生成为 page。
问题:
- 前端动态路由会找不到真实组件
- 后端接口授权和前端按钮显隐无法稳定对应
- 授权页面读取权限树时会出现目录、页面、接口和按钮混杂
正确方式:权限类型统一使用 catalog / menu / page / api / button。catalog 是目录或分组,不写 permissionUrl/component;menu 是独立菜单入口,写前端访问路径和真实组件路径;page 是不独立出现在菜单的隐藏/从属页面,常见于详情、编辑、配置、主从资源子列表页,通常伴随 ISearchOfMasterIdController / search-of-master-id;api 写后端接口路径;button 只表达页面可见操作,不写 permissionUrl/component/icon。
日志缺少关键追踪信息
错误方式:只打印“操作失败”之类的空日志,不带动作名、关键入参和异常对象,也不复用请求上下文。
问题:
- 无法关联到具体接口和具体请求
- 排查异常时缺少
requestUrl、requestMethod、signature、applicationKey等线索
正确方式:日志至少带上动作名、关键入参、异常对象,并优先复用 WebMvcOperateInfoFilter / WebFluxOperateInfoFilter 提供的追踪上下文。
继续使用过期 API
错误方式:新增或修改代码时继续使用 JDK 或第三方依赖中已经标记为 @Deprecated 的 API,例如在 commons-lang3 中继续使用 StringUtils.equals(...)、StringUtils.equalsIgnoreCase(...)、StringUtils.contains(...) 或 StringUtils.containsIgnoreCase(...) 做字符串比较和查找。
问题:
- 过期 API 可能在后续依赖升级中被移除,导致模块迭代时集中编译失败
- 编译器和 IDE 的 deprecation warning 会被噪声淹没,真正需要处理的升级风险不容易被发现
- 同一代码库中混用旧 API 和替代 API,会让生成器、人工修改和代码审查继续沿着旧写法扩散
正确方式:不使用过期 API,也不要用 @SuppressWarnings("deprecation") 掩盖问题。commons-lang3 字符串大小写敏感比较和查找使用 Strings.CS.equals(...)、Strings.CS.contains(...),大小写不敏感比较和查找使用 Strings.CI.equals(...)、Strings.CI.contains(...);空白判断和默认值处理继续按未过期的 StringUtils.isBlank(...)、StringUtils.defaultIfBlank(...) 等方法使用。
Service 回查多对多对象
错误方式:查询已分配对象列表时,Service 先调用关系 Manager 查 ID 集合,再调用另一个 Manager / Service 批量回查对象;保存关系时,Service 又先加载全部目标对象,再逐个过滤后保存关系。
问题:
Service会混入持久化细节和对象过滤规则- 关系读取链路变成多次查询,代码绕、性能差,也容易漏掉逻辑删除或启用状态过滤
- 后续前端分配列表、认证权限计算、关系写入三类场景会挤在同一批方法里,导致关系
Manager越来越臃肿 Service容易为了拿对象而横向调用其它Service,破坏事务和职责边界
正确方式:保存关系时,Service 只做主对象校验、状态判断和 ID / 授权模式归一化,然后调用关系 Manager 覆盖保存;查询已分配对象列表时,由主对象 Manager 通过关系表 join 目标对象表并直接返回 VO。认证/鉴权链路如需高频计算,可以保留轻量 ID 查询,但不要把它复用成前端列表查询。
JPA 关系查询手写 SQL
错误方式:JPA 实现中为了多对多列表或级联查询直接写 native SQL / 拼接 SQL。
问题:
- 字段改名、实体映射调整后,SQL 不会随 ORM 模型一起演进
- 逻辑删除、启用状态、租户等通用过滤容易在手写 SQL 中漏掉
- JPA、MyBatis-Flex、JDBC、R2DBC 的实现边界会混乱
正确方式:JPA 自定义关系查询优先使用框架约定查询或 QueryDSL join;MyBatis-Flex 优先使用 QueryWrapper 和 processor 生成的 TableDef;JDBC / R2DBC 暂时保持集中 SQL 实现,不向上层泄漏 SQL 细节。
级联删除散落在业务入口
错误方式:在 Controller 或任意业务 Service 中手工清理关系表、从表、后代节点,然后再调用删除方法。
问题:
- 删除规则会随着入口数量增加而重复
- 直接调用
physicsDelete/forceDelete的其它入口可能绕过清理逻辑 - 级联清理和实体生命周期脱节,后续很难判断哪些关系必须随实体物理删除而删除
正确方式:当前实体被物理删除时必须同步清理的关系表、从表和后代数据,落在该实体 Manager 的 beforePhysicsDelete;物理删除成功后才有意义的外部副作用,落在 afterPhysicsDelete。forceDelete 只表示不做状态判断直接物理删除,不额外生成 beforeForceDelete / afterForceDelete。
子表 count/delete 写在 Manager
错误方式:在 Manager 中为了判断是否存在引用数据或做物理级联删除,手写通用 helper,例如:
- JDBC / R2DBC:
countActiveValue(tableName, columnName, columnValue)、deleteValue(tableName, columnName, columnValue),内部拼 SQL - JPA:在
Manager中为简单外键计数或删除手写 QueryDSLselect count/delete - MyBatis-Flex:在
Manager中写countActiveValue(BaseMapper<?> mapper, String columnName, String columnValue),通过QueryMethods.column(columnName)拼通用条件
问题:
Manager会泄漏表名、列名、SQL/DSL 和 Mapper 细节,职责从业务生命周期滑到持久化实现- 相同规则在 JDBC、R2DBC、JPA、MyBatis-Flex 中容易写出四套不同风格,后续维护难以对齐
- 子表字段改名、外键字段调整或持久化框架升级时,需要到业务层搜 SQL/列名,容易漏改
- MyBatis-Flex 使用
BaseMapper<?> + columnName会丢掉具体 Mapper 的语义,代码审查时看不出到底在操作哪张表
正确方式:把按外键 count/delete/findBy/exists 的动作放到对应子表、关系表、从表自己的 Repository / Mapper。
- JDBC / JPA 使用 Spring Data 派生方法,例如
countByCategoryIdAndDeleted(...)、deleteByCategoryId(...) - R2DBC 使用响应式派生方法,例如
Mono<Long> countByCategoryIdAndDeleted(...)、Mono<Void> deleteByCategoryId(...) - MyBatis-Flex 在具体
Mapper中定义明确默认方法,内部用QueryWrapper或生成的TableDef表达条件 Manager只保留业务判断、生命周期落点和异常语义,例如在checkCanDelete/beforePhysicsDelete中调用这些方法
最后更新:2026-05-18
仍有疑问?按反馈清单整理复现信息 →