域驱动设计:层次结构、存储库和访问



我开始学习域驱动设计,一些问题突然出现在我的脑海中。让我们想象一下,我正在构建一个电子学习应用程序。

我有以下层次结构:

  • 一个Section可以有0个或多个Lesson
  • 一个Lesson可以有0个或多个Resource

所有实体的id都是全局唯一的

每个实体都充当自己的聚合,但包含对其他聚合的引用。例如:Lesson通过id引用Section

如果我们想象第一课的课堂,它会是这样的:

class Lesson {
id : string,
sectionId : string,
order: number,
title: string,
description: string,
resources: Resource[]
}

如果我们想象Section的类是这样的:

class Section {
id: string,
lessons: Lesson[]
}

现在,我可以在不访问Section的情况下修改Lesson名称或描述(因为id是全局的),因为没有不变的规则可以打破。但是,如果我修改Lesson的顺序,那么通过Section进行修改是有意义的,因为我需要跟踪特定部分中的课程以保持不变。

话虽如此,以下是我的疑虑:

  1. 所有修改都通过Section类运行。在这种情况下,如果我正在执行UpdateLessonTitleUseCase,我认为它可能是这样的:

选项A)

let mySection = SectionRepository.getById(sectionId);
Section.updateLessonTitle(lessonId, 'new title');
SectionRepository.save(mySection);

但是,如果我需要为每个属性公开一个方法,那就很痛苦了(因为树可能会长得很深,或者某个特定的类有很多属性)。另一种选择是这样的:

选项B)

let mySection = SectionRepository.getById(sectionId);
let myLesson = mySection.getLessonById(lessonId);
myLesson.setTitle('new title');
mySection.updateLesson(myLesson);
SectionRepository.save(mySection);

如果我正在执行ChangeLessonsOrderUseCase,它将是这样的:

let mySection = SectionRepository.getById(sectionId);
mySection.updateLessonsOrder(orderData); // orderData is an array of {lessonId: string, order: number}
SectionRepository.save(mySection);

如果我正在执行UpdateResourceLinkUseCase,它将是这样的:

let mySection = SectionRepository.getById(sectionId);
let myLesson = mySection.getLessonById(lessonId);
let myResource = myLesson.getResourceById(resourceId);
myResource.setLink('new link'); 
myLesson.updateResource(myResource); 
mySection.updateLesson(myLesson); 
SectionRepository.save(mySection);

在这种情况下,Lesson不可能是它自己的集合,对吧?因为Section只能返回其他聚合的只读版本,除此之外,SectionRepository不应该访问LessonRepository,对吧?所以,我们只有一个集合,这就是Section,而SectionRepository将负责保存所有内容。而且,如果我们上树(或下树),它会有更多的东西要储存。

在考虑这一点时,任何帮助都将不胜感激。谢谢

这听起来像是我也遇到过几次的问题。根据我的经验,我了解到,这种从聚合根到似乎位于层次树下游的实体的深度嵌套访问有时表明您没有适当地发现和建模聚合。

从你分享的这行代码:

let myResource = ResourceRepository.getResourceById(resourceId);
myResource.setLink('new link');

看起来您只是在更改Resource实体的内容。我不知道您的域模型和业务需求的详细信息,但我认为在您的情况下,您至少有一个额外的聚合根,可以独立生存,而不仅仅是Section聚合的子级。我想这会让你的生活轻松很多。

因此,从我的角度来看,资源是可以在不同的课程中参考的东西。如果这一假设适用,您应该考虑将您的参考作为自己的聚合。然后,您将在课程中只保留一个参考ID列表。我通常会使用显式值对象来表示对根实体的强类型引用;引用Id";值对象类。

所以你的课看起来应该是这样的:

class Lesson {
id : string,
sectionId : string,
order: number,
title: string,
description: string,
resources: ResourceId[]
}

因此,在这种情况下,更改资源的链接将直接通过ResourceRepository进行管理,而不是从Section聚合开始遍历整个树。

