领域驱动设计(DDD):三层架构到DDD架构演化

三层架构的问题

在前文中,我从基础代码的角度探讨了如何运用领域驱动设计(DDD)来实现高内聚低耦合的代码。本篇文章将从项目架构的角度,继续探讨三层架构与DDD之间的演化过程,以及DDD如何优化架构的问题。

三层架构作为一种常见的软件架构模式,将应用程序分为展示层、业务逻辑层和数据访问层,具有以下优点:

  1. 分离关注点: 三层架构将不同功能模块分隔开,使每个模块专注于特定任务,降低了代码复杂性。
  2. 可维护性和可扩展性: 不同层之间的松耦合使得对某一层的修改不会影响其他层,有助于系统的维护和扩展。
  3. 可测试性: 不同层的独立性使得单元测试和集成测试更容易实现,有助于确保代码质量。

然而,尽管三层架构有其优点,在处理复杂业务时,三层架构也可能面临一些问题。具体有:

  1. 业务逻辑分散: 在三层架构中,业务逻辑往往分散在不同的层中,导致业务流程难以理清,影响了代码的可读性和可维护性。
  2. 领域模型贫血: 三层架构中,领域逻辑和数据存储混合在一起,导致领域模型的业务方法受限,难以表达复杂的业务规则。
  3. 过度依赖数据存储: 不同层之间对数据存储的依赖紧密,当切换数据存储介质时,需要大量修改代码。

具体具体示意如下图:

image-20230820193649616

随着业务的不断复杂化,service层变得越来越庞大,服务之间的引用也变得越来越混乱,这为项目带来了风险和不确定性。

三层架构演化到DDD

在三层架构的演化过程中,有时会尝试引入额外的”Manager”层来管理服务层的功能,但这并不是DDD所倡导的概念。在DDD中,更加关注领域的划分和内聚,以及如何将领域模型与业务需求对应起来。

一般情况下,三层架构的问题可以通过引入领域驱动设计来解决。在以下内容中,我们将重点放在如何将DDD思想融入现有的三层架构中,以实现更高内聚、更低耦合的代码架构。

  1. 领域的划分: DDD将service层按业务场景划分成不同的领域,每个领域内包含实体、值对象、聚合根等元素。
  2. 内聚的领域: 在领域内,业务尽量内聚,避免领域之间的耦合。每个领域内部可以根据需要建立更细粒度的子域,进一步提高内聚性。
  3. 应用层的组合: 引入一个Application层,将领域内的service组合调用,形成业务服务,避免服务之间直接引用,降低耦合度。

经过我们的修改,三层架构可以(组合和聚合)演进到右侧架构模式,通过这种方式,我们能够更好地组织和管理代码,实现领域内高内聚低耦合的目标。

image-20230820225904411

代码组织

在进行了基础代码的优化后,接下来我们将探讨如何根据领域驱动设计(DDD)思想来优化整体代码架构。经过前面的分析,我们大致了解了DDD的项目结构,并且明确了每个层次的职责。现在,让我们更详细地探讨每个层次的代码组织。

  1. Domain层: 该层是DDD的核心,包含了领域对象、值对象、聚合根等,以及领域内的业务逻辑和规则。在领域内,业务逻辑应该尽量内聚,领域间应该尽量松耦合。
  2. 基础架构层: 包括仓储实现,缓存实现,队列实现等等系列系统需要的基础能力,这一层的目的是为整个项目提供基础支持。
  3. Application层: 这一层用于组合领域内的服务,形成具体的应用用例。它不包含具体的业务逻辑,只是通过调用领域内的服务来实现具体的功能。
  4. UI层: UI层负责展示数据和接收用户输入,它不包含业务逻辑,只是通过调用Application层来触发业务流程。

具体架构类似如下图:

image-20230821225045621

当将领域驱动设计(DDD)引入到项目架构中,代码的组织方式会有所不同,以更好地体现领域的业务逻辑和关系。让我们详细解释每个层次的代码组织,为了保证阅读的连贯性,我们从引用的最低层(domain层)开始说起

Domain层:

