ドメインとインフラ層のDIP
この記事は BEAR.Sunday Advent Calendar 2021の記事です。
以前の記事、Ray.DiでDependency Inversion Principle(DIP)、依存性逆転の原則を紹介しました。
その原則に従えば、ドメインから特定の永続化コンポーネント、特定ORMの実装に依存することはどうでしょうか?
ドメイン層からインフラ層の実装に依存するのではなく、その逆、ドメイン層のインターフェイスにインフラ層の実装を合わせる事を特定の状況下でより効率的に行うためのパッケージがこのRay.MediaQueryです。
ドメインのインターフェイス
まずドメインはインフラ層の実装や仕様に対して無知に、自由にインターフェイスを設計します。例えばtodo
アイテムを追加するインターフェイスというものを考えてみましょう。
interface TodoAddInterface
{
public function add(string $id, string $title): void;
}
この永続化をデータベースで行うならDbQuery
属性を与えます。
interface TodoAddInterface
{
#[DbQuery('user_add')]
public function add(string $id, string $title): void;
}
この永続化をWebAPIで行うならWebQuery
属性を与えます。
interface TodoAddInterface
{
#[WebQuery('user_add')]
public function add(string $id, string $title): void;
}
ドメインのコードはインフラに対しての関心を持っていないのでメソッドシグネチャは不変で、属性だけが変わっています。
実装はノーコード
次にこのインタフェーイスを実装するクラスを用意するのはユーザーではなくRay.MediaQuery
の仕事です。このインターフェイスを利用するために必要なのはインジェクトする事だけです。
class Todo
{
public function __construct(
private TodoAddInterface $todoAdd
) {}
}
インジェクトされたで"インフラストラクチャ操作オブジェクト"を利用します。
public function add(string $id, string $title): void
{
$this->todoAdd->add($id, $title);
}
ドメインは操作の詳細に興味を持っていません。
ランタイム
これがどのように実現されているかに興味がありますか? Ray.DiとRay.AopそれにNullObjectを生成する技術で実現されています。簡単に説明します。
1. データーソースの特定
属性で指定されたuser_add
を手がかりとして、DbQuery
の場合はuser_add.sql
、WebQuery
の場合はhttps://{host}/{path/to/user_add}
を特定します。
2. 束縛
メソッドの引数名と値を束縛します。
DbQuery
の場合、以下の引数id
、title
が
public function add(string $id, string $title): void
"データーソースの特定で特定されたSQL"にバインドされ実行されます。複数のSQLを記述すればトランザクション実行されます。
INSERT INTO user (id, name) VALUES (:id, :name);
WebQuery
の場合はid={$id}&title={$title}
というようなクエリーまたはペイロードが作られ、初期化の時に指定したメソッドでリクエストされます。
ジェネレートされるクラス
ランタイム
で説明したクラスをジェネレートしてインジェクトすれば実現できそうですね。それも可能ですが冗長です。必要になってそのコードを読むのも大変そうです。
実際は実行コードをジェネレートする代わりに、Nullオブジェクトをジェネレートして、それに対して特定のインターセプターを束縛しています。
実際にジェネレートされているのはこのようなNullクラス(メソッドの中身がないクラス)です。
class TodoAddInterfaceNull implements TodoAddInterface
{
#[DbQuery('todo_add')]
public function __invoke(string $id, string $title): void
{
}
}
これは別途作成したkoriym/null-objectで実現しています。これに対してDbQueryInterceptorやWebQueryInterceptorを束縛しています。
DBの場合
public function invoke(MethodInvocation $invocation)
{
$method = $invocation->getMethod();
/** @var DbQuery $dbQury */
$dbQury = $method->getAnnotation(DbQuery::class);
$pager = $method->getAnnotation(Pager::class);
$values = $this->paramInjector->getArgumentes($invocation);
if ($pager instanceof Pager) {
return $this->getPager($dbQury->id, $values, $pager);
}
$fetchStyle = $this->getFetchMode($dbQury);
return $this->sqlQuery($dbQury->id, $values, $fetchStyle, $dbQury->entity);
}
Web APIの場合
public function invoke(MethodInvocation $invocation)
{
$method = $invocation->getMethod();
/** @var WebQuery $webQuery */
$webQuery = $method->getAnnotation(WebQuery::class);
/** @var array<string, string> $values */
$values = $this->paramInjector->getArgumentes($invocation);
$request = $this->webApiList[$webQuery->id];
return $this->webApiQuery->request($request['method'], $request['path'], $values);
}
デバッカーでトレースしてみると行っている事はとても単純だと言う事に気付かれるでしょう。
着想
テスト可能性(testability)や、ソフトウエアの継続進化を考えるとドメインコードからインフラストラクチャ"コンポーネント"への関心が取り除かれる事がとても魅力的な事に思えてきます。
Ray.Di、Ray.Aop、それにそれぞれで実績にあるcodegen(コード生成)の力を合わせれば、この問題がとてもエレガントに解決できるのではないかと考えました。インターフェイスからオブジェクトを生成する技術はモックとかではありますが、インフラ層に対するアクセスでこれを実現しているものはないと思います。JavaではDomaがほぼ同じことを実現しているそうです。(kawanamiさんに教えていただきました)
Ray.MeidaQueryで解決できないような複雑なアクセスは他に専用のクラスを作成することができます。この実装よりもっと素晴らしいMySuperRayMediaQuery
に入れ替えることもできます。いずれにしてもオリジナルコード本体に変更はありません。依存コンポーネントの後方互換性破壊に振り回される事もありません。
コードのオーナーシップをより高い次元で得る事になるのではないでしょうか。