HelloWorld 充血模型教程
充血模型把业务逻辑和数据聚合到同一个领域对象中,使对象不仅持有状态还承担行为。用一个简单的 HelloWorld 实例,可以展示实体、值对象、领域服务和仓储如何分工协作:实体封装不变性与行为,值对象承载值语义,领域服务处理不归单一实体的业务,仓储负责持久化。这样设计利于测试、演化与表达业务意图。

先说结论(为什么选择充血模型)
如果你想让代码更能表达业务意图、减少”服务操控数据”的反复逻辑、提升可测试性和可维护性,充血模型(Rich Domain Model)通常是更好的选择。它把业务规则自然地放回到领域对象内,从而把“做什么”和“什么”绑在一起,避免散落在各处的程序化逻辑。
什么是充血模型?为什么会有“贫血模型”这个说法?
先把概念理清楚:在面向对象设计里,模型可以分为两种极端:
- 贫血模型(Anemic Domain Model):领域对象主要是数据结构(getter/setter),业务逻辑集中在服务层或过程式代码里。
- 充血模型(Rich Domain Model):领域对象同时包含数据和行为,业务规则由对象方法来实现。
贫血模型看起来简单(尤其对 CRUD 应用),但弊端是逻辑分散、对象只是数据容器,导致测试和推理成本上升。充血模型强调把行为和责任放回实体和值对象中,使代码更具表达力。
用 HelloWorld 演示充血模型的最小要素
我们用一个非常小的例子来讲清楚:假设要实现一个“问候”子域,核心需求是根据不同条件生成问候信息并记录发送历史。这个场景足够小,又包含了实体、值对象、领域服务和仓储的职责划分。
角色划分(概念层面)
- Greeting(实体):表示一次问候事件,包含内容、接收者、状态等,并能执行“发送”行为。
- Recipient(值对象):接收者,带有姓名、语言偏好等,值语义,不可变。
- GreetingService(领域服务):处理跨实体或不适合放在某个单一实体内的业务,比如根据外部规则生成消息模板。
- GreetingRepository(仓储接口):聚合持久化和检索,隐藏存储细节。
逐步实现(以 Java 为例,易于理解且常见)
这里用伪真代码(接近实际 Java)来说明关键点,注意要把业务逻辑放在实体或值对象方法里,服务只做协调。
1. 定义值对象:Recipient
public final class Recipient {
private final String name;
private final String language; // "en", "zh", "es" 等
public Recipient(String name, String language) {
if (name == null || name.isEmpty()) throw new IllegalArgumentException("name required");
if (language == null || language.isEmpty()) throw new IllegalArgumentException("language required");
this.name = name;
this.language = language;
}
public String getName() { return name; }
public String getLanguage() { return language; }
// 值对象通常需要 equals/hashCode
@Override
public boolean equals(Object o) { /* standard */ }
@Override
public int hashCode() { /* standard */ }
}
2. 定义实体:Greeting(充血点在哪里)
实体不仅存数据,还包含行为,比如 validate、formatMessage、send 等。
public class Greeting {
private final UUID id;
private final Recipient recipient;
private GreetingStatus status;
private final LocalDateTime createdAt;
private String message; // 可能是懒加载或通过生成函数得到
public Greeting(Recipient recipient) {
this.id = UUID.randomUUID();
this.recipient = Objects.requireNonNull(recipient);
this.createdAt = LocalDateTime.now();
this.status = GreetingStatus.PENDING;
}
// 业务行为:生成消息(把业务知识放在实体或值对象中)
public void generateMessage(MessageTemplateEngine engine) {
if (status != GreetingStatus.PENDING) throw new IllegalStateException("already processed");
this.message = engine.render(recipient);
}
// 业务行为:发送问候
public void send(MessageSender sender) {
if (message == null) throw new IllegalStateException("message not generated");
sender.sendTo(recipient, message);
this.status = GreetingStatus.SENT;
}
// 领域方法,封装判断
public boolean isSent() { return this.status == GreetingStatus.SENT; }
// getters...
}
3. 领域服务:协调复杂规则
当一个行为需要跨多个实体或不属于单一实体职责时,用领域服务。
public class GreetingOrchestrator {
private final GreetingRepository repository;
private final MessageTemplateEngine engine;
private final MessageSender sender;
public GreetingOrchestrator(GreetingRepository repo, MessageTemplateEngine engine, MessageSender sender) {
this.repository = repo;
this.engine = engine;
this.sender = sender;
}
public Greeting createAndSendGreeting(Recipient r) {
Greeting g = new Greeting(r);
g.generateMessage(engine);
g.send(sender);
repository.save(g);
return g;
}
}
4. 仓储接口(便于测试与替换实现)
public interface GreetingRepository {
void save(Greeting greeting);
Optional findById(UUID id);
List findPendingBefore(LocalDateTime time);
}
为什么把 generateMessage 放到实体里,而不是直接在服务中拼字符串?
主要基于几个原因:
- 表达力更强:实体的方法告诉阅读者“Greeting 能生成它自己的消息”,这是业务事实。
- 封装一致性:实体内部可以保证前置条件,比如只在 PENDING 状态生成消息。
- 可测试性:可以对 Greeting 的 generateMessage 单元测试,而不需要搭建完整的服务或数据库。
常见争议与折中方案
有人会说:“把所有逻辑都放对象里会让对象臃肿,难以维护。”这点是合理的,实践中需要平衡:
- 对于与多个实体有关联或跨边界的复杂逻辑,使用领域服务。
- 对于纯粹的表示或 CRUD,简单 DTO/数据结构足矣。
- 不要把基础设施代码(比如发送邮件的细节、ORM 注解)污染领域对象——使用接口和适配器模式隔离。
设计要点清单(做充血模型时务必注意)
- 聚合边界:确定聚合根(Aggregate Root),只通过聚合根修改内部实体。
- 领域语言:使用 Ubiquitous Language,让类名、方法名、异常都反映业务概念。
- 不可变与值对象:值对象应不可变,方便比较与缓存。
- 避免基础设施泄露:领域模型不应直接依赖 ORM 或外部服务实现。
- 小粒度方法:把行为拆成语义明确的方法,而非一大堆条件分支。
持久化与事务:怎么保存充血对象?
通常把领域对象映射到表结构(例如 JPA),但建议:
- 仓储接口在领域层定义,具体实现放在基础设施层。
- 在事务边界内操作聚合根(一个聚合一个事务理想),避免跨多个聚合做长事务。
- 如果需要事件溯源或最终一致性,考虑发布领域事件并用事件处理器异步更新外部系统。
简单的持久化表设计(示例)
| 表名 | greetings |
| 字段 | id, recipient_name, recipient_language, message, status, created_at |
| 索引建议 | status、created_at(用于查找待处理项) |
测试策略:如何验证充血模型的正确性
测试要覆盖行为,不只是状态:
- 对实体做单元测试,验证方法在不同状态下的行为(例如 generateMessage 在非 PENDING 时抛异常)。
- 对领域服务做集成测试,模拟仓储与外部发送器(可以用内存存储或 mock)。
- 对仓储实现做独立测试(集成测试),确保映射与事务正确工作。
示例测试场景
- 当 Recipient 缺失语言偏好时,构造失败(值对象校验)。
- Greeting.generateMessage 后 message 非空且包含 recipient 名称。
- Greeting.send 在 message 未生成时抛异常,在成功后 status 变为 SENT。
- GreetingOrchestrator.createAndSendGreeting 保存并返回 SENT 的 Greeting。
常见反模式(别踩这些坑)
- 把持久化细节(例如 JDBC 操作)直接放在实体方法里。
- 让实体过于庞大,承担 UI、验证、序列化等杂项职责。
- 用大量 setter 打破不可变性,导致状态在多个位置被修改,很难追踪。
扩展:当系统变复杂时的演进思路
一开始可以用简单的充血模型实现需求,随着复杂度上来,可以做这些演进:
- 引入领域事件(Domain Events),把副作用异步化。
- 实现 CQRS:读模型与写模型分离,读侧用于高性能查询,写侧保持领域完整性。
- 使用事件溯源(Event Sourcing)在历史层面保留更丰富的行为轨迹。
对比表:贫血模型 vs 充血模型(简明)
| 维度 | 贫血模型 | 充血模型 |
| 职责分配 | 数据对象 + 服务层承载业务 | 对象同时包含数据和行为 |
| 可读性 | 业务逻辑分散,穿透成本高 | 业务意图更明确,方法名表达语义 |
| 测试 | 服务测试多,实体测试少 | 实体易于单元测试,服务做协调 |
| 复杂性管理 | 适合简单 CRUD | 更适合复杂业务与演化 |
实践小贴士(让我写代码时常用的几个习惯)
- 先写测试:对行为驱动测试可以让领域模型更清晰。
- 小方法优先:把较大行为拆成语义明确的小方法,便于复用与测试。
- 界限清晰:领域层只用领域概念,基础设施通过接口注入。
- 用名字说话:类与方法名用业务语言,读代码如读需求。
参考书目(可进一步阅读)
- Eric Evans,《Domain-Driven Design》
- Vaughn Vernon,《Implementing Domain-Driven Design》
- Martin Fowler,有关 Anemic Domain Model 的文章与讨论
最后一点随想(边想边写的那种结尾)
写到这里,我其实又想起很多真实项目经历:初期用贫血模型快,但随着业务增长,你会不停遇到“为什么这里会这样”的问题。把行为放回到领域对象里并不是万灵药,但它确实把业务意图放到了代码的中心,长期来看能节省很多沟通成本。试着在下一个小模块里,先把几个关键业务动作放进实体,做几次简单的单元测试,你会慢慢看到差别。