Domain层是DDD的核心,它包含了领域对象、值对象、聚合根等,以及领域内的业务逻辑和规则。在这一层,你应该更关注领域的核心业务,让代码更贴近业务现实。以下是一些代码组织的思路:

  • 实体和值对象: 领域对象可以分为实体和值对象。实体是有唯一标识的对象,通常代表业务概念;值对象是没有唯一标识的对象,它们通常用来描述实体的属性。在这一层,你应该为每个实体和值对象定义其属性和行为。

  • 聚合和聚合根: 将相关联的实体和值对象组合成聚合,聚合根是聚合的入口。聚合根负责保持聚合内的一致性,它是领域模型的核心部分。

  • 领域服务: 领域服务用于处理一些领域范围内的业务逻辑,它们不属于任何具体的实体或值对象。将这些逻辑封装在领域服务中可以使领域模型更加清晰。

  • 通用工具类: 通用工具类是一些与领域相关的辅助方法,可以被领域内的多个实体或值对象使用。将通用工具类放在领域层可以更方便地供领域内的实体使用,避免在其他层重复实现。

image-20230822231940163

在domain域内提供,entity(实体),valueobj(值对象),AggregateRoot(聚合根),仓储接口(IRepository),事件驱动相关(event)

基础架构层:

在基础架构层,我们主要关注与系统的基础设施和通用功能。这一层包含仓储模式和接口适配器,用于封装数据存储操作并为领域层提供统一的数据访问接口。通用工具类也可以在这里定义和实现,为领域层和应用层提供通用的辅助功能。基础架构层的代码组织通常如下:

  • 第三方库封装: 如果项目使用了第三方库或框架,你可以在基础架构层进行封装,以便在其他层中更方便地使用。封装可以包括对第三方库的初始化、配置以及封装特定的操作接口。

  • 仓储接口和适配器: 在基础架构层中定义仓储接口,以及不同数据存储介质的适配器实现。这样可以将数据访问操作与领域层解耦,同时实现数据存储的切换。

  • 中间件实现: 如果系统使用了中间件,如缓存、消息队列等,你可以在基础架构层实现中间件的具体操作。这有助于将与中间件相关的逻辑隔离在基础架构层中。

  • 事件驱动实现: 如果系统采用了事件驱动的架构,你可以在基础架构层实现事件的发布与订阅机制,以及事件的处理逻辑。

    image-20230822231211769

    如上图,使用redis提供缓存,使用kafka提供消息队列,使用guava提供事件驱动,仓储层负责实现仓储功能

Application层:

Application层用于组合领域内的服务,形成具体的应用用例。它不包含具体的业务逻辑,而是通过调用领域内的服务来实现功能。在这一层,你可以有以下的组织方式:

  • 应用服务: 应用服务负责处理用户请求,协调领域内的服务,形成具体的用例。每个应用服务通常对应一个用户操作,它们应该是轻量级的,不涉及具体的业务逻辑。
  • DTO(数据传输对象): DTO负责承接前端传入的数据,为领域层转换为对应的业务参数。它们将用户输入的数据进行封装,以便传递给领域层进行处理。
  • 数据转换: 在应用层,你可能需要将领域对象转换为DTO,用于与UI层进行数据交互。数据转换负责将领域对象的数据映射到DTO中,只暴露需要的数据字段。

UI层:

UI层负责展示数据和接收用户输入,它不包含业务逻辑,只是通过调用Application层来触发业务流程。在这一层,主要形式有 api,job和视图页面等等

总结

当我们将三层架构向DDD演进时,我们逐步重塑我们的代码组织,让领域层成为核心,包含实体、值对象、聚合根和领域服务,以最佳方式捕捉业务逻辑和规则。基础架构层负责提供通用能力,如仓储实现、中间件封装等,为领域层提供支持。应用层负责将领域内的服务组合成具体的应用用例,通过调用领域服务实现功能。最后,UI层负责与用户交互,通过调用应用层触发业务流程。这种结构使得不同层次之间的耦合度降低,代码变得更清晰、可维护和可扩展。

