返回博客

Java 代码规范:写给「未来的自己」的约定

很多团队的「编码规范」都是一堆禁令清单——不要用魔数、不要写 catch (Exception e) {}——读完之后你只知道「不能这样」,却不明白「为什么」。这篇笔记想换一个切面:规范的本质是在向未来的维护者(通常是三个月后的自己)传达意图。

不可变优先,不是语法糖而是认知税

Java 17 引入了 record,很多人把它当成「少写 getter/setter 的语法糖」。但我认为它更核心的价值是强制不可变——一旦构造完成,数据就是事实,不会被某段代码悄悄改掉。

// DTO 用 record,意图清晰:这是一个只读快照
public record MarketDto(Long id, String name, MarketStatus status) {}

对比普通 POJO 加一堆 setter,record 的可读性优势不在于行数更少,而在于它消灭了「这个字段会不会在某个 service 里被改」的焦虑。写代码的时候减少了自己的认知负担,阅读代码的时候减少了追踪路径的成本。

final 字段同理。我见过一些代码库,几乎所有字段都是可变的,结果定位一个值是从哪儿来的要看十几个方法调用链。把「可变」当成默认、把「不可变」当成特例,这个假设本身就会产生维护成本。

Optional 与异常:表达「不存在」的两种语言

// 查询层返回 Optional,调用层决定怎么处理
Optional<Market> market = repository.findBySlug(slug);
 
return market
    .map(MarketResponse::from)
    .orElseThrow(() -> new MarketNotFoundException(slug));

Optional 适合表达「结果可能为空,请调用方自行决策」;域异常适合表达「这件事已经确定是错误,需要中止并上报」。两者不是竞争关系,是两种不同语义的信号。

常见的误用是用 Optional.get() 直接取值——这等于把「可能为空」的信号拿到,然后把它扔掉,回到 NPE 时代。另一种误用是对所有错误都 throw new RuntimeException("error")——调用方无法区分「市场不存在」和「数据库连接超时」,全变成了黑盒。

异常命名即文档

域异常类的名字本身就是文档。MarketNotFoundException 告诉读者:这是业务预期内的「找不到」,不是系统故障。在全局 ExceptionHandler 里可以分别映射到 404 和 500,不需要靠字符串匹配来区分。

Stream 的克制原则

Stream API 写起来很爽,但有一个实践原则我坚持:流水线保持短,嵌套超过两层就换回 for 循环

// 可读性好
List<String> names = markets.stream()
    .map(Market::name)
    .filter(Objects::nonNull)
    .toList();
 
// 超过两层嵌套时,显式循环反而更清晰

这不是性能问题,是可读性问题。复杂的嵌套 Stream 在你写完的当下逻辑密度很高,但一周后回来看,拆解步骤的显式循环通常更容易定位问题。「优雅」不是目标,「下次能快速理解」才是。

包结构与项目布局

controller/   ← HTTP 入口,只做参数校验和路由
service/      ← 业务逻辑
repository/   ← 数据访问
domain/       ← 实体、值对象
dto/          ← 进出边界的数据形状

这套分层的价值在于测试边界清晰。service 层单元测试 mock 掉 repository,不需要数据库;controller 层集成测试只验证 HTTP 契约,不关心 service 内部实现。层次混乱的代码库里,「写测试」的成本会急速上升,最终测试覆盖率成为摆设。

一句话心法

规范不是约束聪明人的枷锁,而是在告诉接手代码的人:「这里是有意这样设计的,不是偶然。」