TypeScript 中关于 DDD 的故事继续,本期将介绍第一个无状态模式 —— 域服务。
我没有关于命名代码结构的官方统计数据,但让我们诚实点 —— 我们是开发人员,我们给类命名......几乎很好,大部分很糟糕。在大多数情况下,我们提供一些几乎唯一的名字,并附加一些后缀。
名称可以不同:用户、帐户、注册......选项是无限的。但是,后缀是有限的,在大多数情况下,它们是:实体、存储库、工厂、服务......
是的,服务!我相信这是最常见的后缀,无论我们使用DDD或任何其他模式都无关紧要。在许多情况下,Service只是用于我们无法更好地命名的任何东西。
服务可能是DDD模式中被误用最多的模式之一。对域服务的误解来自于许多不同的Web框架,在这些框架中,服务就是一切。
但是域服务的实际目的是什么?它与我们放在应用层上的其他服务有什么区别?
让我们开始吧。
无状态的
事实一:Domain Service不允许持有状态,也不允许包含任何具有状态的字段,包括间接的。
事实二:这可能是所有战术DDD模式中最重要的规则。
这条规则可能很明显,但不幸的是,它并不是,取决于每个开发人员的背景,他们中的一些人有在Web开发中使用语言的经验,这些语言为每个请求运行独立的进程。
在这种情况下,服务是否包含状态并不重要,但是当你使用 NodeJS 时,你可能会为整个应用程序使用单个 Domain Service 实例。
因此,你可以想象如果许多不同的客户访问内存中的相同值会发生什么。
// Entity keeps state like Person Entity, Wallet Value Objects etc
class Account {
private id: number;
private person: Person;
private wallets: Wallet[];
// some business logic
}
// Value Object keeps state like Currency Entity, Amount primitive type etc
class Money {
private amount: number;
private currency: Currency;
// some business logic
}
// Domain Service depends only on other stateless constructs like:
// other Services, Repositories, Factories, objects that represent app configuration
class DefaultExchangeRateService {
private repository: ExchangeRateRepository
private useForceRefresh: boolean;
// some business logic
}
class TransactionService {
private bonusRepository: BonusRepository;
private bonusFactory: BonusFactory;
private accountService: AccountService;
// some business logic
}
如上例,实体和值对象都保存状态。实体可以在运行时改变状态,而值对象总是保持不变。当我们需要一个新的值对象时,我们创建一个新的值对象。
Domain Service不包含任何有状态对象,它只保存其他无状态结构,如仓库、其他服务、工厂或保存配置值的对象,它可以初始化状态的创建或持久化,但它不保存状态。
错误的实现
class TransactionService {
private bonusRepository: BonusRepository;
private result: Money; // field that contains state
async deposit(account: Account, money: Money) {
const bonuses = await this.bonusRepository.FindAllEligibleFor(account, money);
//
// some business logic
//
this.result = this.result.Add(money) // changing state of service
}
}
在上面的第一个例子中, TransactionService
以 Money
值对象的形式保存了一个有状态字段,每当我们想要进行新的存款时,我们执行应用 Bonuses
的逻辑,然后将它添加到最终结果中,这是服务内部的一个字段。
这种方法是错误的。每当有人,任何人,进行存款时,结果都会改变。我们不想这样做,而是要保持每个 Account
的汇总。相反,我们应该将计算作为方法的结果返回,如下面的例子所示。
正确的实现
class TransactionService {
private bonusRepository: BonusRepository;
async deposit(current: Money, account: Account, money: Money): Promise<Money> {
const bonuses = await this.bonusRepository.FindAllEligibleFor(account, money);
//
// some code
//
return current.Add(money) // changing state of service
}
}
在第二个例子中, TransactionService
总是产生最新的计算结果,而不是将它们存储在内部,不同的用户不能共享内存中的同一个对象,Domain Service 应该像内存中的单个实例一样工作。
这个服务的客户端现在负责保存新结果,并在每次发生存款时刷新它。
在过去的经验中,我经历过这种模式与有状态性关系的错误使用,在大多数情况下,这种误用来自于以前在客户端使用JavaScript的经验,例如PHP。
在这些情况下,你需要格外小心,很多时候我看到在调用所需的方法之前,在服务内部设置访问令牌作为状态,你可能会猜到——许多错误会话的干扰都是结果。
行为
域服务代表问题域的行为。它为那些太复杂而无法存储在单个实体或值对象中的业务不变量提供解决方案。
有时,一个特定的行为可能与多个实体或值对象交互。在这些情况下,很难找到该行为属于哪个实体。这应该是域服务的情况。
Domain Services不处理会话或请求。它不知道任何关于UI组件的事情。它不执行数据库迁移。它不验证用户输入。Domain Service只管理业务逻辑。
interface ExchangeRateService {
isConversionPossible(from: Currency, to: Currency): Promise<boolean>;
convert(to: Currency, from: Money): Promise<Money>;
}
class DefaultExchangeRateService implements ExchangeRateService {
constructor(private repository ExchangeRateRepository) {}
async isConversionPossible(from: Currency, to: Currency): Promise<boolean> {
let result: boolean;
//
// some business logic
//
return result;
}
async convert(to: Currency, from: Money): Promise<Money> {
let result: Money;
//
// some business logic
//
return result;
}
}
在上面的例子中,有一个 ExchangeRateService
的情况,使用 Domain Services 接口是没有必要的,尽管当我们想要支持更好的依赖管理时,它会有所帮助。
这个服务处理货币兑换的完整业务逻辑。它包含 ExchangeRateRepository
来获取所有汇率,以便它可以转换任何数量的货币。
请看下一个例子:
class TransactionService {
constructor(
private bonusRepository: BonusRepository;
private accountService: AccountService;
){}
async transfer(account: Account, money: Money) {
const bonuses = await this.bonusRepository.FindAllEligibleFor(account, money);
//
// some business logic
//
for (const bonus of bonuses) {
bonus.Apply(account);
}
//
// some cobusiness logic
//
await this.accountService.Update(account);
}
}
如上所述,域服务包含业务不变量,这些不变量太复杂,无法存储在单个实体或值对象中。上面的示例 TransactionService
包含应用 Bonuses
的复杂逻辑,每当从某个 Account
有新的传输时。
与其强迫 Account
或 Bonus
实体相互依赖,或者更糟糕的是,为实体的方法提供预期的存储库或服务,我们应该创建一个域服务。
这个服务可以封装完整的业务逻辑,以将 Bonuses
应用于任何必要的 Account
。
合同
有时,Bounded Context 依赖于其他 Bounded Context,典型的例子是微服务集群,其中一个通过 REST API 访问另一个。
在大多数情况下,从外部 API 接收的数据对于特定的 Bounded Context 的运行至关重要,因此,在我们的域层中,这些数据应该是可访问的。
我们必须始终将我们的领域层与技术细节解耦,这意味着如果我们将一些与外部 API 或数据库的集成放在我们的业务逻辑中,那么它就是代码的异味。
在域层,应该有一个作为外部集成合同的服务接口。然后我们可以在我们的业务逻辑中注入该接口,但实现是在基础架构层。
领域层
interface AccountService {
update(account Account);
}
基础结构层
class AccountAPIService implements AccountService {
constructor(private client: AxiosInstance) {}
async update(account: Account) {
//
// some integrational code
//
await this.client({
//
// some configuration
//
});
}
}
在上面的例子中,域层上有一个 AccountService
接口,它代表了一个其他域服务可以调用的契约,但是,它的实现形式是 AccountAPIService
。
AccountAPIService
发送 HTTP 请求到外部 CRM 系统,或到我们的内部微服务,只用于 Accounts
。
我们还可以为 AccountService
提供一个额外的实现,它将使用这种方法在一个隔离的测试环境中从文件中测试 Account
。
域服务与其他类型的服务
基础设施服务是最容易识别的。它们总是包含技术细节、与数据库的集成或外部API。在大多数情况下,它们是来自其他层的接口的实际实现。
表示服务也很容易识别,它们总是提供一些与 UI 组件或验证用户输入相关的逻辑,表单服务就是一个典型的例子。
当涉及到区分应用程序和域服务时,问题就出现了。
一种可能性是仅使用应用服务来提供处理会话或请求的一般逻辑。在应用层内部处理授权和访问权限也是不错的。
class AccountSessionService {
constructor(
private accountService: AccountService
) {}
async getAccount(session: Record<string, any>): Promise<Account> {
const id = session["accountID"];
if (!id) {
throw new Error("there is no account in session");
}
if (typeof id !== 'string') {
throw new Error("invalid value for account ID in session")
}
return await this.accountService.byID(id);
}
}
在许多情况下,应用服务可以是域服务的包装结构,当我们想在会话(或请求、cookie)中缓存一些东西,并使用域服务作为数据的后备时,这是一个很好的用例。
其中, AccountSessionService
是一个应用服务,它从域层包装了 AccountService
。它负责从会话存储中提取一个值,然后使用它来查找下面的服务中的 Account
细节。
结论
Domain Service 是本系列中我们介绍的第一个无状态模式,它不能直接或间接地保存任何类型的状态,它主要用于保存复杂的业务逻辑,否则这些逻辑会因为太大而无法放入实体或值对象中。
它还可以作为接口来表示外部 API 的契约,而实际的实现在基础设施层上。此外,我们可以拥有不处理业务逻辑的应用程序服务,就像域服务那样。
TypeScript领域驱动设计(DDD)系列:
1.
TypeScript中的实用领域驱动设计(DDD):为什么重要?
3. 在TypeScript中实践DDD(领域驱动设计):实体
欢迎关注公众号:文本魔术,了解更多