本文翻译自https://github.com/kgrzybek/modular-monolith-with-ddd,项目采用MIT宽松许可
1. 领域
知识、影响或活动的领域,用户应用程序的主题区域是软件的域。
DDD中将系统分为UI层、应用层、领域层和基础设施层:
上层模块不应该依赖于下层模块,两者都应该依赖于抽象;
抽象不应该依赖于实现,实现应该依赖于抽象;
应用层是很薄的一层,因为它只负责接收UI层传来的参数和路由到对应的领域模型,它不负责处理具体的业务逻辑。系统的业务逻辑放在了领域层中,所以,领域层在系统架构中占据了很大的面积。下面以会议系统举例讲解。
1.1 描述
会议(Meetings)
- 主要的业务实体包括会议(Meeting)、会议组(Meeting Group)和会议成员(Member)。会议成员可以创建会议组,或者是会议组的一部分,或者能够参加会议。
- 会议组成员可以是会议组的组织者(Organizer)或者是普通成员。
- 只有会议组的组织者可以创建新的会议。
- 会议包括参与者(Attendees)和不参与者(not attendees)以及候选成员。
- 会议可以有与会限制条件,如果达到限制条件,成员只能在等待列表签到。
- 会与参与者可以携带宾客到会议,会议宾客数量是会议的一个属性,会议也可以禁止携带宾客。
- 会议参与者可以有两种角色:普通会议参与者和主办者。一个会议至少包括一个主办者,主办者就有特殊权限,包括更改会议信息和修改参与者列表。
管理(Administration)
创建新的会议组需要成员先提出创建会议组申请,申请会发送到管理员进行审核,管理员可以接受或者拒绝申请,接受申请后则会议组自动创建。
支付(Payments)
会议组的组织者需要支付会议组费用,会议组织者也可以向会议参与者及宾客收取费用。
用户(Users)
- 每个管理员、会议成员及支付者都是用户,想要成为用户,需要进行注册和审核。
- 每个用户可以拥有一个到多个角色。
- 每个角色包含一系列权限。权限定义了用户是否能够执行某个特定的行为。
1.2 概念模型(Conceptual Model)
概念模型用于信息世界建模,是现实世界到信息世界的第一层抽象,是数据库设计人员进行数据库设计的有力工具,也是数据库设计人员与用户之间进行交流的语言。因此,概念模型应该有较强的语义表达能力,另一方面它还应该简单、清晰、易于用户理解。最常用的就是E-R模型图。
1.3 事件风暴(Event Storming)
概念模型聚焦在域的结构及域间的关系,更重要的是域中的行为和事件。有很多方法可以展示行为和事件,一种非常流行的轻量级技术叫做Event Storming。
- 用户注册过程:
- 会议组创建:
- 会议组织:
2.架构
2.1 顶层视图
模块描述:
- API-REST API应用程序,主要职责:1.获取请求,2.认证和授权,3.委托工作到指定模块发送命令和查询,4.返回响应;
- 用户访问-用户认证、授权和注册;
- 会议-实现会议边界上下文:创建会议组和会议;
- 管理-实现管理边界上下文:实现管理任务比如用户组申请审核;
- 支付-实现支付边界上下文:实现跟支付相关的所有功能;
- 内存消息总线-异步订阅/发布事件,使用事件集成所有模块;
关键点:
- API不包括任何程序逻辑;
- API与模块通信使用一个小的接口来发送查询和命令;
- API用到的模块都有一个自己的接口;
- 模块间的通信只通过事件总线异步通信,不使用RPC;
- 每个模块拥有自己的数据,使用单独的数据库,不允许数据共享;
- 模块不应依赖其他模块,模块只依赖其他模块的集成事件程序集;
- 各个模块拥有自己的组合根(Composition Root),意味着每个模块都有自己独立的控制反转模块;
- API作为宿主需要初始化各个模块,每个模块都有一个初始化方法;
- 每个模块都是高度封装的,只有需要的类型和成员是公共的,剩下的都是内部和私有的;
2.2 模块级视图
每个模块包含以下几个子模块(程序集):
- 应用:子模块的主要部分,负责初始化、处理所有请求、内部命令及集成事件;
- 领域:领域驱动设计术语中在指定边界上下文中的领域模型;
- 基础设施:基础代码实现,如EF配置和映射;
- 集成事件:发布到消息总线上的集成事件契约,只有这个程序集可以跟其他模块共享;
2.3 API和模块通信
API与模块通信仅发生在两个地方:模块初始化和请求处理。
模块初始化
API在Startup类里调用各模块的静态的Initialize方法。所有该模块用到的配置需要作为参数传递到该方法。
public static void Initialize(
string connectionString,
IExecutionContextAccessor executionContextAccessor,
ILogger logger,
EmailsConfiguration emailsConfiguration)
{
var moduleLogger = logger.ForContext("Module", "Meetings");
ConfigureCompositionRoot(connectionString, executionContextAccessor, moduleLogger, emailsConfiguration);
QuartzStartup.Initialize(moduleLogger);
EventsBusStartup.Initialize(moduleLogger);
}
请求处理
各模块具有统一的接口暴露给API。该接口包含三个函数:执行带返回值的命令、执行不带返回值的命令和查询。
public interface IMeetingsModule
{
Task<TResult> ExecuteCommandAsync<TResult>(ICommand<TResult> command);
Task ExecuteCommandAsync(ICommand command);
Task<TResult> ExecuteQueryAsync<TResult>(IQuery<TResult> query);
}
注意:一些人认为处理命令不应该返回结果,这个个好的方式但是有时不实用,尤其是你创建了一个资源并且想马上获得资源ID的时候。有时命令和查询的边界是很模糊的,举个例子认证命令(AuthenticateCommand)返回了一个token但是它并不是一个查询。