这种方法带来了另一个好处:如果你在不同的课程中引用了相同的资源,你不需要到处更新它,因为资源只由标识符引用。当然,在提供数据时,您需要更改您的方法,因为您只存储了一个引用。但根据我的经验,在许多情况下,只有在某些前端查看数据时才需要实际数据,而不是执行域逻辑时才需要。因此,当您在渲染视图模型时知道引用时,就变得很容易了,因为仅在查看数据时,您实际上不需要遍历域存储库,但您可以从数据库中以需要的方式访问所有数据。查看课程时,资源id允许您获取渲染所需的任何资源数据。

请注意,根据您的业务需求,这种方法可能有意义,也可能没有意义。但是如果课程->引用关系在某种程度上类似于订单->用户关系我认为这对你来说是一个可行的选择。

另一种选择是将你的课程本身视为聚合根。这也将使你可以选择通过在不同的课程中复读相同的课程来编译课程,从而获得不同课程的部分。

在这种情况下,章节中的课程ID也会引用课程(如有必要,还可以根据您的需要提供一些附加信息,如标题)。通常的问题是,聚合真正需要什么样的信息才能正确应用其业务规则和不变量。

更新

我想进一步阐述你在评论中提出的其他问题。

1.)例如,谁负责从课程中获取实际资源

如果你只想在UI上获取用于查看章节或课程的数据,我想使用阅读模型。在这种情况下,您可以绕过聚合存储库,因为您不更改任何数据。对数据库的读取访问应该以最适合查看数据的方式进行。这是一种完全有效的方法,还可以防止不必要的性能问题,因为通过域存储库加载聚合通常是为执行一致的业务事务而设计的,同时保持业务逻辑不变。此外,在大多数情况下,它通常侧重于更改单个聚合。

如果您需要在Lesson聚合上执行业务操作,并且需要资源数据(而不仅仅是资源id),我会使用资源存储库通过资源存储库根据其id加载资源,然后将所需数据传递到相应的Lesson域操作。

例如,如果你处理一个作者的更改请求,比如说本课中资源的标题(考虑到你在一节课中有相同资源的不同标题),我会使用一个值对象,类似于LessonResource。该值对象将包含Resource聚合的引用id和仅在本课程中使用的标题。

应用程序层的工作流(或您所指的用例)可能如下所示:

let resource = resourceRepository.findById(changeResourceTitleCommand.resourceId);
let lesson = lessonRepository.findById(changeResourceTitleCommand.lessonId);
lesson.changeResourceTitle(
changeResourceTitleCommand.resourceId, 
changeResourceTitleCommand.title,
resource
);
lessonRepository.save(lesson);

注意:ChangeResourceTitleCommand将是一些简单的DTO,表示通过例如REST请求传入的数据,该请求只保存此操作所需的数据。

在这种情况下,让我们考虑一下您的Lesson域操作(changeResourceTitle())也需要来自Resource的信息。例如,如果该资源应使用全局一致的标题,则必须在所有课程中使用该标题。这可能不是现实生活中最好的例子,但我希望你能明白:-)

2.)并且,让我们假设我有一个用例来更改一节中课程的顺序。该部分包含lessonsId,并且需要修改和存储Lessons。Section域是否应该访问LessonRepository

在这种情况下,我将使用SectionLesson值对象,该对象包含课程的id以及顺序(位置)。

在这种情况下,您甚至不必获取Lesson聚合数据来对Section聚合执行域操作。

用例可能看起来像这样:

let section = sectionRepository.findSectionById(changeSectionOrderCommand.sectionid);
section.changeLessonOrder(
changeSectionOrderCommand.lessionId,
changeSectionOrderCommand.position
);
sectionRepository.save(section);

您的changeLessonOrder()方法可以处理该部分中的课程的重新排序。

注意:根据您的实现和需求,将用户(作者)在UI中定义的全新课程排序列表发送到后端可能更合适。无论哪种方式,您都只需要一个排序的课程ID列表即可执行Section域操作,而无需访问任何LessonRepository。

在您的案例中,Section看起来像根聚合。所以,如果这是你想要的,你应该为你想应用于聚合的所有操作放弃它并保存它。

我认为你选择A是对的,也许你应该更深入地探索你的领域,并找到为什么你需要处理你的集合中每个实体/价值对象的每个属性。

对于您的用例UpdateResourceLinkUseCase我将尝试类似的东西

let mySection = SectionRepository.getById(sectionId);
mySection.setRessourceLink(lessonId,resourceId,'new link'); 
SectionRepository.save(mySection);

最新更新