在我们演进的过程中,重要的是不仅仅是技术层面的变化,更是对于业务的深入理解和把握。DDD不仅仅是一种架构模式,更是一种用于探索和应对复杂业务的方法论。通过将DDD思想融入我们的架构设计中,我们能够更好地应对日益复杂的业务需求,使得我们的系统更具弹性和适应性,从而为日后面临复杂的业务奠定基础。

在下一讲主要讲下DDD在实际落地中碰到的问题和解决方案,欢迎关注。

领域驱动设计(DDD):从基础代码探讨高内聚低耦合的演进

在2019年我初次接触到领域驱动设计(Domain-Driven Design,简称DDD)的概念。在我的探索中,我发现许多有关DDD的教程过于偏重于战略设计,充斥着许多晦涩难懂的概念,导致阅读起来相当艰难。有些教程往往只是解释了DDD的概念,而未深入探讨为何要采用这种方式以及这样做能带来哪些好处,这导致很多人在实践应用DDD时遇到了诸多难题。甚至有些人为了引入DDD而在项目中强制采用DDD架构,结果却意外增加了代码的复杂性,带来了一系列潜在的风险。

为了解决这一问题,我计划从代码的基础入手,详细讲解如何将DDD的理念应用于实际开发中,以便解答为何DDD能使我们的代码更加整洁的问题。今天,我们将着重讨论如何运用DDD的思想来组织我们的代码,从而实现”高内聚、低耦合”的开发目标。

在接下来的讨论中,我将与大家分享我在将DDD理念融入实际项目中的一些心得和体会,以及如何在现实项目中充分发挥DDD的优势。无论是战略设计还是战术实施,我都将尽可能以通俗易懂的方式进行解释,希望能够帮助大家更好地理解和应用DDD,从而在编码的道路上越走越远。

首先,让我们看一个电商系统中下单功能的代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@Autowired
ProductDao productDao;
@Autowired
UserDao userDao;
public void createOrder(String productId,String userId,int count){
Product product = productDao.queryById(productId);
UserInfo user=userDao.queryByUserId(userId);

//风控检测
RiskResponse riskRes= riskClient.queryRisk(xxx);
if(riskRes!=null&&riskRes.getCode()==0&&riskRes.getRiskCode().equal("0001")){
//命中风控
throw new BizException("下单失败,请检查网络")
}

Order order=new Order();
order.setOrderId(IdUtils.generateId());
order.setPrice(product.getPrice()*count);
order.setAddress(user.getAddress());
order.setStatus(OrderEnum.OrderSucess);
orderDao.insert(order);

//预热缓存和增加记录
redisService.set("Order:OrderID_"+order.getOrderId(),order);
orderLogDao.addLog(order);

MessageEntity message = new MessageEntity();
message.setOrderId(order.getOrderId());
message.setMessage("下单成功");
kafkaSender.sent(messageEntity);

}

代码分析

首先,我们对这段代码的逻辑进行整理,共涉及5个步骤:

  1. 查询商品和用户信息
  2. 下单行为的风控检测
  3. 订单创建和持久化
  4. 写入缓存和记录下单日志
  5. 发送订单下单成功消息,通知其他系统

我们从这几个过程入手,根据业务的重要性,我们可以将它们划分为核心业务和非核心业务。显然,下单及其相关操作属于核心代码(步骤1、2、3)。与此相比,写日志、写入缓存以及发送Kafka消息则属于下单过程的非核心业务

核心代码分析

1. 【查询商品和用户信息】

productDaouserDao 这两个类是用于封装数据库的增删改查(CRUD)操作。然而,这种封装方式的问题在于,它们的方法实现与具体的数据存储介质密切相关,导致我们的业务逻辑对数据存储方式有着强烈的依赖。

举个例子来说明:当前情况下,我们的数据存储介质是MySQL数据库,因此 userDaoproductDao 类中的方法都是基于SQL语句的封装。然而,如果以后需要更换不同的数据访问框架,或者将数据存储从MySQL迁移到Elasticsearch(ES),我们就必须修改 userDaoproductDao 类的实现,以适应新的数据存储方式。这样的操作不仅会对核心业务代码产生影响,还会在项目的各个角落引发不确定性,从而导致每一次的代码优化都需要小心谨慎地进行。

