我为什么不喜欢DDD
这不是一篇 DDD 教程,也不是什么最佳实践指南。这是我使用 DDD 后的一些真实感受和困惑。如果你正在考虑是否要在项目中使用 DDD,或者已经在用但感觉哪里不对劲,这篇文章可能会引起你的共鸣。
理由一:CQRS 分类让人头疼
CQRS 要求你把所有操作分成三类:Command(命令)、Query(查询)、Event(事件)。理论上很清晰,但实际写代码的时候,你会发现很多操作根本不知道该归到哪一类。
一个真实的例子
假设我们做了个服务调用系统,用户通过 API 调用第三方服务:
java
// 用户请求
POST /api/service-invoke-requests
{
"serviceId": "weather-api",
"inputData": {
"city": "Beijing"
}
}
// 期望的响应(同步返回)
{
"requestId": "req-123",
"status": "SUCCESS",
"outputData": {
"temperature": "25°C",
"weather": "Sunny"
}
}
现在问题来了:这个接口应该叫 ServiceInvokeCommand 还是 ServiceInvokeQuery?
按照 CQRS 的定义来判断:
它是 Command 吗?
- Command 应该改变系统状态,但不返回大量业务数据(最多只返回id、status)
- 但这个接口需要返回用户配置的服务的调用结果
- 不符合
那是 Query 吗?
- Query 应该是只读的、幂等的、轻量的
- 但这个接口调用了外部服务(可能发送短信、扣费等写操作)
- 也可能要等好几秒才有结果
- 还是不符合
那是 Event?
- Event 是对已发生事情的被动响应
- 但这明明是用户主动发起的操作
- 根本不是
结果就是:三个都不是!
这样的例子还有很多
| 操作类型 | 改变状态? | 返回数据? | 同步? | 符合哪个? |
|---|---|---|---|---|
| 用户注册 | ✅ | ❌(只返回ID) | ✅ | Command ✅ |
| 查询用户列表 | ❌ | ✅ | ✅ | Query ✅ |
| 调用第三方服务 | ✅ | ✅ | ✅ | ??? |
| 生成报表 | ❌ | ✅ | ❌(耗时长) | ??? |
| 批量导出数据 | ❌ | ✅ | ❌ | ??? |
| AI 生成内容 | ✅ | ✅ | ✅ | ??? |
我的感受
现实情况是:
- 很多操作既改变状态,又需要返回复杂的业务数据
- 不同的人对这些定义有不同的理解
- Code Review 的时候经常为了"这到底是 Command 还是 Query"争论半天
- 最后发现,花了这么多时间讨论,实际收益很小
与其这样纠结,不如都叫 Request 算了:
java
// 与其纠结
CreateUserCommand
QueryUserByIdQuery
// 不如简单点
CreateUserRequest
GetUserByIdRequest
是的,这可能不够"纯粹",但至少不用浪费时间争论了。
理由二:聚合根边界太难判断
DDD 说要定义"聚合根",但实际项目中,我经常不知道某个东西到底该不该是聚合根。
一个让我纠结很久的例子
假设我们要做一个内容社区,有个"收藏"功能:
这个收藏到底该怎么建模?
我当时陷入了纠结:
从用户角度看:
- 收藏是"我的收藏列表"
- 用户删了,收藏也该删
- 那收藏应该属于 User 聚合吗?
从文章角度看:
- 收藏是"文章的收藏数"
- 文章删了,收藏也该删
- 那收藏应该属于 Article 聚合吗?
从行为角度看:
- 收藏有自己的 ID
- 可以单独查询
- 有自己的创建时间
- 那它应该是独立的聚合根吗?
但换个角度想:
- 收藏就是个多对多关系表
- 没什么复杂的业务规则
- 类似数据库的中间表
- 需要这么复杂的建模吗?
我尝试用 DDD 理论来判断
结果更混乱了:
一致性边界?
- 用户删了,收藏不一定要立即删(可能保留做数据分析)
- 文章删了,收藏也不一定要立即删(可以显示"文章已删除")
- 点击收藏,只要保证不重复就行(数据库唯一约束就够了)
- 那一致性边界在哪?不知道
事务边界?
- 收藏的时候要不要同时更新文章的收藏数?
- 必须在同一个事务吗?
- 如果更新收藏数失败了怎么办?
- 其实收藏数可以异步更新,不一定要强一致
- 那还需要事务边界吗?不确定
生命周期?
- 用户删了,收藏可能保留(做数据分析)→ 不绑定 User
- 文章删了,收藏也可能保留(显示"已删除")→ 不绑定 Article
- 那它有独立的生命周期?→ 应该是独立聚合根?
- 但它就是个关联关系啊!
业务规则?
- 简单场景:不能重复收藏(唯一约束就够了)→ 不需要聚合根
- 复杂场景:如果要加收藏分类、数量限制、可见性控制... → 需要聚合根
- 同样的功能,不同阶段,建模完全不同?
试过几种方案,都有点问题
方案 A:Favorite 作为独立聚合根
java
public class Favorite {
private FavoriteId id; // 有独立的 ID
private UserId userId;
private ArticleId articleId;
private Instant createdAt;
// 业务规则?
public void cancel() {
// 取消收藏
}
}
public interface FavoriteRepository {
Optional<Favorite> findById(FavoriteId id);
void save(Favorite favorite);
}
// 问题:
// 1. Favorite 更像是一个关联记录,而不是业务实体
方案 B:Favorite 是 User 聚合的一部分
java
public class User {
private UserId id;
private String username;
private List<Favorite> favorites; // User 的一部分
public void favoriteArticle(ArticleId articleId) {
if (hasFavorited(articleId)) {
throw new AlreadyFavoritedException();
}
favorites.add(new Favorite(articleId));
}
public boolean hasFavorited(ArticleId articleId) {
return favorites.stream()
.anyMatch(f -> f.getArticleId().equals(articleId));
}
}
// 问题:
// 1. 一个用户可能有几千条收藏记录,每次查询 User 都要加载所有收藏?
// 2. 如果用户被删除,那文章的收藏数呢?
方案 C:Favorite 是 Article 聚合的一部分
java
public class Article {
private ArticleId id;
private String title;
private List<Favorite> favorites; // Article 的一部分
public void receiveFavorite(UserId userId) {
if (isFavoritedBy(userId)) {
throw new AlreadyFavoritedException();
}
favorites.add(new Favorite(userId));
}
}
// 问题:
// 1. 一篇热门文章可能有几万个收藏,每次查询 Article 都要加载所有收藏?
方案 D:不建模为聚合,只是数据库记录
java
// 没有领域模型,直接操作数据库
@Service
public class FavoriteService {
@Autowired
private JdbcTemplate jdbcTemplate;
public void favoriteArticle(Long userId, Long articleId) {
jdbcTemplate.update(
"INSERT INTO favorite (user_id, article_id, created_at) " +
"VALUES (?, ?, NOW())",
userId, articleId
);
}
public boolean hasFavorited(Long userId, Long articleId) {
return jdbcTemplate.queryForObject(
"SELECT EXISTS(SELECT 1 FROM favorite " +
"WHERE user_id = ? AND article_id = ?)",
Boolean.class,
userId, articleId
);
}
}
// 问题:
// 1. 完全放弃了 DDD 建模
// 2. 业务规则散落在应用层
附上 Claude 4.5 的分析

