【PHPでDDD】Entityのライフサイクル編
PHPでEntityをどのように設計したら良いのか、
弊社で日々のプロジェクトなどで試行錯誤続けていますが、現時点での設計方法を書き残したいと思います。
Entityのライフサイクルがポイント
リアルな業務における物事のライフサイクルと、仮想世界であるプログラム内のEntityのライフサイクルを近づけることによって、アプリケーションの継続的なリファクタリングや機能追加に役立ちます。
良く例にされる「ユーザー」Entityのライフサイクルを見てみましょう。
ユーザーは会員登録をしてから様々な変化を続け、最終的にライフサイクルを終えるという流れですね。
サービスによっては「退会=End of life」とは限らないので、あくまでもサンプルとして捉えて頂きたい。
では、アプリケーション内のEntityにおける変化を見ていきましょう。
リアルな業務で「ユーザー」はずっと存在し続けるのに対して、アプリケーションは実行されっぱなしな状態ではない。リクエストがあればアプリケーションが起動し、処理が終われば終了しますが、「ユーザー」がずっと存在していることを再現しなければならない。
なので、DDDを適用する際に、「ユーザーのライフサイクルをどう忠実に再現するか」に尽きると思います。
BadCase
DDDはオブジェクト指向設計と混在される例を良く見かけます。その悪い例を見ていきましょう。
final class User
{
/**
* @var UserId
*/
private UserId $id;
/**
* @var string
*/
private string $email;
/**
* @var UserProfile|null
*/
private ?UserProfile $profile;
/**
* @var UserPassword
*/
private UserPassword $password;
/**
* User constructor.
* @param UserId $id
* @param string $email
* @param UserProfile|null $profile
* @param UserPassword $password
*/
public function __construct(UserId $id, string $email, ?UserProfile $profile, UserPassword $password)
{
$this->id = $id;
$this->email = $email;
$this->profile = $profile;
$this->password = $password;
}
/**
* @return UserId
*/
public function getId(): UserId
{
return $this->id;
}
/**
* @return string
*/
public function getEmail(): string
{
return $this->email;
}
/**
* @param string $email
*/
public function setEmail(string $email): void
{
$this->email = $email;
}
/**
* @return UserProfile|null
*/
public function getProfile(): ?UserProfile
{
return $this->profile;
}
/**
* @param UserProfile|null $profile
*/
public function setProfile(?UserProfile $profile): void
{
$this->profile = $profile;
}
/**
* @return UserPassword
*/
public function getPassword(): UserPassword
{
return $this->password;
}
/**
* @param UserPassword $password
*/
public function setPassword(UserPassword $password): void
{
$this->password = $password;
}
}
言語が違えど、このような設計が紹介されているケースをよく見かけます。
このEntityを先程のライフサイクル図に当てはめたパターンを見てみましょう。
BadCase Review
特に問題ないように見えるコードかもしれませんが、「ライフサイクルを忠実に再現」することが難しいでしょう。
ユーザーが新規登録しても、DBから復帰されてもnew User
でインスタンスを作っています。
ここで困るケースを想定してみましょう。
このアプリケーションの開発当初はメールアドレスに関する制約がなかったとします。
その後、サービスを運用してみた結果、「フリーメールでの登録を禁止しましょう」 という要件が増えたとします。
しかし、既に登録済みのユーザーに不便をかけるわけにもいかないので、「既に登録されている人はそのままにしましょう。」 という条件つき。
要件をまとめると、
- フリーメールでの登録は禁止する
- 既存のユーザーはそのまま
さて、このクラスを見ながらプログラマーは考えます。どこにバリデーションを入れたら良いのか。
フリーメールでの新規登録を許可しないが、既存ユーザーには影響を出したくない。
しかし、コンストラクターは既存ユーザー・新規ユーザーのどちらにも実行されるし、DBから復帰時にもコケてしまう。
この要件は明らかなビジネスロジックなので、ドメイン層で解決しなくてはならない。仮にユースケースでバリデーションしてしまったら、ドメイン知識が外部に漏れてしまう。
このケースで最大の問題点は「リアルユーザーのライフサイクルとEntityのライフサイクルが一致していない」ことである。
では、ライフサイクルをどのように守れば良いのか、次のケースを見ていきましょう。
GoodCase
上記ケースで判明したEntityオブジェクトのライフサイクル問題ですが、言語によってはアプローチ方法が変わると思います。Javaであれば、複数のコンストラクターを利用できる仕様を持つため、別のアプローチも考えられる。
この記事ではあくまでもPHPによる解決方法を実践します。早速コードを見ていきましょう。
final class User
{
/**
* @var UserId
*/
private UserId $id;
/**
* @var string
*/
private string $email;
/**
* @var UserProfile|null
*/
private ?UserProfile $profile;
/**
* @var UserPassword
*/
private UserPassword $password;
/**
* User constructor.
*/
private function __construct()
{
}
/**
* @param UserId $id
* @param string $email
* @param UserPassword $password
* @param UserProfile|null $profile
* @return static
*/
public static function restoreFromSource(
UserId $id,
string $email,
UserPassword $password,
?UserProfile $profile
): self
{
$user = new self();
$user->id = $id;
$user->email = $email;
$user->password = $password;
$user->profile = $profile;
return $user;
}
/**
* @param string $email
* @param UserPassword $password
* @return static
*/
public static function register(
string $email,
UserPassword $password
): self
{
if (!FreeMailValidator::isValid($email))
throw new DomainException('Free email was detected.');
$user = new self();
$user->id = UserId::generate();
$user->setEmail($email);
$user->setPassword($password);
$user->setProfile(null);
return $user;
}
/**
* @return UserId
*/
public function getId(): UserId
{
return $this->id;
}
/**
* @return string
*/
public function getEmail(): string
{
return $this->email;
}
/**
* @param string $email
*/
public function setEmail(string $email): void
{
$this->email = $email;
}
/**
* @return UserProfile|null
*/
public function getProfile(): ?UserProfile
{
return $this->profile;
}
/**
* @param UserProfile|null $profile
*/
public function setProfile(?UserProfile $profile): void
{
$this->profile = $profile;
}
/**
* @return UserPassword
*/
public function getPassword(): UserPassword
{
return $this->password;
}
/**
* @param UserPassword $password
*/
public function setPassword(UserPassword $password): void
{
$this->password = $password;
}
}
GoodCase Review
BadCaseで得られた課題をどのように解決しているのか、このサンプルの要点をまとめてみます。
ライフサイクルの再現
ユーザーが会員登録するタイミングでオブジェクトが初めて作られますが、単なるオブジェクト生成として捉えてしまってはいけません。会員登録を行ったわけですから、register
メソッドを使ってオブジェクトを生成します。会員登録時のみに絡むビジネスロジックがあれば、この中で解決します。
次にデータベースからの復帰するケースを考えましょう。
「リアルユーザー」は存在し続けますが、「アプリケーション内ユーザーは、アプリケーションが動作している間だけ存在する」ということを踏まえなければならない。
ユーザーEntityをどこにも永続化しなかったら、アプリケーションの実行終了と同時に消えます。なので、データベース等のストレージに永続化し、次回のアプリケーション起動時にEntityを復元させます。
このような仕組みはアプリケーションの都合でしかないので、この部分も「リアルユーザー」に合わせて再現しなければなりません。
上記コードでは、restoreFromSource
メソッドを使ってオブジェクトを生成していますが、プロパティーを直接代入していることにポイントがあります。このメソッドの目的は、永続化されていたユーザー情報を漏れなく正確にEntityとしてを復帰することを目的としています。
なので、いかなる時もビジネスロジックの都合で処理を止めてはいけません。復帰するのみです。
これらの役割を先程の図に当てはめて、間違いがないことを確かめてみましょう。
まとめ
いかがでしたでしょうか。簡単な例ではございますが、ライフサイクルの重要性を伝えられたのではないかと思います。
私の場合は、DDDの概念を理解したつもりになっても、いざ実装してみるとうまくいかないことだらけでした。その原因は、DDDのルールに縛られてばかりで「どうすればルール通りの実装ができるのか」ということをずっと考えてましたが、経験を積むことによって「DDDは何を解決しようとしているのか」の核心的な部分に理解を深めることが一番の近道だと考えるようになりました。
実装に悩んでいる方の参考になればと思います。
Discussion