领域模型落地的关键之一——解决持久化问题

在DDD中,领域模型处于核心位置,负责表示业务概念、有关业务状况的信息和业务规则。如果用层次结构描述的话,领域模型处于领域层,这个层次在软件设计时不依赖于基础设施层。如果涉及到对基础设施的访问,在领域层需要定义相关的接口,而将接口的实现放到基础设施层,在运行时使用依赖注入将接口与接口的实现进行装配。这种结构就是控制反转,使得领域模型在设计时独立于基础设施的具体实现。涉及到持久化的存储库就是采用这种方式进行设计,在领域模型中,我们只定义存储库的接口,其操作的领域对象都是普通对象,也就是所谓的POCO,而将存储库的具体实现放在基础设施层,这样领域模型所在的项目不需要依赖任何持久化相关的类库,在实现存储库的组件中需要引用领域模型。

然而,“理想是丰满的,现实是骨感的”,当我们定义完成领域模型,创建了存储库接口,并且新创建一个组件用来实现这个接口时,就会发现问题不是那么简单。当我们选择持久化框架时,会发现很多框架所推荐的使用方式,是在需要进行持久化的类中增加数据标签,持久化框架通过这些标签与数据库实现对应。下面是一个常见的例子:

1
2
3
4
5
6
7
8
9
10
11
12
[Table(“Player”)]
public class Player
{
[Key]
public Guid Id { get; protected set; }
[Column("username")]
public string UserName { get; private set; }
[Column("nickname")]
public string NickName { get; private set; }
[Column("score")]
public int Score { get; private set; }

上面的代码很容易理解,Player这个实体的数据会保存在数据库表Player中,Id是关键字,每个属性都使用Column标记与数据库的列相对应。使用这种方式,在存储库实现中就不需要使用代码进行模型创建了。这样的好处是直观,数据库中表和字段与实体的对应关系一目了然,如果使用数据驱动的方式进行开发,数据实体的代码甚至可以自动生成。

然而,这种方式的缺点也是很明显的,在业务对象中引入了数据库定义,而业务对象本应该关注业务而不是具体的存储。想象一下,如果从零开始设计领域模型,我们甚至无法知道所设计的类将来是否需要持久化,有关数据库的部分显然是多余的,业务模型需要若干次迭代才能够逐渐成熟。那么是否可以在业务模型成熟后,再增加数据库相关的这些标记呢?也不是很好的办法,因为这样违反了“单一职责原则”:如果数据库结构发生了变化,而业务模型没有改变,仍然需要修改代码,比如将数据库表从Player改为Player_tb。

为了避免框架对领域模型的侵入,我们需要进行充分的技术准备,在实现存储库时,需要选择合适的持久化框架,并且掌握正确的使用方式。以EF Core为例,我们需要使用流畅API而不是数据标签进行领域模型与数据模型的对应:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using PoemGame.Domain.PlayerAggregate;

namespace PoemGame.Repository.EF
{
internal class PlayerEntityTypeConfiguration : IEntityTypeConfiguration<Player>
{
public void Configure(EntityTypeBuilder<Player> playerConfiguration)
{
playerConfiguration.ToTable("Player");
playerConfiguration.HasKey(o => o.Id);
}
}
}

还需要解决如下的问题:
1. 一对一关系的保存:这主要针对保存具有一对一关系的值对象的情况。应该可以很方便地将值对象的属性映射到数据库表的字段。
2. 一对多关系的保存:当保存聚合根相关的值对象集合和实体集合时需要使用这种方式。
3. 私有属性或字段映射:由于聚合根中的属性可能需要数据保护,没有公共的赋值函数,就需要框架有能够映射到私有属性或者字段的功能。
4. 忽略不需要保存的属性或字段:聚合根中有很多不需要持久化的属性或字段,比如保存领域事件的集合,框架应该能够忽略这些属性或者字段。

更多的内容可以参考《领域驱动设计.Net实践》一书。