我为什么不喜欢DDD

我为什么不喜欢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 生成内容 ???

我的感受

现实情况是:

  1. 很多操作既改变状态,又需要返回复杂的业务数据
  2. 不同的人对这些定义有不同的理解
  3. Code Review 的时候经常为了"这到底是 Command 还是 Query"争论半天
  4. 最后发现,花了这么多时间讨论,实际收益很小

与其这样纠结,不如都叫 Request 算了

java 复制代码
// 与其纠结
CreateUserCommand
QueryUserByIdQuery

// 不如简单点
CreateUserRequest
GetUserByIdRequest

是的,这可能不够"纯粹",但至少不用浪费时间争论了。


理由二:聚合根边界太难判断

DDD 说要定义"聚合根",但实际项目中,我经常不知道某个东西到底该不该是聚合根。

一个让我纠结很久的例子

假设我们要做一个内容社区,有个"收藏"功能:

erDiagram USER ||--o{ FAVORITE : "has" ARTICLE ||--o{ FAVORITE : "receives" USER { bigint id PK varchar username varchar email } ARTICLE { bigint id PK varchar title text content bigint author_id FK int favorite_count } FAVORITE { bigint id PK bigint user_id FK bigint article_id FK timestamp created_at }

这个收藏到底该怎么建模?

我当时陷入了纠结:

从用户角度看

  • 收藏是"我的收藏列表"
  • 用户删了,收藏也该删
  • 那收藏应该属于 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% 的查询只需要 usernameemail
  • 但每次都要扫描所有字段,很慢

那拆表吧:

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。根据项目实际情况选择合适的架构,才是最重要的。

没有完美的架构,只有合适的架构。

相关推荐
周杰伦_Jay4 小时前
【计算机网络核心】TCP/IP模型与网页解析全流程详解
网络·网络协议·tcp/ip·计算机网络·算法·架构·1024程序员节
gfdgd xi4 小时前
Wine运行器3.4.0——虚拟机安装工具支持设置UEFI启动
android·windows·python·ubuntu·架构
Lenz's law6 小时前
智元灵犀X1-本体通讯架构分析2:CAN/FD总线性能优化分析
架构·机器人·can·1024程序员节
云雾J视界14 小时前
TMS320C6000 VLIW架构并行编程实战:加速AI边缘计算推理性能
人工智能·架构·边缘计算·dsp·vliw·tms320c6000
消失的旧时光-194315 小时前
Flutter 异步体系终章:FutureBuilder 与 StreamBuilder 架构优化指南
flutter·架构
bug攻城狮18 小时前
SaaS多租户架构实践:字段隔离方案(共享数据库+共享Schema)
mysql·架构·mybatis·springboot·1024程序员节
007php00718 小时前
京东面试题解析:同步方法、线程池、Spring、Dubbo、消息队列、Redis等
开发语言·后端·百度·面试·职场和发展·架构·1024程序员节