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

Domain Driven Design: Hierarchies, Repositories and access

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

我有以下层次结构:

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

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

如果我们想象一个 class 的课程,它会是这样的:

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

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

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

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

说到这里,我的疑惑是:

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

选项 A)

let mySection = SectionRepository.getById(sectionId);

Section.updateLessonTitle(lessonId, 'new title');

SectionRepository.save(mySection);

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

选项 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 只能 return 其他聚合的只读版本,除此之外, SectionRepository 不应该访问 LessonRepository,对吗?因此,我们只有聚合,即 SectionSectionRepository 会负责保存所有内容。而且,如果我们上树(或下树),它会有更多的东西要存储。

在考虑这个问题时,我们将不胜感激。谢谢!

在您的例子中,Section 看起来像根聚合。 所以如果它是你想要的,你应该把它扔给你想要应用到聚合上的所有操作并保存它。

我认为你的选项 A 是正确的,也许你应该更深入地探索你的领域并找出为什么你需要处理每个 Entity/Value 聚合对象的每个 属性。

对于你的用例UpdateResourceLinkUseCase我会尝试类似的东西

let mySection = SectionRepository.getById(sectionId);

mySection.setRessourceLink(lessonId,resourceId,'new link'); 

SectionRepository.save(mySection);

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

来自您分享的这行代码:

let myResource = ResourceRepository.getResourceById(resourceId);

myResource.setLink('new link');

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

所以从我的 point-of-view 资源中可以在不同的课程中引用。如果这个假设适用,你应该考虑让你的引用成为它自己的聚合。然后你将只在你的课程中保留一个参考 ID 列表。我通常会使用显式值 Object 来表示对根实体的强类型引用,例如“ReferenceId”值 object class.

所以你的课程 class 应该看起来像这样:

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

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

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

请注意,根据您的业务需求,这种方法可能有意义也可能没有意义。但是,如果 Lesson -> Reference 关系在某种程度上与 Order -> User 关系相当,我认为它对你来说是一个可行的选择。

另一种选择是将您的课程本身视为聚合根。这也让您可以选择通过在不同课程中重复相同的课程,从而在不同课程中重复相同的课程来编译课程。

在这种情况下,章节中的课程 ID 也将引用课程(如果需要,还可以引用一些附加信息,例如标题,具体取决于您的需要)。问题通常是,聚合真正需要什么样的信息才能正确应用其业务规则和不变量。

更新

我想进一步详细说明您在评论中提出的其他问题。

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

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

如果您需要对课程聚合执行业务操作并且需要资源数据(不仅仅是资源 ID),我会使用资源存储库通过资源存储库根据其 ID 加载资源,然后将需要的数据传入对应的Lesson域操作

例如如果您处理作者的更改请求,假设本课中资源的标题(考虑到您在一节课中对同一资源有不同的标题),我会使用值 object,类似于 LessonResource。此值 object 将包含资源聚合的引用 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 请求传入的数据,它仅保存此歌剧所需的数据离子.

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

2.) 并且,假设我有一个用例来更改某个部分中课程的顺序。 section保存的是lessonsId,需要修改和存储的Lessons。 Section 域应该访问 LessonRepository 吗?

在这种情况下,我将使用 SectionLesson 值 object,它包含课程的 ID 以及顺序(位置)。

在这种情况下,您甚至不必获取课程聚合数据即可对部分聚合执行域操作。

用例可能如下所示:

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

您的 changeLessonOrder() 方法可以负责对本节中的课程进行重新排序。

注意:根据您的实施和要求,发送用户(作者)在 UI 到后端。无论哪种方式,您也只需要一个排序的课程 ID 列表即可执行节域操作,而无需访问任何 LessonRepository。