这种紧密耦合的情况,除了增加了代码维护的难度,还可能引发系统的脆弱性。一旦需要改动存储层的实现,就必须在整个项目中寻找并修改所有与之相关的代码。这不仅消耗时间,还增加了出错的可能性。

2.【下单的行为的风控检测】

在订单生成的过程中,我们会调用风控查询的接口,这一步骤可以被视为对第三方应用的一种依赖。这种依赖关系迫使我们在处理返回值时必须非常仔细,涵盖判断返回值是否存在、验证成功的响应状态、以及业务代码的验证等多个环节,以确保我们的代码具备足够的健壮性。

然而,这种依赖关系同时也带来了潜在的问题,即我们的核心业务逻辑可能会随着第三方接口的变更而需要进行修改。以一个实际例子来说明,假设风控接口新增了代表风控生效的业务代码,如00020003等,那么我们的核心代码就必须相应地进行调整,例如:

1
riskRes!=null&&riskRes.getCode()==0&&(riskRes.getRiskCode().equal("0001")||riskRes.getRiskCode().equal("0002")||riskRes.getRiskCode().equal("0003")),

然而,这种改动会使代码变得难以阅读和维护。每次接口变更,我们都需要在多处代码中进行类似的修改,而且这些修改会在整个代码库中产生涟漪效应,导致代码的耦合度上升,可维护性下降。

3. 【订单的创建和持久化】

关于持久化的问题,上面已经有过详细的讲解,因此不再赘述。

在观察订单的创建过程时,我们发现这属于核心业务的关键部分。然而,仔细思考下,我们发现实际需要的其实是订单创建的结果。因此,将这个过程放在核心的业务代码中,可能会对代码的可读性产生不良影响。

很多朋友看到这里可能会想到,我们可以将订单的创建过程独立出来,以此来减轻核心业务对订单创建过程的依赖。当然,这是一个合理的解决方案,许多DDD实践也是这么做的。 我个人更倾向于采用实体的工厂模式来创建实体,以此进一步解耦实体的创建过程。

如果我们采用这种方式,我们可以更好地组织和管理代码,使其更易于阅读和理解。同时,这也能够避免在核心业务代码中过度混合不同的功能,从而提高代码的可维护性。

在订单创建过程中,有两个属性的赋值操作

1
2
order.setOrderId(IdUtils.generateId());
order.setPrice(product.getPrice() * count);

这两个赋值语句背后蕴含着更深层次的业务意义。其中,生成订单号是为了唯一标识每个订单,确保订单信息的准确无误;而计算订单价格则是根据产品数量和单价进行计算,确保订单金额的准确性。

然而,在传统的贫血模型中,这些隐藏在赋值语句背后的业务意义并没有得到明确的定义和体现。当类似的业务代码分散在各个类或服务中时,会导致业务代码呈现出碎片化的状态,无法形成有机的整体。在进行修改和维护时,我们需要在整个代码库中搜索引用,分别进行修改,这无疑增加了维护的难度和成本。

非核心代码分析

在领域驱动设计(DDD)中,我们通常将系统划分为三个主要部分:核心域、通用域和支撑域。

  1. 核心域:这是业务的核心部分,包括业务的核心规则和业务流程。在这个例子中,下单动作及其依赖的数据应该是核心域的一部分。

  2. 通用域:这个部分包含了一些跨领域的业务逻辑,比如缓存、日志记录、通知等。在这个例子中,下单后写入缓存、写入下单日志和通知都属于通用域。

  3. 支撑域:这个部分包含了一些基础设施和公共代码,比如数据库访问、网络通信、错误处理等。

对于如何拆分, 并没有固定的规则,需要根据具体的业务需求来确定。在这个例子中,由于下单动作及其依赖的数据是核心,而下单后写入缓存、写入下单日志和通知属于其他领域,所以应该采用领域间的交互方式进行拆分。也就是说,下单动作应该在核心域中完成,而写入缓存、写入下单日志和通知等操作则通过领域间的方式进行调用。这样可以保证核心域的内聚性,同时也可以降低不同领域之间的耦合度。

