前回のおさらい + 今回の内容
前回、エンティティと値オブジェクトをJavaScriptでどう実装していくかを説明しました。
引き続き今回はDDDで登場するドメインサービス、リポジトリ、ファクトリーについてみていきます。
そして、それぞれどのように実装していくかを説明します。
目次
PART1 ドメインモデルの実装方法(前記事)
- DDDとは何かをざっくり知り、ドメインモデルとドメインオブジェクトを知る。
- ValueObject
- Entity
PART2 ドメインサービス、リポジトリ、ファクトリーの実装方法(本記事)
- Domain Service
- Repository
- Factory
PART3 集約の実装方法(次回記事で書きます)
- 集約とは
- 集約の実装方法
ドメインサービス
ドメインサービスとはドメインモデルの振る舞いのうち、エンティティと値オブジェクトで表現しきれない範囲の振る舞いを表現するサービスのことです。
Eric Evansによると__「ドメインで必要な機能をエンティティの責務として押し付けるとモデルに基づくオブジェクトの定義を歪める場合にドメインサービスを使う」__というのがドメインサービスの使いどころです。
Vernon 『実践ドメイン駆動設計』では__「エンティティに実装した時に不格好になる場合もドメインサービスを使う」__と表現されています。
どちらも若干曖昧さを残す表現ですが、エンティティで表現できない振る舞いを表現するということは共通です。
「エンティティで表現できない振る舞い」とは何か
以下の場合を「エンティティで表現できない振る舞い」と考えておけば良いでしょう。
- インフラストラクチャ層の処理(db操作、外部APIとの通信など)が含まれる振る舞い
- 集約が異なる複数のエンティティの振る舞い
1. インフラストラクチャ層の処理が含まれる振る舞い
例として、あるアカウント登録が必要なサービスでユーザーがログインする場合を考えます。
考えるusecaseは
「emailとpasswordからuserの存在チェックをし、存在すれば認証トークンを返す、という処理をする」
とします。以下のようにかけます。email, passwordは値オブジェクトをインスタンス化していて、userはエンティティをインスタンス化しています。
悪い例
interface LoginParams {
email: string;
password: string;
}
class LoginUsecase {
async login(params: LoginParams) {
const email = new Email(params.email);
const encryptedPassword = new EncryptedPassword(params.password);
const user = new User({ email, encryptedPassword });
await user.authenticate();
// クライアントに認証トークンを返す
return {
authenticationToken: user.authenticationToken,
};
}
}
この時、Userエンティティは次のようになります。
悪い例
export class User {
email: Email;
encryptedPassword: EncryptedPassword;
authenticationToken? : AuthenticationToken;
userRepository: IUserRepository;
async authenticate() {
const authenticateUser = await
this.userRepository.findByEmailAndPassword({
email: this.email,
password: this.encryptedPassword,
});
if (!authenticatedUser) {
throw new Error('認証に失敗しました');
}
const authenticationToken = new AuthenticationToken({
email: this.email,
encryptedPassword: this.encryptedPassword,
});
this.authenticationToken = authenticationToken;
return;
}
}
このauthenticateというメソッドの中で、repositoryにユーザーが認証済みかを問い合わせています。これはエンティティが直接インフラストラクチャ層に依存していることになります。
現実世界のユーザーはdb操作などの振る舞いはもちろんありません。
ここが不自然ポイントになり、エンティティで表現できない振る舞いになります。
また、db操作や外部のAPIとの通信は、DDDではエンティティの責務ではなく、リポジトリの責務とされています。
DDDに限りませんが、各オブジェクトが担っている責務を決めることで、変更に強い設計にすることができます。
エンティティでdb操作をすることはこの責務の所在を曖昧にしてしまいます。
そういう意味でも不自然になってしまします。
この認証済みかをインフラ層に問い合わせることをドメインサービスにやってもらいます。
良い例(ドメインサービスの例)
export class AuthenticationService {
userRepository: IUserRepository;
constructor() {
this.userRepository = new UserRepository();
}
async authenticate(user: User) {
const _authenticateUser = await
this.userRepository.findByEmailAndPassword({
email: user.email,
encryptedPassword: user.encryptedPassword,
});
if (!_authenticatedUser) {
throw new Error('認証に失敗しました');
}
const email = new Email(_authenticatedUser.email);
const encryptedPassword = new EncryptedPassword(_authenticatedUser.encryptedPassword);
const authenticationToken = new AuthenticationToken({
email,
encryptedPassword,
});
const authenticatedUser = new User({
email,
encryptedPassword,
authenticationToken,
});
return authenticatedUser;
}
}
このようにドメインサービスにはドメインロジックを書くことができます。
このドメインサービスをusecaseから使うことになります。
この時のusecaseは以下のようになります。
良い例
interface LoginParams {
email: string;
password: string;
}
const authenticationService = new AuthenticationService();
class LoginUsecase {
async login(params: LoginParams) {
const email = new Email(params.email);
const encryptedPassword = new EncryptedPassword(params.password);
const user = new User({
email,
encryptedPassword,
});
// ドメインサービスに認証を依頼
const authenticatedUser = await authenticationService.authenticate(user);
// クライアントに認証トークンを返す
return {
authenticationToken: authenticatedUser.authenticationToken,
};
}
}
最初の悪い例における
user.authenticate();
の代わりに
userAuthenticationService.authenticate({
email,
password,
});
を使うようにしています。
また、エンティティは以下のようになります。
良い例
export class User {
email: Email;
encryptedPassword: EncryptedPassword;
authenticationToken? : AuthenticationToken;
userRepository: IUserRepository;
authenticate(authenticationToken: AuthenticationToken) {
this.authenticationToken = authenticationToken;
}
}
authenticateという振る舞い自体は現実世界のユーザーには存在するため、モデルにも表現されているのが望ましいです。
ドメインサービスは実際の認証だけ依頼して、トークンをセットする処理自体はエンティティに表現するのが良いと思います。
2. 集約が異なる複数のエンティティの振る舞い
Eric Evansが紹介していたサービスの使い方を紹介します。
銀行のシステムに使われるアプリケーションを考えます。
このアプリケーションで、口座Aから口座Bに振替することを考えます。
説明の都合上まず、Eric Evansが述べている正しいドメインサービスの例を先に出します。
ドメインモデルの設計は以下のようにするのが良さそうです。
- 口座というエンティティがある
- 口座から引き落とす、入金するという業務の責務は口座エンティティが担う
- 口座振替(ある口座のお金を全部もう一つの口座に移す)という業務の責務は口座エンティティに持たせるのは不自然 (なぜかは後で説明します)
→口座振替ドメインサービスを使う - 口座振替ドメインサービスが登場の責務はそれぞれの口座に入出金の指示のみ
この時usecaseは以下のようになります。
良い例
async accountTransfer(transferParams: TransferParams) {
const fromAccount = await this.accountRepository.find({
id: transferParams.fromAccountId,
});
const toAccount = await this.accountRepository.find({
id: transferParams.toAccountId,
});
// accountTransferServiceで振替する
const {
fromAccount: completeFromAcount,
toAccount: completeToAccount,
transactionHistory,
} = await this.accountTransferService.transfer({
fromAccount,
toAccount,
amount: transferParams.amount,
});
// 取引履歴を保存
await this.transactionHistoryRepository.save(transactionHistory);
// 口座情報を保存
await this.accountRepository.save(completeFromAccount);
await this.toRepository.save(completeToAccount);
}
口座振替サービスは次のようになります。
良い例
class AccountTransferService {
async transfer(transferParams: TransferParams) {
const { fromAccount, toAccount, amount } = transferParams;
// 振替もとから引き出す
fromAccount.withdraw(transferParams.amount);
// 振替さきに入金する
toAccount.deposit(transferParams.amount);
// 取引履歴を作る
const transactionHistory = new TransactionHistory({
fromAccountId: fromAccount.id,
toAccountId: toAccount.id,
});
return {
fromAccount,
toAccoount,
transactionHistory,
};
}
}
ここで注目したいのは、出金、入金の振る舞いはエンティティにやらせている点です。
あくまで、ドメインサービスはエンティティで表現できない振る舞いを処理するものです。
今回の例で言えば、口座Aから口座Bにお金を移動させるという振る舞いです。
2つの口座間でお金を移動させるという振る舞いは一つのエンティティだけではありえない振る舞いで、複数の口座が集まって初めて、集団としての振る舞いを持ちます。
この時、口座エンティティは以下のようになります。
良い例 (エンティティ)
class Accounts {
id: Id;
bankName: BankName;
accountType: AccountType;
balance: number;
withdrawToken: WithdrawToken;
// 出金
withdraw(params: WithdrawParams) {
if (!this.withdrawToken) {
throw new Error('引き落とす権限がありません');
}
const withdrawAmount = new Amount(params._amount);
if (withdrawAmount.greaterThan(ONE_TIME_LIMIT)) {
throw new Error('一度に出金できる額を超えています');
}
if (withdrawAmount.greaterThan(this.balance)) {
throw new Error('預金額より多く引き出そうとしています');
}
const remainder = this.balance - Number(withdrawAmount));
this.balance = remainder;
}
// 入金
deposit(params: DepositParams) {
省略(出金と同じ)
}
エンティティを見れば、出金、入金というような振る舞いがあるのだなということがわかるようになっています。
ドメインサービスを使わないとどうなるのか
ドメインサービスを使わず、エンティティだけで処理するとどうなるでしょうか。
悪い例
interface TranferParams{
toAccount: Account;
amount: Amount;
}
class Accounts {
id: Id;
bankName: BankName;
accountType: AccountType;
balance: number;
withdrawToken: WithdrawToken;
// 送金する
transfer(params: TranferParams) {
const { toAccount } = params;
// この口座が出金する
this.withdraw(params.amount);
toAccount.deposit(params.amount);
}
// 出金
withdraw(params: WithdrawParams) {
省略(良い例と同じ)
}
// 入金
deposit(params: DepositParams) {
省略(良い例と同じ)
}
このようにエンティティのなかで、送金するという処理が追加されることになります。
このメソッドの中で、このインスタンスとは違うAccountインスタンスを受け取って、そのインスタンスの操作をしています。
これなんか変ですよね?2個のAccountインスタンスは集約が同じではないので、インスタンスの中で別の集約のインスタンスを操作することはできません。
これが、Eric Evansのいう「ドメインで必要な機能をエンティティの責務として押し付けるとモデルに基づくオブジェクトの定義を歪める場合」にあたり、ドメインサービスを使う場面ということになります。
ドメイン貧血症
「エンティティの責務をドメインサービスが果たしてしまっていて、その結果、エンティティがドメインモデルの振る舞いを表現しきることができなくなっている状態」は__ドメイン貧血症__と呼ばれます。
本来エンティティの振る舞いとして表現したいことを、(サボって)ドメインサービスに全部書いたとします。
これでも処理としては実行され、正常にアプリケーションは動きます。
しかし、これでは問題があります。以下で説明します。
先ほどのaccountTransferServiceドメインサービスは以下のようになります。
export class AccountTransferService {
async transfer(transferParams: TransferParams) {
const { fromAccount, toAccount, amount } = transferParams;
const isFromAuthenticated = fromAccount.isAuthenticated();
if (!isFromAuthenticated) {
throw new Error('権限がありません');
}
if (amount > ONE_TIME_LIMIT) {
throw new Error('一度に出金できる額を超えています');
}
if (amount > fromAccount.balance) {
throw new Error('預金額より多く引き出そうとしています');
}
fromAccount.setBalance(fromAccount.balance - amount);
const isToAuthenticated = toAccount.isAuthenticated();
if (!isToAuthenticated) {
throw new Error('権限がありません');
}
toAccount.setBalance(toAccount.balance + amount);
const transactionHistory = new TransactionHistory({
fromAccountId: new AccountId(fromAccount.id),
toAccountId: new AccountId(toAccount.id),
});
await this.transactionHistoryRepository.save(transactionHistory);
await this.accountRepository.save(fromAccount);
await this.toRepository.save(toAccount);
return Promise.resolve();
}
}
ここで注目してほしい点が、良い例で示した、エンティティに書かれていた振る舞いをドメインサービスで処理しているという点です。
この時、エンティティは以下のようになります。
悪い例
export class Accounts {
id: Id;
bankName: BankName;
accountType: AccountType;
balance: Balance;
withdrawToken: WithdrawToken;
get isAuthenticated() {
return !!this.withdrawToken;
}
set setBalance(amount: number) {
this.balance = new Balance(amount);
}
}
何が問題なのか
- エンティティには出金、入金するという振る舞いが見えない。
- setBalanceというユビキタス言語に合わないメソッドが存在している。
- 処理がドメインサービスのあっちこっちに散財して、ドメインロジックを集約させるという理想から遠ざかってしまう
エンティティはドメインロジックを扱うものであるので、メソッドは全てユビキタス言語に沿っていなくてはいけません。それに反しているため、エンティティの振る舞いはますます見えなくなってしまいます。
ドメインサービスの命名: メソッド名は業務を表すユビキタス言語の動詞にする
上でAuthenticationServiceとAccountTransferServiceの二つのドメインサービスを紹介しました。
AuthenticationServiceにはauthenticate
という名前のメソッドがあり、使うときは
const authenticationService = new AuthenticationService();
authenticationService.authenticate(params);
のように使うことになります。
これはドメインサービスの名前とメソッドの名前が重複しているように見えるかもしれませんがこれで良いです。
Vernonの『実践ドメイン駆動設計』ではこの命名で紹介されていて、その中で
__「ドメインサービスはドメインロジックを書く場所で、メソッドは業務そのものを表す動詞で命名するのを勧める」__ということが述べられています。
メソッド名がユビキタス言語に沿っていることが一番大事なことで、エンティティやドメインサービスで使われるメソッド名は業務を表す動詞にするようにしましょう。
ドメインサービスとはそもそも状態を持たず、いろいろな処理を流動的にやっていく役割を担うので、ドメインサービスの名前も(動詞や動名詞) + Service
とつけることをVernonは推奨していますが、ドメインサービス名についてはそこまで強くは言っていませんでした。
リポジトリ
リポジトリとは__「集約(エンティティ)のインスタンスを永続化し、必要な時に再構築するオブジェクト」__のことです。
ここで、集約はエンティティの集まりみたいなものだと思ってください。次回の記事で詳しく説明します。
リポジトリはドメインを知っていてはいけない。
アプリケーションのなかで、ある状態のドメインオブジェクトを保存しようとしたら、インメモリにデータを載せたり、データベースにレコードを作ったり、何かしら永続化する処理をします。
ただし、この処理はインフラ層の処理であり、ドメイン(業務)に全く関係ないので、ドメイン層の処理(エンティティやドメインサービスがする処理)と切り離される必要があります。
そのため、リポジトリはドメインオブジェクトの情報をドメイン層から受け取り永続化し、永続化されたドメインオブジェクトの情報を再構築してドメイン層に渡すことに専念します。
そして、リポジトリの中ではドメインオブジェクトの振る舞いが実行されてはいけません。
user.authenticate()
みたいなことはリポジトリの中で実行してはいけないということです。
リポジトリを使わないと
前回紹介した、ecサイトでuserが商品の購入の処理を考えてみます。
リポジトリを使わないとき、userBuyingServiceはどうなるでしょうか。
悪い例
import _ from 'lodash';
const externalPaymentService = new ExternalPaymentService();
export class UserBuyingService {
buyProduct(params: Params) {
const { user, products } = params;
const payments = products.map(product =>
new Payment({
userId: user.id,
price: product.price,
taxRate: product.taxRate,
product: Product,
});
}
const amount = _.sum(payments.map(payment => payment.amount)); // payment.amountで税込計算
// 外部決済サービス(GMO, pay.jp, stripe などなど)に決済レコードを作る
await externalPaymentService({
amount,
tradingId: user.tradingId,
});
for (const payment of payments) {
payment.complete();
);
const paymentPromiseList = [];
for (const productId of productIds) {
const req = db.payments.create({
user_id: userId,
product_id: productId,
amount: product.price,
paid_status: PAID_STATUS.COMPLETE,
payment_service_transaction_id: res.payment_service_transaction_id,
}]);
paymentPromiseList.push(promise);
}
await Promise.all(paymentPromiseList);
// 商品情報をupdate
const productPromiseList = [];
for (const product of products) {
const promise = db.products.update(
{
user_id: userId,
purchased_date: res.paid_date,
stock_count: product.stock - 1,
},
{
where: {
id: product.id,
},
}
);
productPromiseList.push(promise);
}
await Promise.all(productPromiseList);
// メールを送る
mailerService.sendPurchaseComplete({
to: userId,
});
}
ドメインサービスの中で、ORMを用いてデータベースのデータをupdateしています。
これには大きく二つの問題があります。
- データベースが変わった時、ORMが変わった時に困る
- ドメインロジックを追えない
データベースが変わった時に困る
データベースが変わったり、ORMを変更した時にどうなるかというと、当然
db.products.update(...
のように書いていたところは書き直さなくてはいけません。
業務ロジックは少しも変わっていないのに、ドメインサービスを変更することになります。
これは、ドメインが主役のはずなのに、インフラ層の都合でドメインが振り回されていることになります。
ドメインロジックを追えない
もう一つのデメリットはドメインロジックだけを追えないということです。
db操作がドメインサービスに書かれていると、みている処理がドメインの処理なのかどうかがわかりにくくなります。
dbの知識がないと読めないコードになり、ビジネスサイドの人でもわかるコードには程遠くなってしまいます。
リポジトリを使うと
リポジトリを使うと先ほどの購入処理は以下のようになります。
良い例
import _ from 'lodash';
const externalPaymentService = new ExternalPaymentService();
export class UserBuyingService {
paymentRepository: IPaymentRepository;
productRepository: IProductRepository;
buyProduct(params: Params) {
const { user, products } = params;
const payments = products.map(product =>
new Payment({
userId: user.id,
price: product.price,
taxRate: product.taxRate,
product: Product,
});
}
const amount = _.sum(payments.map(payment => payment.amount)); // payment.amountで税込計算
// 外部決済サービス(GMO, pay.jp, stripe などなど)に決済レコードを作る
await externalPaymentService({
amount,
tradingId: user.tradingId,
});
for (const payment of payments) {
payment.complete();
);
// 自社DBに決済レコードを作る(のをrepositoryに依頼)
await paymentRepository.save(payments);
// 商品情報をupdate
product.sell();
await productRepository.save(product);
// メールを送る
mailerService.sendPurchaseComplete({
to: userId,
});
}
こうすると、
paymentRepository.save(payments);
の部分で、paymentインスタンスが永続化されているな!と一瞬でわかります。
これによって、ドメインロジックだけを際立たせて、どういう処理が行われているかを理解しやすくなります。
リポジトリの実装
上の例の時、リポジトリは以下のようになっています。
interface IPaymentRepository {
save: (payments: Payments) => Promise<void>;
}
class PaymentRepository implements IPaymentRepository {
save: (payments: Payments) => Promise<void>;
save(payments: Payments) {
const paymentPromiseList = [];
for (const payment of payments) {
const promise = db.payments.create({
user_id: userId,
product_id: payment.product.id,
amount: payment.amount,
paid_status: PAID_STATUS.COMPLETE,
payment_service_transaction_id: payment.payment_service_transaction_id,
}]);
paymentPromiseList.push(promise);
}
return Promise.all(paymentPromiseList);
}
}
このようにdbに直接問い合わせたり、更新する処理はリポジトリにまとめて書くことになります。
そして、このファイルの中にはドメインオブジェクトの振る舞いが一切現れることはありません。
インターフェイスを必ず定義する。
もう一つリポジトリを作る時に大事なのが、必ずリポジトリのインターフェイスを用意することです。
先ほども説明した通り、ドメインサービスがリポジトリに依存することは避けたいです。
そこで、依存関係逆転の原則を使い、ドメインサービスがリポジトリのインターフェイスに依存するようにします。
それが、userBuyingService.ts
のなかの
paymentRepository: IPaymentRepository;
にあたります。
さらにリポジトリの実装はpaymentRepository.ts
の
class PaymentRepository implements IPaymentRepository {
で表現されます。
依存関係逆転についてよくわからない方はこちらを参考にしてください。
インターフェイスに依存する形で書くことの意味
上の例では、データベースにおける決済レコードを管理するpaymentsテーブルが登場しました(db.payments)。
この状況で例えば、外部決済サービスとの取引id(payment_service_transaction_id)はより強固なセキュリティにおきたいという理由で別のテーブルで管理することになったとします。
データベースのテーブルは以下のように変更されます。
この時、注目すべきはドメインロジックは1ミリも変わっていないということです。
何も変わっていないので、ドメインサービスは変える必要はありません。
paymentRepository.ts
だけを変更すれば良いです。
以下のように変更されます。
interface IPaymentRepository {
save: (payments: Payments) => Promise<void>;
}
class PaymentRepository implements IPaymentRepository {
save: (payments: Payments) => Promise<void>;
async save(payments: Payments) {
for (const payment of payments) {
const _payment = await db.payments.create({
user_id: userId,
product_id: payment.product.id,
amount: payment.amount,
paid_status: PAID_STATUS.COMPLETE,
payment_service_transaction_id: payment.payment_service_transaction_id,
}]);
await db.external_payment_informations.create({
payment_service_transaction_id,
payment_id: _payment.id,
});
}
return Promise.resolve();
}
}
このように、リポジトリの中だけを変え、インターフェイスをそのままに保つことができればドメインサービスは何事もありません。
注意したいことはpaymentRepositoryが表す「payment」とdb.payments.createが表す「payments」はまったく別物です。
前者はエンティティ(または集約)としてのpayemntであり、後者はデータベースのテーブルの一つであるpaymentsです。
これを混同しがちですが、上の例の変更後はpayment集約をsaveすることが、結果的に2つのテーブルに更新をかけていることがわかると思います。
ファクトリー
ファクトリーとは__「複雑なオブジェクトと集約のインスタンスを生成するオブジェクト」__のことです。
前回の記事で少し触れましたが、ドメインオブジェクトの生成が役割になります。
ファクトリーを使わないと
ファクトリーを使わないと、newをたくさんしなきゃいけない問題が発生します。
ユースケースやドメインサービスでドメインオブジェクトをインスタンス化したい時、何回もnew Hoge()
と書かなくてはいけなくなります。
class-transformerを用いた設計だとファクトリーは簡単に作れる
実は前回紹介したclass-transformerを使っていれば、factoryの実装は簡単です。
userクラスが以下のようになっているとします。
import { MaxLength } from 'class-validator';
class User {
firstName: string;
lastName: string;
email: string;
@MaxLength(5, {
message: 'nickNameが長すぎます',
})
nickName: string;
}
この時、userFactoryは以下のようになります。
import { transformAndValidate } from "class-transformer-validator";
interface IUserFactory {
create: (params: UserParams) => User;
}
class UserFactory implements IUserFactory {
create: (params: UserParams) => Promise<User>;
async create(params: UserParams) {
return transformAndValidate(params);
}
}
ファクトリーもリポジトリと同じようにインターフェイスを用意し、その実装で、めんどくさい処理をしていきます。
ただし、class-transformerとclass-transformer-validatorを使うことで実装もとても簡単な形で書くことができます。
まとめ
- ドメインサービスはエンティティでしきれないドメインロジックを担当する。ドメイン貧血症にならないようにするためにエンティティができることは必ずエンティティにさせるようにする。
- リポジトリは、ドメインオブジェクトの永続化、再構築を行う。ドメインロジックは入り込まない。必ずインターフェイスを定義する。
- ファクトリーはインスタンス化するめんどくさい処理を担当する。class-transformerを使う場合は実装自体は簡単にかける。