前言
很多 Java 工程师都有一个隐秘的习惯: 拿到需求,第一反应不是"这个业务对象有什么行为",而是打开数据库客户端,先把表建了。
表建好了,实体类用工具一键生成(Lombok 加上 @Data),Service 层再把 CRUD 一铺,完事。
这种开发模式爽不爽?爽,尤其是赶进度的时候。 但这种爽是透支未来的。几年下来,你可能会发现自己陷入了一个怪圈: 明明用的是面向对象的语言,写出来的代码却全是过程式的逻辑。
写了多年代码之后,都会有一个隐约的感觉:
"我好像在用 Java,但又怎么用到'面向对象'。"
"不先设计数据库表,我代码该怎么写?"
这个问题本身,其实已经说明了一件事: 我们习惯的是"围绕数据写代码",而不是"围绕对象写代码"。
这篇文章想聊的,并不是什么高深的理论,而是一个很现实的问题: 我们是不是在用一门面向对象的语言,却一直在用过程式的方式做业务开发?
那个越写越厚的 Service,就是罪证
在典型的 Spring Boot 项目里,我们最熟悉的"三板斧"是:Controller → Service → Dao。
一开始大家都相安无事。但随着业务迭代,你有没有发现 Service 层开始变得畸形?
- 一个
OrderService动辄两三千行。 - 所有的业务规则(状态判断、权限校验、数据计算)都堆在这个类里。
- 而对应的
Order实体类,除了那一堆get/set方法,干净得像一张白纸。
Martin Fowler 早在十好几年前就给这种现象起过名字,叫**"贫血模型"(Anemic Domain Model)**。
说白了,我们把对象当成了**"数据垃圾桶"**,而把灵魂全部抽离到了 Service 里。Service 像个操碎了心的保姆,事无巨细地去掏对象里的数据,算完再塞回去;而对象本身,像个没有智商的木偶。
这不是面向对象,这是披着 Java 外衣的面向过程。
把行为还给对象,让自己负责自己
光说不练假把式。我们来看一个常见的场景:修改订单收货地址。
常见的"过程式"写法
这段代码你一定很眼熟。它的问题不在于逻辑错误,而在于逻辑的归属权错了。
java
public class OrderService {
@Transactional
public void updateAddress(Long orderId, String newAddress) {
Order order = orderMapper.selectById(orderId);
// 痛点在这里:Service 手伸得太长了
// 它需要了解订单的所有内部状态细节
if (order.getStatus().equals(OrderStatus.WAIT_PAY) ||
order.getStatus().equals(OrderStatus.WAIT_DELIVER)) {
order.setAddress(newAddress);
order.setUpdateTime(LocalDateTime.now());
orderMapper.updateById(order);
} else {
throw new BusinessException("当前状态不支持修改地址");
}
}
}
这种写法的隐患是: 如果明天"取消订单"的逻辑里也要判断状态,你是不是得把 if (status == ...) 这一坨代码再复制粘贴一遍?如果状态规则变了,你得满世界找这些散落的 if。
"面向对象"写法
面向对象有一个核心原则:Tell, Don't Ask(要命令它,不要询问它)。
别问对象"你是什么状态",然后你替它做决定;而是直接告诉对象"我要改地址",让它自己判断能不能改。
java
// 这是一个有血有肉的领域对象,不是单纯的数据库映射
public class Order {
// 状态和数据依然在对象内部
private Integer status;
private String address;
private LocalDateTime updateTime;
// 行为:对象自己管理自己的状态流转
public void changeAddress(String newAddress) {
if (!canChangeAddress()) {
throw new BusinessException("订单已锁定,无法修改地址");
}
this.address = newAddress;
this.updateTime = LocalDateTime.now();
}
// 规则:什么是"可修改"的逻辑,内聚在对象内部
private boolean canChangeAddress() {
return Objects.equals(status, OrderStatus.WAIT_PAY) ||
Objects.equals(status, OrderStatus.WAIT_DELIVER);
}
}
改造后的 Service 变得极其简洁:
java
public class OrderService {
public void updateAddress(Long orderId, String newAddress) {
Order order = orderRepository.findById(orderId);
// Service 变得极度清爽,只负责协调
order.changeAddress(newAddress);
orderRepository.save(order);
}
}
你看,Service 从"逻辑计算者"变成了"流程编排者"。代码的可读性瞬间提升了一个档次:order.changeAddress(...),代码本身就是文档。
这种变化看起来很小,但带来的好处非常实际:
- 规则内聚:修改逻辑只在一处,不会散落
- 可读性提升:业务意图更加直白
- 维护简单:改需求时,不用到处翻 Service
别让 String 和 Integer 裸奔
很多老系统的代码里,充斥着这种"基础类型依赖症"(Primitive Obsession)。
看看这个入参: public void register(String name, String phone, String email, Integer roleType)
这些 String 和 Integer 是没有"防守能力"的。
-
手机号格式对吗?
-
邮箱是不是空的?
-
角色类型是不是越界了?
如果不封装,你就要在 Service 的开头写上十几行的 StringUtils.isBlank 和正则校验。一旦漏写一个,脏数据就进数据库了。
尝试用"值对象"(Value Object)
java
public class PhoneNumber {
private final String number;
public PhoneNumber(String number) {
if (!isValid(number)) {
throw new IllegalArgumentException("无效的手机号格式");
}
this.number = number;
}
// ... getter & logic
}
当你把入参改成 register(String name, PhoneNumber phone, ...) 时,世界清静了。 你不需要再校验手机号格式,因为只要能 new 出来的 PhoneNumber 对象,一定是合法的。这才是强类型语言该有的安全感。
认清现实:一个"万能"对象往往什么都干不好
很多系统的另一个痛点在于:系统里有一个超级大的 User 类,或者一个超级大的 Order 类。
- 登录服务用它,需要账号密码。
- 交易服务用它,需要收货地址。
- 营销服务用它,需要会员等级。
最后这个类有了 100 多个字段,谁都不敢动,动一下不知道哪里会炸。
这是因为我们把"数据库的表"等同于了"业务的对象"。
在 DDD(领域驱动设计)里,这叫"限界上下文"。说人话就是:见人说人话,见鬼说鬼话。
- 在认证模块 ,你应该设计一个
Account对象(只含 id, username, password)。 - 在物流模块 ,你应该设计一个
Consignee(收货人)对象(只含 id, address, phone)。
它们底层可能对应同一张 user 表,但在代码层面,请把它们拆开。不要为了省那几个类的定义,让系统耦合得像一团乱麻。
别幻想"一步到位"
看到这,可能有人会说:"我的项目已经烂成这样了,现在改得动吗?"
不要试图搞"大爆炸"式的重构。 业务不会停,时间也不允许。
更现实的方式是:
- 新功能:尝试充血模型,把逻辑写进对象里。
- 修 Bug 或优化:如果这段逻辑刚好在 Service 里乱飞,顺手把它收拢到实体类里。
- 接受现实 :Service 层依然需要,它负责事务控制、仓储调用、第三方服务编排。但请记住,它不该负责业务状态的判断。
这是一个习惯的改变,而不是架构切换。
写在最后
很多工程师技术的瓶颈,不是不懂高并发或微服务,而是连最基本的代码分层和职责分配都没搞清楚。
这种"面向对象"的思维转变,一开始会很别扭。你可能会觉得:"这不就是把代码从 Service 挪到了 Entity 吗?有什么区别?"
相信我,等你维护一个历经多年、多人经手过的系统时,你会感谢这种"搬动"的价值。
好的代码,不是展现你用了多复杂的技巧,而是让后来者在读代码时,能清晰地看到业务的轮廓,而不是一堆混乱的数据操作。