代码优化与领域驱动设计

问题分析总结

结合以上讨论,我们归纳出上述代码存在的问题:

  1. 第三方接口的业务无关性影响核心业务可维护性,容易引发对核心代码的频繁修改,降低代码的稳定性和可维护性。
  2. 业务逻辑与数据存储紧耦合,难以实现逻辑的复用和数据存储的切换,扩展性受限。
  3. 核心业务中掺杂了与核心业务无关的代码片段,影响代码的可读性,理解核心逻辑需要分离非关键细节。
  4. 实体内的业务逻辑分散在代码的不同地方,导致业务逻辑零散、难以维护。
  5. 领域间存在强耦合,对其他领域的修改容易对当前核心逻辑造成意外影响,增加系统的脆弱性和改动的风险。

针对这些问题,我们需要考虑采用领域驱动设计(DDD)的原则和方法,以及相应的重构策略,来优化和改善代码结构,提高代码的可维护性、扩展性和稳定性。

代码优化实践

为了解决上述问题,我们引入了DDD的思想,通过优化核心业务代码和拆分通用业务逻辑,使代码更加整洁和可维护。以下是我们对代码的优化方案和具体实现:

1. 适配器模式隔离第三方接口

原始代码中的风控查询接口可能会变化,因此我们引入了适配器模式,将第三方接口的调用从核心业务代码中分离出来。具体地,我们创建了一个 RiskCheckAdapter 类来封装风控查询逻辑,并将返回值转化为业务领域的语言。这样,核心业务只关心风控是否通过,而不关心具体的返回值和变化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Service
public class RiskCheckAdapter {
@Autowired
private RiskClient riskClient;
public boolean isRiskDetected(String productId, String userId) {
RiskResponse riskRes = riskClient.queryRisk(xxx); // 根据实际情况传入参数
return riskRes != null && riskRes.getCode() == 0 && riskRes.getRiskCode().equals("0001");
}
}

@Service
public class RiskCheckService {
@Autowired
private RiskCheckAdapter riskCheckAdapter;

public boolean isRiskCheckPassed(String productId, String userId) {
return riskCheckAdapter.isRiskDetected(productId, userId);
}
}

2. 仓储模式解耦数据访问

为了解决核心业务与数据存储的紧密耦合问题,我们引入了仓储模式。通过创建抽象的仓储接口和具体的实现,我们将核心业务与数据访问解耦。这样,如果数据存储介质发生变化,只需修改对应的仓储实现,而不影响核心业务代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
// IProductRepository.java
public interface IProductRepository {
Product findById(String productId);
}

// IUserRepository.java
public interface IUserRepository {
User findByUserId(String userId);
}

// IOrderRepository.java
public interface IOrderRepository {
Order add(OrderDO order);
}

// IOrderLogRepository.java
public interface IOrderLogRepository {
void addLog(OrderLogDO log);
}

// ProductRepository.java
@Repository
public class ProductRepository implements IProductRepository {
@Autowired
private ProductDao productDao;

@Override
public Product findById(String productId) {
return productDao.queryById(productId);
}
}

// UserRepository.java
@Repository
public class UserRepository implements IUserRepository {
@Autowired
private UserDao userDao;
@Override
public User findByUserId(String userId) {
return userDao.queryByUserId(userId);
}
}

// UserRepository.java
@Repository
public class OrderRepository implements IOrderRepository {
@Autowired
private OrderDao orderDao;
@Autowired
private OrderConvert orderConvert;
@Override
public int save(OrderDO order) {
OrderPO orderPO=orderConvert.convertPO(order);
return orderDao.insert(orderPO);
}
}

// OrderLogRepository.java
@Repository
public class OrderLogRepository implements IOrderLogRepository {
@Autowired
private OrderLogDao orderLogDao;
@Override
public void addLog(OrderLog log) {
orderLogDao.addLog(log);
}
}


