我为什么不喜欢DDD
我为什么不喜欢DDD
这不是一篇 DDD 教程,也不是什么最佳实践指南。这是我使用 DDD 后的一些真实感受和困惑。如果你正在考虑是否要在项目中使用 DDD,或者已经在用但感觉哪里不对劲,这篇文章可能会引起你的共鸣。
理由一:CQRS 分类让人头疼
CQRS 要求你把所有操作分成三类:Command(命令)、Query(查询)、Event(事件)。理论上很清晰,但实际写代码的时候,你会发现很多操作根本不知道该归到哪一类。
一个真实的例子
假设我们做了个服务调用系统,用户通过 API 调用第三方服务:
// 用户请求
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 是对已发生事情的被动响应 但这明明是用户主动发起的操作 根本不是
结果就是:三个都不是!
这样的例子还有很多
| 「???」 | ||||
| 「???」 | ||||
| 「???」 | ||||
| 「???」 |
我的感受
现实情况是:
很多操作既改变状态,又需要返回复杂的业务数据 不同的人对这些定义有不同的理解 Code Review 的时候经常为了"这到底是 Command 还是 Query"争论半天 最后发现,花了这么多时间讨论,实际收益很小
与其这样纠结,「不如都叫 Request 算了」:
// 与其纠结
CreateUserCommand
QueryUserByIdQuery
// 不如简单点
CreateUserRequest
GetUserByIdRequest
是的,这可能不够"纯粹",但至少不用浪费时间争论了。
理由二:聚合根边界太难判断
DDD 说要定义"聚合根",但实际项目中,我经常不知道某个东西到底该不该是聚合根。
一个让我纠结很久的例子
假设我们要做一个内容社区,有个"收藏"功能:
这个收藏到底该怎么建模?
我当时陷入了纠结:
「从用户角度看」:
收藏是"我的收藏列表" 用户删了,收藏也该删 那收藏应该属于 User 聚合吗?
「从文章角度看」:
收藏是"文章的收藏数" 文章删了,收藏也该删 那收藏应该属于 Article 聚合吗?
「从行为角度看」:
收藏有自己的 ID 可以单独查询 有自己的创建时间 那它应该是独立的聚合根吗?
「但换个角度想」:
收藏就是个多对多关系表 没什么复杂的业务规则 类似数据库的中间表 需要这么复杂的建模吗?
我尝试用 DDD 理论来判断
结果更混乱了:
「一致性边界?」
用户删了,收藏不一定要立即删(可能保留做数据分析) 文章删了,收藏也不一定要立即删(可以显示"文章已删除") 点击收藏,只要保证不重复就行(数据库唯一约束就够了) 那一致性边界在哪?不知道
「事务边界?」
收藏的时候要不要同时更新文章的收藏数? 必须在同一个事务吗? 如果更新收藏数失败了怎么办? 其实收藏数可以异步更新,不一定要强一致 那还需要事务边界吗?不确定
「生命周期?」
用户删了,收藏可能保留(做数据分析)→ 不绑定 User 文章删了,收藏也可能保留(显示"已删除")→ 不绑定 Article 那它有独立的生命周期?→ 应该是独立聚合根? 但它就是个关联关系啊!
「业务规则?」
简单场景:不能重复收藏(唯一约束就够了)→ 不需要聚合根 复杂场景:如果要加收藏分类、数量限制、可见性控制... → 需要聚合根 同样的功能,不同阶段,建模完全不同?
试过几种方案,都有点问题
「方案 A:Favorite 作为独立聚合根」
publicclassFavorite{
private FavoriteId id; // 有独立的 ID
private UserId userId;
private ArticleId articleId;
private Instant createdAt;
// 业务规则?
publicvoidcancel(){
// 取消收藏
}
}
publicinterfaceFavoriteRepository{
Optional<Favorite> findById(FavoriteId id);
voidsave(Favorite favorite);
}
// 问题:
// 1. Favorite 更像是一个关联记录,而不是业务实体
「方案 B:Favorite 是 User 聚合的一部分」
publicclassUser{
private UserId id;
private String username;
private List<Favorite> favorites; // User 的一部分
publicvoidfavoriteArticle(ArticleId articleId){
if (hasFavorited(articleId)) {
thrownew AlreadyFavoritedException();
}
favorites.add(new Favorite(articleId));
}
publicbooleanhasFavorited(ArticleId articleId){
return favorites.stream()
.anyMatch(f -> f.getArticleId().equals(articleId));
}
}
// 问题:
// 1. 一个用户可能有几千条收藏记录,每次查询 User 都要加载所有收藏?
// 2. 如果用户被删除,那文章的收藏数呢?
「方案 C:Favorite 是 Article 聚合的一部分」
publicclassArticle{
private ArticleId id;
private String title;
private List<Favorite> favorites; // Article 的一部分
publicvoidreceiveFavorite(UserId userId){
if (isFavoritedBy(userId)) {
thrownew AlreadyFavoritedException();
}
favorites.add(new Favorite(userId));
}
}
// 问题:
// 1. 一篇热门文章可能有几万个收藏,每次查询 Article 都要加载所有收藏?
「方案 D:不建模为聚合,只是数据库记录」
// 没有领域模型,直接操作数据库
@Service
publicclassFavoriteService{
@Autowired
private JdbcTemplate jdbcTemplate;
publicvoidfavoriteArticle(Long userId, Long articleId){
jdbcTemplate.update(
"INSERT INTO favorite (user_id, article_id, created_at) " +
"VALUES (?, ?, NOW())",
userId, articleId
);
}
publicbooleanhasFavorited(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 的分析」
判断标准的悖论
让我们总结一下判断的困境:
| 「一致性边界」 | ||
| 「事务边界」 | ||
| 「生命周期」 | ||
| 「业务规则」 | ||
| 「数量」 | ||
| 「查询模式」 |
我的感受
最后我发现:
「没有标准答案」
同样的收藏功能,在不同的业务阶段,建模方式可能完全不同 不同的人有不同的理解 团队里经常为这个争论,浪费时间 但这个问题本身可能就没有"正确答案"
DDD 假设业务可以被清晰地划分,但真实业务可能很复杂,变化也很快。
理由三:DDD 和性能优化冲突
这是我遇到的最痛苦的问题。
一个真实的例子
项目做大了,user 表太大了,查询慢,要拆表:
CREATETABLEuser (
idBIGINT 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但每次都要扫描所有字段,很慢
那拆表吧:
-- 基本信息表(高频访问)
CREATETABLEuser (
idBIGINT PRIMARY KEY,
username VARCHAR(50),
email VARCHAR(100),
phone VARCHAR(20),
created_at TIMESTAMP,
INDEX idx_username (username),
INDEX idx_email (email)
);
-- 详细信息表(低频访问)
CREATETABLE 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,
FOREIGNKEY (user_id) REFERENCESuser(id)
);
拆完之后性能确实提升了很多,索引也更高效了。
但问题来了
按照 DDD,User 应该是完整的聚合根:
publicclassUser{
private UserId id;
private String username;
private String email;
private String nickname; // 这些都应该在 User 里
private String bio;
private String avatarUrl;
// ...
}
publicinterfaceUserRepository{
Optional<User> findById(UserId id); // 应该返回完整的 User
}
但数据库拆成了两张表。现在怎么办?
我试过几种方案
「尝试 1:Repository 总是返回完整聚合」
@Repository
publicclassUserRepositoryImplimplementsUserRepository{
@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
publicclassUserApplicationService{
public UserBasicInfoDTO getUserBasicInfo(UserId userId){
// 问题:只需要 username 和 email
// 但查询了两张表,获取了所有数据
User user = userRepository.findById(userId).orElseThrow();
returnnew UserBasicInfoDTO(
user.getId(),
user.getUsername(), // 只用了这个
user.getEmail() // 只用了这个
);
}
}
// ❌ 问题:
// 1. 80% 的查询只需要基本信息,却查询了两张表
// 2. 拆表的性能优化完全失效
// 3. 那为什么要拆表?
「尝试 2:延迟加载(Lazy Loading)」
publicclassUser{
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
publicclassUserRepositoryImplimplementsUserRepository{
@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
publicclassUserApplicationService{
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 提供不同粒度的查询」
publicinterfaceUserRepository{
Optional<User> findById(UserId id); // 完整聚合
Optional<UserBasicInfo> findBasicInfoById(UserId id); // 只有基本信息
}
// ❌ 问题:
// 1. UserBasicInfo 是聚合的一部分,不应该单独返回
// 2. 破坏了聚合的封装性
// 3. 外部代码可能基于不完整的数据做决策
// 4. Repository 返回 DTO?分层混乱
// 更深层的问题:
publicclassUserBasicInfo{
private Long id;
private String username;
private String email;
// ❓ 这是实体?值对象?DTO?
// ❓ 它属于哪一层?
// ❓ 它可以执行业务逻辑吗?
}
// 如果 UserBasicInfo 可以执行业务逻辑:
publicvoidsomeBusinessLogic(UserId userId){
UserBasicInfo basicInfo = userRepository.findBasicInfoById(userId).orElseThrow();
// ❌ 危险:basicInfo 只有部分数据
// 如果业务逻辑需要访问 profile 怎么办?
// 会出现 NullPointerException 或错误的业务决策
}
「尝试 4:CQRS 读写分离 + 查询投影」
// 写模型:完整的聚合根(用于业务操作)
publicclassUser{
private UserId id;
private String username;
private String email;
private UserProfile profile; // 完整的
publicvoidupdateProfile(String bio){
this.profile.setBio(bio);
}
}
publicinterfaceUserRepository{
Optional<User> findById(UserId id); // 返回完整聚合
voidsave(User user);
}
// 读模型:查询专用的投影对象(用于查询展示)
publicclassUserListProjection{
private Long id;
private String username;
private String avatarUrl;
// 只读,不包含业务逻辑
}
publicinterfaceUserQueryRepository{
List<UserListProjection> findUserList(UserQuery query);
}
// 使用
@Service
publicclassUserApplicationService{
public List<UserListItemDTO> listUsers(){
// 查询场景:使用查询投影
List<UserListProjection> projections = userQueryRepository.findUserList(query);
return toDTO(projections);
}
publicvoidupdateUserProfile(UserId userId, String bio){
// 业务场景:使用完整聚合根
User user = userRepository.findById(userId).orElseThrow();
user.updateProfile(bio);
userRepository.save(user);
}
}
// ⚠️ 问题:
// 1. 投影对象(UserListProjection)不是聚合根,不能执行业务逻辑
// 2. 只能用于展示,这模糊了领域层和应用层的边界
// 3. 需要维护两套 Repository(写模型和读模型)
「尝试 5:Repository 提供多个查询方法返回不同粒度」
publicinterfaceUserRepository{
// 完整聚合
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 或错误的业务决策
试了这么多方案
| 「总是返回完整聚合」 | ||
| 「延迟加载」 | ||
| 「多粒度查询」 | ||
| 「CQRS 读写分离」 |
本质上,这些都是在性能和 DDD 理论之间妥协,都不完美。
我的感受
问题的根源是:「DDD 和性能优化的目标是矛盾的」
DDD 说:聚合根应该是完整的、封装的,外部不应该知道存储细节 性能优化说:必须按需加载数据,必须针对不同场景优化查询
这是根本性的冲突。为了保持 DDD 的纯粹性,就要牺牲性能;为了性能,就要破坏 DDD 的完整性。
说到底,DDD 假设了一个理想化的世界:性能不是问题,可以随时加载完整的聚合。
但真实世界里,性能才是核心问题,必须按需加载数据。
这个矛盾是无解的。
理由四:DDD 太花时间
敏捷开发要求快速迭代,但 DDD 需要前期花大量时间建模。这两者天然矛盾。
「场景一:有的需求太简单」
产品:"我们需要一个收藏功能"
我:"有什么业务规则吗?"
产品:"就是点个按钮啊"
我的纠结:
- 这么简单,用 DDD 是不是杀鸡用牛刀?
- 但万一后面需求复杂了呢?
- 如果用 DDD,定义聚合根、值对象...花了 5 天
- 结果产品说"需求改了,不做收藏了"
「场景二:需求一直在变」
Sprint 1:"用户可以收藏文章"
→ 花 3 天建模实现
Sprint 2:"收藏要分类"
→ 修改聚合根,花 2 天重构
Sprint 3:"收藏可以分享"
→ 重新考虑聚合边界,花 3 天重构
Sprint 4:"算了,分类太复杂,去掉"
→ 又花 2 天改回去
总共:10 天
如果用 CRUD:可能只要 4 天
「场景三:团队不熟悉 DDD」
团队里不是每个人都懂 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」。根据项目实际情况选择合适的架构,才是最重要的。
没有完美的架构,只有合适的架构。