在Java应用里,对象建模最容易出现的问题不是不会写类,而是把不同性质的职责塞进同一个类。实体类和行为类一旦混淆,轻则让代码变得难以测试,重则导致业务规则散落多处、事务边界模糊。要讲清两者的区别,需要从对象承担的角色入手,而不是简单看类名是否带有Entity或Service后缀。实体类用来表达业务领域中的连续身份,行为类用来组织动作和流程,这种差异会直接反映在字段设计、依赖方向和生命周期上。
一、实体类的核心是身份与生命周期,行为类关注职责与协作
实体类代表业务领域中具有连续身份的对象。哪怕它的属性发生变化,它仍然是同一个对象。比如订单的金额被修改、用户修改了手机号,订单号、用户ID不会因此改变。因此实体类必须具备稳定且唯一的身份标识,通常以主键字段存在。与此同时,实体类往往拥有可变状态,状态变化受到业务规则约束,并且实体通常需要持久化到数据库。判断一个类是否为实体,最重要的标准不是它有没有getter和setter,而是它是否回答“这是哪一个业务对象”这个问题。
行为类则完全不同。行为类的职责是执行操作、编排流程、连接外部系统。它通常没有业务身份,或者身份只用于技术追踪。例如订单支付服务、库存扣减处理器、参数校验器,它们的核心价值体现在方法行为上,而不是字段数据上。行为类可以有状态,但一般是短生命周期的临时状态,或者是为性能设计的缓存状态。常见的行为类命名包括Service、Handler、Processor、Validator、Converter等。行为类往往依赖其他接口来完成工作,而实体类通常不会依赖行为类。
public class Order {
public enum Status {
CREATED, PAID, SHIPPED, CANCELLED;
public boolean canMoveTo(Status target) {
return this != target;
}
}
private String orderId;
private long amount;
private Status status;
public Order(String orderId, long amount) {
this.orderId = orderId;
this.amount = amount;
this.status = Status.CREATED;
}
public String getOrderId() { return orderId; }
public long getAmount() { return amount; }
public Status getStatus() { return status; }
public void changeStatus(Status target) {
if (!this.status.canMoveTo(target)) {
throw new IllegalStateException("非法状态流转");
}
this.status = target;
}
}
上面的Order就是一个典型的实体类。它有唯一标识orderId,有可变状态status,并且把状态流转校验放进了自身方法中。这种设计让订单对象自己保护自己的状态一致性。对应的行为类则负责协调外部流程,例如支付服务会把订单对象从仓储中取出,调用支付网关,再触发订单状态变更。
public class OrderService {
private final OrderRepository orderRepository;
private final PaymentGateway paymentGateway;
public OrderService(OrderRepository orderRepository, PaymentGateway paymentGateway) {
this.orderRepository = orderRepository;
this.paymentGateway = paymentGateway;
}
public void pay(String orderId) {
Order order = orderRepository.findById(orderId);
if (order == null) {
throw new IllegalArgumentException("订单不存在");
}
paymentGateway.charge(order.getOrderId(), order.getAmount());
order.changeStatus(Order.Status.PAID);
orderRepository.save(order);
}
}
可以清楚看到,OrderService没有属于自己的业务身份,它的字段是仓储和支付网关这两个依赖,而不是订单本身的属性。它的生命周期通常由容器管理,或者随调用创建。实体和行为类的边界一旦这样划分,状态规则留在实体内部,外部协作由行为类完成,代码职责会自然清晰很多。
二、四个维度快速识别:字段、状态、依赖与生命周期
如果面对一个已有类,想判断它更像实体类还是行为类,可以从字段特征入手。实体类的字段大多描述业务属性,例如用户名、订单金额、商品库存,并且会有一个用于唯一标识的字段。行为类的字段则大多是对其他组件的引用,比如数据访问对象、消息发送器、配置参数。一个类如果没有任何业务身份字段,却有一堆服务依赖,它大概率是行为类。
第二个维度是状态变化。实体类的状态通常在业务过程中持续变化,而且状态变化往往对应业务事件,例如订单从待支付变成已支付。行为类方法调用结束后,一般不会保留调用方的业务状态。即使行为类内部维护了计数器或缓存,那也只是技术状态,不代表业务对象的状态。因此不能因为一个类有成员变量就认为它是实体。
第三个维度是依赖方向。行为类可以依赖实体类和外部端口,这是正常的分层结构。但实体类应当尽量避免依赖行为类,尤其是不能在实体中注入Repository或Service。如果实体开始依赖服务,说明原本属于实体的业务规则被错误地外移了。第四个维度是生命周期。实体可以跨多个用例存在,常常需要持久化;行为类则大多是一次性调用,或作为单例长期存在,但不会为每个业务操作创建独立身份。
- 字段特征:实体的字段回答它是什么,行为类的字段回答它需要什么。
- 状态变化:实体状态对应业务事件,行为类状态通常只是技术状态。
- 依赖方向:行为类依赖实体和外部接口,实体不依赖行为类。
- 生命周期:实体跨用例存在,行为类随调用创建或复用。
三、常见混用场景:贫血模型与上帝服务类
贫血模型是最常见的实体类失血问题。实体只剩下getter和setter,没有任何业务行为,所有规则被写进OrderService。这会导致一个严重问题:任何调用方都可以绕过服务,直接通过setter修改实体状态,从而破坏业务规则。比如订单状态可以被随意改成已取消,而不经过任何状态流转校验。实体类退化成数据容器后,代码看似分层清楚,但业务约束实际上失去了强制力。
与贫血模型对应的是上帝服务类。一个OrderService同时负责订单创建、支付、库存扣减、通知发送、统计报表,内部私有方法堆积,最终既承担了行为编排,又承担了实体状态管理,还可能夹杂多个领域逻辑。这样的类难以测试,也难以复用。合理的做法是让实体自己管理自身状态一致性,把跨聚合、外部系统调用留给行为类。比如订单状态变更属于订单实体自身逻辑,而发送邮件通知属于行为类职责。
富领域模型并非把所有逻辑塞进实体。实体只处理自身状态一致性,例如订单能否取消、金额是否允许修改。涉及多个聚合的流程编排、外部支付网关调用、消息推送等,仍然由行为类完成。实体不依赖服务,行为类可以依赖实体。这样既避免了失血实体,也不会出现过度膨胀的领域对象。
四、用约束和接口固化边界
设计阶段可以通过构造约束来防止实体退化为自由数据容器。实体构造函数应当要求必填字段,尤其不能允许无参构造后随意setter。对于可能变化的状态,可以提供专门的方法来修改,而不是暴露通用setter。例如订单金额如果只允许在创建时确定,就不要提供setAmount方法。行为类则通过接口暴露能力,实体不依赖行为接口。一个清晰的包结构也有帮助,例如domain.model存放实体,domain.service存放行为接口,application.service存放应用层编排。
边界清晰后,单元测试会变得容易很多。实体类可以直接测试状态规则,不需要启动Spring容器,也不需要模拟外部依赖。行为类测试则集中在协作流程上,通过模拟仓储和网关来验证调用顺序与参数。如果发现一个实体类测试需要大量mock服务,这通常意味着依赖方向已经反了。反过来,如果行为类测试必须准备一大堆实体状态,也可能说明部分业务规则被错误地放进了行为类。
最终目标不是追求严格的教条,而是让每个类的职责能够用一句话说清楚。实体类负责维护业务对象的身份和状态,行为类负责执行操作和协调依赖。把这条边界守住,Java对象建模在长期迭代中才能保持可维护性。