经过上面的组合后,我们可以看下前后的依赖对比,从图中可以看出,service层已经对数据不再有数据依赖。

image-20230818231458476

3. 充血模式收敛实体业务逻辑

通过将实体的业务逻辑进行收敛,我们可以提高代码的内聚性和可读性。原始的贫血模式中,订单实体的业务逻辑分散在各处,使得代码难以维护。现在,我们使用充血模式,将订单的创建和属性设置封装在实体内部,提高了代码的聚焦度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public class Order {
private String orderId;
private int count;
private double totalPrice;
private String address;
private int status;

public void createOrder(Product product, User user, int count) {
this.address = user.getAddress();
this.status = OrderEnum.OrderSuccess;
this.generateOrderId();
this.calculateTotalPrice(product.getPrice(), count);
}

private void generateOrderId() {
this.orderId = IdUtils.generateId();
}

private void calculateTotalPrice(double price, int count) {
this.count = itemCount;
this.totalPrice = price * count;
}

// ...其他属性和方法
}

public class OrderFactory {
public static Order createOrder(Product product, User user, int itemCount) {
Order order = new Order();
order.createOrder(product, user, itemCount);
return order;
}
}

//使用方式
Order order = orderFactory.createOrder(product, user, count);
orderDao.insert(order);

  1. 领域事件解耦领域间通信

    在解耦领域之间的通信方面,我们引入了领域事件。通过定义领域事件、事件监听器以及事件发布机制,不同领域之间的交互变得更加松耦合。这样,当订单创建完成时,我们只需发布订单创建事件,其他领域根据事件进行响应,降低了领域间的依赖性。示意图如下:
image-20230817230819455

事件的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// CommonEventListener.java
@Service
public class CommonEventListener {

@Autowired
private IOrderLogRepository orderLogRepository;

@Autowired
private RedisService redisService;

@Autowired
private KafkaSender kafkaSender;

@EventListener
public void handleOrderCreatedEvent(OrderCreatedEvent event) {
String orderId = event.getOrderId();
Order order = getOrderDetailsFromRepository(orderId);

// Process the event within the domain object
order.processOrderCreatedEvent(orderLogRepository, redisService);
sendMessage(order);
}

private Order getOrderDetailsFromRepository(String orderId) {
// Retrieve order details from repository using orderId
// Return the Order object
}

private void sendMessage(Order order) {
MessageEntity message = new MessageEntity();
message.setOrderId(order.getOrderId());
message.setMessage("下单成功");
kafkaSender.send(message);
}
}

优化后的核心业务代码

经过上述优化,核心业务代码变得更加清晰和可维护。以下是优化后的订单创建过程的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// OrderServiceImpl.java
@Service
public class OrderServiceImpl implements OrderService {
@Autowired
private ApplicationEventPublisher eventPublisher;
@Autowired
private IProductRepository productRepository;
@Autowired
private IUserRepository userRepository;
@Autowired
private IOrderRepository orderRepository;
@Autowired
private IOrderFactory orderFactory;
@Autowired
private RiskCheckService riskCheckService;

@Override
public Order createOrder(String productId, String userId, int count) {
Product product = productRepository.findById(productId);
User user = userRepository.findByUserId(userId);

boolean isRiskPassed = riskCheckService.isRiskCheckPassed();

if (!isRiskPassed) {
throw new BizException("下单失败,请检查网络");
}

Order order = orderFactory.createOrder(product, user, count);
orderRepository.save(order);

// Publish OrderCreatedEvent
eventPublisher.publishEvent(new OrderCreatedEvent(order.getOrderId()));
return order;
}
}

总结:

通过领域驱动设计的思想,我们成功地对原始的代码进行了优化。引入适配器模式、仓储模式、充血模式和领域事件等概念,使得代码更加整洁、可读和可维护。这些优化不仅使核心业务更加稳定,也为未来的扩展和变化提供了更好的支持。

在下一讲中,我们将探讨如何在项目架构中演进DDD,并提供一个简洁的项目框架作为示例。感谢大家的关注!

Your browser is out-of-date!

Update your browser to view this website correctly.&npsb;Update my browser now

×