将领域驱动设计原则应用于 Nest.js 项目
我们的架构
通过我们的服务器的典型流程
概括
嘿,今天我将撰写有关如何将领域驱动设计 (DDD) 原则应用到 Nest.js 项目的文章。
我创建了一个快速存储库,用于展示本博客中解释的架构。点击此处查看。
免责声明
- 我绝不是 DDD 方面的专家,我们只是在重写服务器时决定将其用于Repetitio 。
- 我不会在本博客中介绍什么是 DDD,请参考此dev.to 博客文章或DDD 圣经。
- Nest.js - 一个渐进式 Node.js 框架,用于构建高效、可靠且可扩展的服务器端应用程序。它沿用了 Angular 的架构风格,并完全使用 TypeScript 构建。
为什么选择 Nest?
Nest 对代码结构有自己的看法,这在 DDD 场景下非常有效,因为你需要为代码设置严格的边界,以保持代码的可维护性和可读性。如果你正在为 Node.js 应用程序寻找一个强大且可扩展的框架,我强烈推荐 Nest.js。
那么我们为什么要这么做呢?
在生产环境中使用 Nest.js 大约 4 个月后,由于项目本身没有明确的界限,导致服务器和 API 的代码变得杂乱无章,代码services
长度超过 600 行。
这绝不是 Nest.js 的反映,而是没有实施严格规则和界限的代码库会发生什么情况的反映。
在决定重写服务器之后,我们决定加入潮流并开始研究 DDD 作为前进的方向。
因此,我们最终希望从这次练习中得到的是一个从设计本质上来说可驯服的代码库。
你是怎样做到这一点的?
我们发现 DDD 为您的代码勾勒出清晰的结构,这不仅有意义,而且可以最大限度地减少存在于您的领域各个方面的代码/方法/类的数量。
以我们之前的服务器为例,我们之前有一个名为 的服务user.service.ts
,它包含了用户的所有逻辑。现在,与大多数应用程序一样,用户往往占据中心位置,并封装了大量的领域逻辑。因此,我们的服务变得非常庞大,涵盖了所有的用户逻辑。阅读和理解每个方法想要实现的目标以及执行的操作变得非常困难。
因此,让我们使用这个例子并对其应用一些 DDD 逻辑,首先我们决定对我们的领域层非常严格,因此在用户领域中我们只关心我们的用户领域模型,所有其他关系都被抽象到一个新的领域层或子领域中。
我们保持了简洁的使用方式,为每个操作创建一个类,而不是将它们全部集中到同一个无所不能、无所不知的服务中。通过简化,我们的领域层变得非常实用,每个类通常只执行一个操作,其内部的方法也进一步体现了这一点。
我们的架构
这是我们的架构:(使用非常通用的用户作为不同文件和服务的示例)
src/
/API
/User
UserController.ts
CreateUserDTO.ts
APIModule.ts
/Auth
AuthModule.ts
/Database
DatabaseModule.ts
/Domain
/User
CreateUser.ts
IUserRepository.ts
User.ts
UserModule.ts
/Persistence
/User
UserRepository.ts
UserRepositoryModule.ts
UserEntity.ts
/Utils
/Mappers
/User
CreateUserDTOToUser.ts
/Services
/Email
EmailSenderService.ts
AppModule.ts
Environment.ts
main.ts
本例中是一个 Nest模块Module
。我们决定每个域都有自己的模块,每个持久化实体也一样。但是,我们决定将所有 API 端点集中到一个模块中,因为唯一需要导入的APIModule
是AppModule
应用程序引导程序。
API
这一层包含我们所有的端点和控制器。我们尝试让 Controller 类映射域实体。因此,如果我们有一个用户域,我们就会有一个用户控制器。这种模式也扩展到了持久层。
这也是声明任何 DTO(发送或接收)的明显空间。
授权
这是我们所有的防护、策略和一般身份验证配置所在的位置。
数据库
您猜对了,这一层负责连接任何类型的数据存储,或者多个数据存储。
领域
这是我们代码库中最重要的部分。领域模型反映了我们试图解决的问题。它被分解成多个领域模型(每个模型都有各自的文件夹)。在我们的例子中,我们的领域模型是一个 TypeScript 类型文件。
我们希望我们的领域层不受第三方组件的影响,因此在领域内应该只引用我们自己的代码。我们不希望看到 MongoDB 模式、第三方包、任何数据库特定逻辑的引用,以及任何与 API 层相关的内容。
我们定义了一个模拟存储库层的接口。由于它与领域层直接相关,因此它被定义在领域层中。我们在此概述了如何修改领域模型,因此无需关心具体实现(这是持久层的工作)。
我们在领域层遵循一些规则:
- 领域操作应该仅接受领域模型作为参数或字符串形式的 ID。在调用领域层之前,应该先映射 DTO。
- 将所有第三方库、包等放在领域层之外。它应该不依赖任何第三方。
- 它应该只引用领域层中存在的代码
- 理论上,您应该能够将您的域层剪切并粘贴到任何项目(依赖于语言)中,并且它应该可以工作。
无依赖域
最大的心理挑战之一是以这样的方式组织您的代码,即您的域层仅依赖于域层内的其他类和文件。
例如,为了与持久层通信,我们可以将UserRepositoryModule
作为依赖项引入到我们的持久层UserModule
,但这违背了 DDD 的一个关键组件——无依赖领域。这也是我们拥有User.ts
(领域)和UserEntity.ts
(持久层)的原因。一个是我们的领域模型,以其最纯粹的形式呈现。另一个是我们的领域模型,但添加了针对任何数据存储的属性/功能。
一种方法(感谢SeWaS向我展示如何操作)我们可以使用接口注入,而不是典型的模块注入来与持久层进行通信。
DomainAction.ts
只是一个通用名称,代表我们的域将执行的许多操作。
// Domain/User/DomainAction.ts
import { Injectable } from '@nestjs/common';
import { Injectable, Inject } from '@nestjs/common';
import { UserRepository } from '../../Persistence/User/Repository';
import { IUserRepository } from './IUserRepository';
const UserRepo = () => Inject('UserRepo');
@Injectable()
export class DomainAction {
constructor(
@UserRepo() private readonly userRepository: IUserRepository,
) {}
}
// Persistence/User/UserPersistenceProvider.ts
import { Provider } from "@nestjs/common";
import { UserRepository } from "./Repository";
export const UserRepoProvider: Provider = {
provide: 'UserRepo',
useClass: UserRepository
}
// Persistence/User/UserRepositoryModule.ts
import { UserRepoProvider } from './UserPersistenceProvider';
@Module({
providers: [UserRepoProvider],
exports: [UserRepoProvider],
})
export class UserRepositoryModule {}
域结构
在我们的领域模型上执行的每个领域操作都应该构成一个独立的文件和类。这个类的名称应该清晰明确,不让人猜测它的用途。
例如:
/Domain
/User
Create.ts
Update.ts
Delete.ts
GetEmail.ts
RemoveToken.ts
IUserRepository.ts
UserEntity.ts
UserModule.ts
我们甚至可以从类名中删除用户,因为它隐含地位于用户域内。
持久性
我们的持久层是执行所有数据库查询的地方。它将包含实体的模块及其存储库。从这个意义上讲,存储库是一个包含所有数据库操作的类。同样,它应该与我们的领域实体 1:1 对应,并且通常只包含大约 4/5 个方法 - 主要是 CRUD 操作。与领域层不同,我们将多个操作耦合在同一个类中。
实用程序
这些通常共享我们整个领域所需的功能。
这是我们存储所有将数据传输对象映射到域模型以及反之亦然的映射器的地方。
它是存储单例服务的好地方,例如电子邮件发送服务。
通过我们的服务器的典型流程
由于我觉得有必要展示一些代码,我将展示一些代码片段,展示如果我们在一个纯粹虚构的服务器中创建一个非常基本的用户,这一切看起来会是什么样子。显然,我在这里排除了导入语句。
// the user DTO we receive from the client
export class CreateUserDTO {
@IsString()
@IsNotEmpty()
public name: string;
@IsString()
@IsNotEmpty()
public password: string;
}
类验证器非常适合验证,将其与AuthGuard
(在层中Auth
)结合起来,您可以在一个地方处理代码的所有异常并处理响应对象。
// UserController.ts
@Controller('user')
export class UserController {
constructor(
private readonly user: Create,
) {}
@Post()
public async Register(@Body() createUser: CreateUserDTO): Promise<HttpStatus> {
// all our mappings get done in static classes
const domainModel: User = UserMap.mapCreateDTOToUserModel(createUser);
await this.user.Create(domainModel);
return HttpStatus.OK;
}
由于我们的领域只关心我们的领域,我们需要确保所有 API 层相关对象都映射到适当的领域模型,因此我们的映射发生在 API 层。
// /Domain/User/Create.ts
const UserRepo = () => Inject('UserRepo');
const EmailService = () => Inject('EmailService');
@Injectable()
export class Create {
constructor(
@UserRepo() private readonly userRepository: IUserRepository,
@EmailService() private readonly email: IEmailSenderService,
) {}
public async Register(user: User): Promise<void> {
const registeredUser: User = await this.repository.Create(user);
await this.email.SendEmail(registeredUser.email, EmailOptions.AccountCreationEmailOptions, {
userId: registeredUser._id,
});
}
}
这EmailSenderService
是我们域中共享逻辑的一个例子,它存在于Utils
上述层中。
// /Persistence/User/UserRepository.ts
@Injectable()
export class UserRepository implements IUserRepository {
constructor(@InjectModel('User') private readonly user: Model<UserEntity>) {}
public async Create(newUser: User): Promise<UserEntity> {
return new Promise<UserEntity>((resolve, reject) => {
const createdUser: UserEntity = new this.user(newUser);
this.user.create(createdUser, (err: GenericError, addedEntity: UserEntity) => {
if (err) {
reject(err);
}
resolve(addedEntity);
});
});
}
}
我们还可以看到需要有两种类型的User
,一种用于我们的领域,它是我们领域模型的最纯粹形式,另一种存在于我们的持久层中,它与我们的领域模型非常相似,但包含第三方(在本例中为与数据库相关的)属性或特定于我们的持久层的逻辑。
这进一步强调了我们的领域层需要纯净,不受任何第三方工件的影响。
概括
这是我们在 Repetitio 所采用的架构的简要概述。通过严格遵守这些规则,我们发现我们的代码库变得非常易于管理,这与以前截然不同!服务器拥有清晰的逻辑层,易于导航和理解,使我们能够轻松地深入研究并根据不断变化的需求进行迭代。
我创建了一个快速存储库,用于展示本博客中解释的架构。点击此处查看。
鏂囩珷鏉ユ簮锛�https://dev.to/bendix/applying-domain-driven-design-principles-to-a-nest-js-project-5f7b