DomainModelとDTOが相互に変換可能であることを要求するTraitが作りたかったんだ

業務の中で思いついた便利 Trait を供養します。どこかで使えそうで、いやしかしどこにも使えそうにない Trait です。もしかしたらライブラリにありそうだけど標準ライブラリにはないはず。たぶん。

経緯

いま業務で関わっているアプリケーションでは DomainModel をリポジトリに渡して DB に保存しています。その DomainModel を DB には JSON 形式で保存する要件がありました。DomainModel を JSON に変換するということは、DomainModel に serde::Deserializeserde::Serialize を derive することになりますが、DomainModel は DB での保存のされ方を意識したくありません。そこで、必ず DomainModel と一対一に対応する DTO が欲しくなります。

相互に変換可能であることを要求するTrait

さっそくですが、Trait の定義は下記のようになりました。

trait BiConvertible<T>
where
    Self: Sized,
    T: BiConvertible<Self>,
{
    fn convert(self) -> T;
}

この Trait は型変数 T を受け取り、convert メソッドの戻り値になります。この T は BiConvertible を実装している必要があります。つまり、T の convert メソッドを実行すると Self に変換されます。

例えば、2つの型のうち型方の型のみに実装を行うとコンパイルエラーになります。

struct A;
struct B;

impl BiConvertible<B> for A {
    fn convert(self) -> B {
        B {}
    }
}
error[E0277]: the trait bound `B: BiConvertible<A>` is not satisfied
  --> src/main.rs:12:6
   |
12 | impl BiConvertible<B> for A {
   |      ^^^^^^^^^^^^^^^^ the trait `BiConvertible<A>` is not implemented for `B`
   |
   = help: the trait `BiConvertible<B>` is implemented for `A`
note: required by a bound in `BiConvertible`
  --> src/main.rs:4:8
   |
1  | trait BiConvertible<T>
   |       ------------- required by a bound in this trait
...
4  |     T: BiConvertible<Self>,
   |        ^^^^^^^^^^^^^^^^^^^ required by this bound in `BiConvertible`

ちなみに自身に変換する場合はエラーが発生しません。2つの異なる型が相互に変換可能であることをコンパイル時に検知したいというコンセプトからは違反しますが考えないことにします。(となると BiConvertible の命名が微妙なわけだけど結局採用はしなかったので、命名についても同様に考えないこととする)

impl BiConvertible<A> for A {
    fn convert(self) -> A {
        self
    }
}

DomainModelとDTOが相互に変換可能であることを要求するTrait

さて、本題の DomainModel と DTO の相互変換についてです。下記の Trait はコンパイルが通ります。

trait DomainModel<T>
where
    Self: Sized + BiConvertible<T>,
    T: Dto<Self>,
{
    fn to_dto(self) -> T {
        self.convert()
    }
}
trait Dto<T>
where
    Self: Sized + BiConvertible<T>,
    T: DomainModel<Self>,
{
    fn to_domain(self) -> T {
        self.convert()
    }
}

impl DomainModel<B> for A {}
impl Dto<A> for B {}

しかしこの実装では DomainModel Trait を実装する時に DTO の具体的な名前が出てきてしまいます。DTO が変換先の DomainModel の型を知っていることは問題ないですが、DomainModel はそれ自身が誰に変換されてどう使われるのかを関知する必要はなく、むしろこの形だと変換先/利用先の知識が Domain 層を侵食しています。

型変数にするから型が現れてしまうわけなので、制約を書く場所を変えてみました。

trait DomainModel: Sized {
    type T: Dto + BiConvertible<Self>
    where
        Self: BiConvertible<Self::T>;

    fn to_dto(self) -> Self::T
    where
        Self: BiConvertible<Self::T>,
    {
        self.convert()
    }
}

trait Dto: Sized {
    type T: DomainModel + BiConvertible<Self>
    where
        Self: BiConvertible<Self::T>;

    fn to_domain(self) -> Self::T
    where
        Self: BiConvertible<Self::T>,
    {
        self.convert()
    }
}

変換先を Associated Type にしました。Self が BiConvertible であるという制約は Associated Type に行っています。メソッドにも制約が加えられているのは Self への BiConvertible が where で足されているため、メソッドを呼び出す Self が BiConvertible を満たしていることがわからないためです。('not satisfy'というエラーログで出たので制約が満たせていないことはわかりましたが、where 周りの細かい理解が足りていないことがわかった)

しかし、これらの Trait を実装すると、コンパイルエラーになってしまいます。

impl DomainModel for A {
    type T = B;
}
impl Dto for B {
    type T = A;
}
error[E0275]: overflow evaluating the requirement `<A as DomainModel>::T == B`
  --> src/main.rs:50:14
   |
50 |     type T = B;
   |              ^

制約が再帰的になっているぞ、とのことです。

E0275 - Error codes index

むすび

他にも制約をつける場所を変えることはできますが、その場合は convert メソッドが呼び出せなくなってしまったり、BiConvertible が実装不要になってしまうなどの問題があり解決には至りませんでした。集合論や型パズルに自信ニキなら上手く作れそうな気もしますが時間切れ。実務に採用することはありませんでした。

こういう妙な型は納期とのトレードオフで破壊されがちなので、コメント含むドキュメンテーションを通じてドメイン知識を共有できる体制づくりをした方が長期で万人に効くと思う。とはいえ、本当に厳しい納期の前にはドキュメンテーションや体制づくりも脆弱。やはり型なのか。そうなのか。