判断标准的悖论
让我们总结一下判断的困境:
| 判断维度 | 结论 | 矛盾 |
|---|---|---|
| 一致性边界 | 收藏不需要和 User/Article 强一致 | 那它的边界在哪? |
| 事务边界 | 收藏操作独立,不需要和其他操作在同一事务 | 那为什么需要聚合? |
| 生命周期 | 收藏有独立的生命周期 | 但它只是个关联关系 |
| 业务规则 | 简单场景:几乎没有规则 | 不需要聚合根 |
| 复杂场景:有很多规则 | 需要聚合根 | |
| 数量 | 一个用户/文章可能有成千上万条行为 | 不能放在聚合内 |
| 查询模式 | 需要频繁查询"是否点赞" | 需要独立的表和索引 |
我的感受
最后我发现:
没有标准答案
- 同样的收藏功能,在不同的业务阶段,建模方式可能完全不同
- 不同的人有不同的理解
- 团队里经常为这个争论,浪费时间
- 但这个问题本身可能就没有"正确答案"
DDD 假设业务可以被清晰地划分,但真实业务可能很复杂,变化也很快。
理由三:DDD 和性能优化冲突
这是我遇到的最痛苦的问题。
一个真实的例子
项目做大了,user 表太大了,查询慢,要拆表:
sql
CREATE TABLE user (
id BIGINT PRIMARY KEY,
-- 基本信息(高频访问,小字段)
username VARCHAR(50),
email VARCHAR(100),
phone VARCHAR(20),
created_at TIMESTAMP,
-- 详细信息(低频访问,大字段)
nickname VARCHAR(50),
bio TEXT, -- 可能很长
avatar_url VARCHAR(200),
address_json TEXT, -- JSON 字段
preferences_json TEXT, -- JSON 字段
social_links_json TEXT, -- JSON 字段
-- ... 还有 20 多个字段
);
问题是:
- 表有 1000 万条数据
- 80% 的查询只需要
username和email - 但每次都要扫描所有字段,很慢
那拆表吧:
sql
-- 基本信息表(高频访问)
CREATE TABLE user (
id BIGINT PRIMARY KEY,
username VARCHAR(50),
email VARCHAR(100),
phone VARCHAR(20),
created_at TIMESTAMP,
INDEX idx_username (username),
INDEX idx_email (email)
);
-- 详细信息表(低频访问)
CREATE TABLE user_detail (
user_id BIGINT PRIMARY KEY,
nickname VARCHAR(50),
bio TEXT,
avatar_url VARCHAR(200),
address_json TEXT,
preferences_json TEXT,
social_links_json TEXT,
FOREIGN KEY (user_id) REFERENCES user(id)
);
拆完之后性能确实提升了很多,索引也更高效了。
但问题来了
按照 DDD,User 应该是完整的聚合根:
java
public class User {
private UserId id;
private String username;
private String email;
private String nickname; // 这些都应该在 User 里
private String bio;
private String avatarUrl;
// ...
}
public interface UserRepository {
Optional<User> findById(UserId id); // 应该返回完整的 User
}
但数据库拆成了两张表。现在怎么办?
我试过几种方案
尝试 1:Repository 总是返回完整聚合
java
@Repository
public class UserRepositoryImpl implements UserRepository {
@Override
public Optional<User> findById(UserId id) {
// ✅ 符合 DDD:返回完整聚合
// ❌ 性能问题:总是查询两张表
UserPO userPO = userMapper.selectById(id.getValue());
UserDetailPO detailPO = userDetailMapper.selectByUserId(id.getValue());
return Optional.of(assembleUser(userPO, detailPO));
}
}
// 使用场景
@Service
public class UserApplicationService {
public UserBasicInfoDTO getUserBasicInfo(UserId userId) {
// 问题:只需要 username 和 email
// 但查询了两张表,获取了所有数据
User user = userRepository.findById(userId).orElseThrow();
return new UserBasicInfoDTO(
user.getId(),
user.getUsername(), // 只用了这个
user.getEmail() // 只用了这个
);
}
}
// ❌ 问题:
// 1. 80% 的查询只需要基本信息,却查询了两张表
// 2. 拆表的性能优化完全失效
// 3. 那为什么要拆表?
尝试 2:延迟加载(Lazy Loading)
java
public class User {
private UserId id;
private String username;
private String email;
// 延迟加载的属性
private Supplier<UserProfile> profileLoader;
private UserProfile profile; // 缓存
public UserProfile getProfile() {
if (profile == null) {
profile = profileLoader.get(); // ❌ 首次访问时触发数据库查询
}
return profile;
}
}
@Repository
public class UserRepositoryImpl implements UserRepository {
@Override
public Optional<User> findById(UserId id) {
// 只查询基本信息
UserPO userPO = userMapper.selectById(id.getValue());
// 创建延迟加载函数
Supplier<UserProfile> profileLoader = () -> {
UserDetailPO detailPO = userDetailMapper.selectByUserId(id.getValue());
return toUserProfile(detailPO);
};
return Optional.of(User.reconstitute(id, username, email, profileLoader));
}
}
// ❌ 致命问题:
@Service
public class UserApplicationService {
public List<UserListItemDTO> listUsers(List<UserId> userIds) {
List<User> users = userIds.stream()
.map(id -> userRepository.findById(id).orElse(null))
.collect(Collectors.toList());
return users.stream()
.map(user -> new UserListItemDTO(
user.getId(),
user.getProfile().getNickname() // 💥 N+1 查询!
// 这里看起来只是个 getter
// 但实际上每次都触发一次数据库查询
))
.collect(Collectors.toList());
}
}
// 结果:
// - 查询了 100 个用户
// - 触发了 100 次数据库查询(N+1 问题)
// - 性能灾难!
// 更严重的问题:
// 1. user.getProfile() 看起来只是个 getter
// 2. 但实际上是数据库查询
// 3. 开发者很容易在循环中无意调用
// 4. 隐式的数据库操作,难以调试
// 5. 实体依赖 Repository,破坏了分层
尝试 3:Repository 提供不同粒度的查询
java
public interface UserRepository {
Optional<User> findById(UserId id); // 完整聚合
Optional<UserBasicInfo> findBasicInfoById(UserId id); // 只有基本信息
}
// ❌ 问题:
// 1. UserBasicInfo 是聚合的一部分,不应该单独返回
// 2. 破坏了聚合的封装性
// 3. 外部代码可能基于不完整的数据做决策
// 4. Repository 返回 DTO?分层混乱
// 更深层的问题:
public class UserBasicInfo {
private Long id;
private String username;
private String email;
// ❓ 这是实体?值对象?DTO?
// ❓ 它属于哪一层?
// ❓ 它可以执行业务逻辑吗?
}
// 如果 UserBasicInfo 可以执行业务逻辑:
public void someBusinessLogic(UserId userId) {
UserBasicInfo basicInfo = userRepository.findBasicInfoById(userId).orElseThrow();
// ❌ 危险:basicInfo 只有部分数据
// 如果业务逻辑需要访问 profile 怎么办?
// 会出现 NullPointerException 或错误的业务决策
}
尝试 4:CQRS 读写分离 + 查询投影
java
// 写模型:完整的聚合根(用于业务操作)
public class User {
private UserId id;
private String username;
private String email;
private UserProfile profile; // 完整的
public void updateProfile(String bio) {
this.profile.setBio(bio);
}
}
public interface UserRepository {
Optional<User> findById(UserId id); // 返回完整聚合
void save(User user);
}
// 读模型:查询专用的投影对象(用于查询展示)
public class UserListProjection {
private Long id;
private String username;
private String avatarUrl;
// 只读,不包含业务逻辑
}
public interface UserQueryRepository {
List<UserListProjection> findUserList(UserQuery query);
}
// 使用
@Service
public class UserApplicationService {
public List<UserListItemDTO> listUsers() {
// 查询场景:使用查询投影
List<UserListProjection> projections = userQueryRepository.findUserList(query);
return toDTO(projections);
}
public void updateUserProfile(UserId userId, String bio) {
// 业务场景:使用完整聚合根
User user = userRepository.findById(userId).orElseThrow();
user.updateProfile(bio);
userRepository.save(user);
}
}
// ⚠️ 问题:
// 1. 投影对象(UserListProjection)不是聚合根,不能执行业务逻辑
// 2. 只能用于展示,这模糊了领域层和应用层的边界
// 3. 需要维护两套 Repository(写模型和读模型)
尝试 5:Repository 提供多个查询方法返回不同粒度
java
public interface UserRepository {
// 完整聚合
Optional<User> findById(UserId id);
// 只有基本信息的聚合(profile 字段为空或懒加载)
Optional<User> findByIdLite(UserId id);
// 带完整 profile 的聚合
Optional<User> findByIdWithProfile(UserId id);
// 批量查询(只加载基本信息)
List<User> findByIds(List<UserId> userIds);
// 批量查询带 profile
List<User> findByIdsWithProfile(List<UserId> userIds);
}
// ❌ 问题:
// 1. findByIdLite 返回的 User 对象是残缺的,可能导致业务错误:
// - 如果调用 user.getProfile(),会返回 null 或抛异常
// - 开发者可能不知道这个 User 对象是"残缺"的
// - 在业务方法中使用残缺对象可能导致 NPE 或错误的业务决策
试了这么多方案
| 方案 | 优点 | 缺点 |
|---|---|---|
| 总是返回完整聚合 | 符合 DDD 理论 | 性能差,表拆分白做了 |
| 延迟加载 | 实现简单 | N+1 查询,容易踩坑 |
| 多粒度查询 | 灵活 | User 对象有时完整有时残缺,容易出 bug |
| CQRS 读写分离 | 查询性能好 | 维护两套模型,复杂度高 |
本质上,这些都是在性能和 DDD 理论之间妥协,都不完美。
我的感受
问题的根源是:DDD 和性能优化的目标是矛盾的
- DDD 说:聚合根应该是完整的、封装的,外部不应该知道存储细节
- 性能优化说:必须按需加载数据,必须针对不同场景优化查询
这是根本性的冲突。为了保持 DDD 的纯粹性,就要牺牲性能;为了性能,就要破坏 DDD 的完整性。
说到底,DDD 假设了一个理想化的世界:性能不是问题,可以随时加载完整的聚合。
但真实世界里,性能才是核心问题,必须按需加载数据。
这个矛盾是无解的。
理由四:DDD 太花时间
敏捷开发要求快速迭代,但 DDD 需要前期花大量时间建模。这两者天然矛盾。
场景一:有的需求太简单
arduino
产品:"我们需要一个收藏功能"
我:"有什么业务规则吗?"
产品:"就是点个按钮啊"
我的纠结:
- 这么简单,用 DDD 是不是杀鸡用牛刀?
- 但万一后面需求复杂了呢?
- 如果用 DDD,定义聚合根、值对象...花了 5 天
- 结果产品说"需求改了,不做收藏了"
场景二:需求一直在变
arduino
Sprint 1:"用户可以收藏文章"
→ 花 3 天建模实现
Sprint 2:"收藏要分类"
→ 修改聚合根,花 2 天重构
Sprint 3:"收藏可以分享"
→ 重新考虑聚合边界,花 3 天重构
Sprint 4:"算了,分类太复杂,去掉"
→ 又花 2 天改回去
总共:10 天
如果用 CRUD:可能只要 4 天
场景三:团队不熟悉 DDD
css
团队里不是每个人都懂 DDD,不是每个人的理解都一样
→ 或者大家按自己理解写,结果风格完全不一样
→ Code Review 变成了 DDD 概念争论会
→ 效率很低
我的感受
完整的 DDD 实践(定义聚合根、值对象、领域事件等)需要的时间,大概是直接写 CRUD 的 2-3 倍。
对于简单的业务,这个投入不值得。对于复杂的业务,需求又经常变,模型调整的成本很高。
最后发现,很多时候不如用最简单的方式,等真的遇到复杂度再重构。
最后想说的
经过这些年的实践,我的感受是:
1. 大多数项目不需要完整的 DDD
80% 的项目就是 CRUD,用传统的三层架构就够了。强行用 DDD 反而增加复杂度。
完整的 DDD(聚合根、值对象、领域事件等)可能只适合业务非常复杂、需要长期维护(5 年以上)、团队有经验的核心系统。
2. DDD 理论确实有问题
不只是实践难,理论本身就有问题:
- CQRS 三分类在灰色地带不够用
- 和性能优化天然冲突
- 时间成本太高
3. 没人在实践"纯粹"的 DDD
实际项目中,大家都在妥协:
- Command 返回业务数据(不只是 ID)
- 查询直接写 SQL(绕过 Repository)
- 为了性能破坏聚合完整性
- 根据情况简化建模
- 应用的模块按 DDD 风格拆分,但是里面的模型却全是贫血模型
我们用的是"DDD 风格"的代码,而不是教科书式的 DDD。
DDD 是 2003 年提出的,那时候的软件开发环境和现在完全不同。20 年过去了,敏捷开发、微服务、高并发成为常态,DDD 的很多假设已经不适用了。
但这不是说 DDD 没价值。它教会我们关注业务、用业务语言沟通、封装业务规则,特别是业务的垂直拆分,这些思想是好的。
只是,不要教条地使用 DDD。根据项目实际情况选择合适的架构,才是最重要的。
没有完美的架构,只有合适的架构。