四层架构
而不是 MVC 的三层
领域驱动设计(DDD)中,并没有一个所谓的"唯一标准"架构,但行业内公认的经典架构 是 Eric Evans 在原著中提出的 四层分层架构。
| 层级名称 | 职责描述 | 核心组件 |
|---|---|---|
| 用户界面层 (Interfaces) | 负责与外部交互。处理 HTTP 请求、解析 JSON、返回数据给前端。 | Controller, WebSocket, DTO 映射器 |
| 应用层 (Application) | 不含业务逻辑。负责编排业务流程(比如先查数据库,再调接口,最后发消息)。 | Application Services, Command/Query |
| 领域层 (Domain) | 系统的灵魂。纯粹的业务逻辑。不依赖数据库、不依赖第三方。 | Entity, Value Object, Aggregate, Domain Service |
| 基础设施层 (Infrastructure) | 提供技术支撑。数据库持久化、缓存、发送邮件、配置管理等。 | Repository 实现, MQ 适配, DB Config |
现代变体:六边形架构 (Hexagonal Architecture)
不要和上面的严格分成两种架构,要把他们融合在一起,他们都是 DDD
在实际开发中,为了让领域层 彻底独立,现在更多公司采用依赖倒置 的模型。(我会单独画一篇文章去理解依赖倒置)
- 核心理念: 领域层在中间,所有的外部系统(数据库、前端、第三方 API)都叫"适配器"。
- 关键点: 领域层定义接口(Ports) ,基础设施层去实现(Adapters) 。
- 优势: 你的业务逻辑不依赖于你是用 MySQL 还是 Oracle,也不在乎你是用 Spring 还是 Micronaut。
一个标准的工程目录结构 (Java 示例)
看一个目录结构 就懂了,也有可能是模块结构
Plaintext
com.company.project
├── interfaces (或 user)
│ ├── controller // 接口入库
│ └── dto // 返回给前端的 DTO
├── application
│ └── service // 应用服务,负责调用领域层
├── domain
│ ├── model // 聚合、实体、值对象
│ ├── service // 领域服务(跨实体的逻辑)
│ └── repository // 仓储接口 (注意:这里只有 interface)
└── infrastructure
├── repository // 数据库实现 (MyBatis/JPA 实现类)
├── config // 配置类
└── external // 调用其他微服务的 Feign 客户端
我认为的 DDD 的核心要义
-
不要在应用层写业务逻辑: 应用层应该很薄,它只是个"指挥官",具体的判断逻辑(比如:余额够不够)必须在领域层。
-
不要在领域层引用框架类: 尽量让
domain文件夹里只有纯粹的 Java 代码,不要出现HttpServletRequest这种东西。 -
资源库 (Repository) 的定义: 接口定义在
domain,具体实现在infrastructure。
依赖倒置
结论: 查询数据库的动作 (执行 SQL、调用驱动)一定不在 Domain 层,但查询数据库的契约(接口) 必须留在 Domain 层。
具体应该怎么放,取决于你的查询目的。我们需要区分两种场景:
1. 为了"执行业务逻辑"而查询 (Repository)
如果你需要查询数据来判断业务规则(例如:下单前查一下用户余额、查一下库存),这种情况遵循 "依赖倒置原则" 。
- Domain 层: 定义一个接口(Repository Interface)。它表达的是业务意图:"我需要一个能根据 ID 获取聚合根的能力"。
- Infrastructure 层: 实现这个接口。这里才真正写 MyBatis、JPA 或 SQL 语句。
- Application 层: 负责调用这个接口。
代码流转示意:
- Application 层 调用
domainRepository.findById(id)。 - Domain 层 拿到返回的实体(Entity),并在实体上执行业务方法(如
order.validate())。 - Application 层 根据结果决定是否提交事务。
2. 为了"页面展示"而查询 (CQRS)
如果你的查询仅仅是为了在 UI 上列表显示,不涉及任何业务逻辑(即:不需要判断,直接展示),那么可以绕过 Domain 层。
- 做法: 在 Application 层(或者专门的 Query 层)直接调用 Infrastructure 层的查询服务,返回 DTO 即可。
- 原因: 领域模型(Domain Model)通常很重且复杂。如果只是为了前端展示一个表格,强行通过 Domain 层包装成实体再转成 DTO,性能差且链路冗余。
| 查询类型 | 逻辑归属 | 实际查询位置 | 最终返回对象 |
|---|---|---|---|
| 业务逻辑查询 | Domain 定义接口 | Infrastructure 实现 | Entity / Aggregate (实体) |
| 页面展示查询 | Application / Query 层 | Infrastructure 直接实现 | DTO (纯数据对象) |
具体实现
在标准的 DDD 实践中,我们会利用 依赖倒置原则 (DIP) 。即 Domain 层不依赖具体的数据库技术,而是定义"我需要什么",由 Infrastructure 层去实现"怎么拿数据"。
以下是基于 Spring Boot 的标准代码结构示例:
1. 目录结构预览
在 IDEA 中,你的包结构看起来应该是这样的:
Plaintext
src/main/java/com/example/demo
├── domain
│ ├── model
│ │ └── User.java // 聚合根 (Entity)
│ └── repository
│ └── UserRepository.java // 接口定义 (只含业务契约)
├── infrastructure
│ └── persistence
│ ├── UserMapper.java // MyBatis/JPA 的具体实现
│ └── UserRepositoryImpl.java // 仓储实现类
└── application
└── UserService.java // 应用服务
2. 代码实现
第一步:Domain 层 - 定义接口
这里不包含任何 Spring Data 或 MyBatis 的注解,它是纯粹的业务契约。
Java
// domain.repository.UserRepository
public interface UserRepository {
// 根据 ID 获取用户,返回的是领域模型(Entity)
User findById(Long id);
// 保存用户
void save(User user);
}
第二步:Infrastructure 层 - 真正实现
这里处理技术细节(如 SQL、缓存等)。我们通常会注入底层的 Mapper 或 Spring Data JPA。
Java
// infrastructure.persistence.UserRepositoryImpl
@Repository // 只有实现类才打上 Spring 的持久化注解
public class UserRepositoryImpl implements UserRepository {
@Autowired
private UserMapper userMapper; // 假设使用 MyBatis
@Override
public User findById(Long id) {
// 1. 调用底层的数据库操作(返回的是数据库实体 PO)
UserPO userPO = userMapper.selectById(id);
// 2. 将数据对象 (PO) 转换为领域对象 (Entity)
// 这是为了保证 Domain 层的纯净,不被数据库字段定义污染
return UserConverter.toEntity(userPO);
}
@Override
public void save(User user) {
UserPO po = UserConverter.toPO(user);
userMapper.insertOrUpdate(po);
}
}
第三步:Application 层 - 编排调用
应用服务只负责"指挥",它并不知道数据是怎么存的。
Java
// application.UserService
@Service
public class UserService {
@Autowired
private UserRepository userRepository; // 注入接口,解耦!
@Transactional
public void changeUserEmail(Long id, String newEmail) {
// 1. 通过仓储加载聚合根
User user = userRepository.findById(id);
// 2. 执行领域逻辑
user.updateEmail(newEmail);
// 3. 保存回仓储
userRepository.save(user);
}
}
3. 为什么一定要这么写?(深度理解)
- 屏蔽技术细节 :如果哪天你要把数据存到 Redis 或者远程 API,你只需要在
infrastructure层写一个新的实现类,domain层和application层的一行代码都不用动。 - 保护业务逻辑 :
User实体是一个具有业务行为的对象(比如有changePassword()方法),而数据库对应的UserPO只是一个简单的字段容器。通过转换,防止数据库的字段设计(如is_deleted)渗透到业务逻辑中。 - 方便单元测试 :在测试
UserService时,你可以非常轻松地 Mock 掉UserRepository接口,而不需要启动真正的数据库。
核心差异点:PO vs Entity
- PO (Persistent Object) : 对应数据库表结构,属性全公开(Getter/Setter)。UserPO.java
- Entity (Domain Model) : 对应业务模型,属性通常私有,通过方法暴露业务动作。User.java