DDDのパターンをRustで表現する ~ Value Object編 ~

はじめに

こんにちは、キャディでバックエンドエンジニアとして働いているkuwana-kbです。

キャディではバックエンドで Rust を採用しています。また、設計手法としてドメイン駆動設計(以下 DDD)を取り入れています。Rust と DDD 、それぞれの解説記事は今では珍しくありませんが、 Rust と DDD をかけ合わせた記事はまだあまり目にしません。

今回は、 Rust で DDD の実装パターンをどう表現するかをまとめたいと思います。DDD に登場する概念は色々とありますが、今回はそのうちの1つである Value Object に焦点をあてたいと思います。

※ 本記事は、2020/04/28 に開催された「下町.rs」にて kuwana-kb が発表した内容を記事にしたものです。

目次

DDD とは

前提知識として、軽くDDDについておさらいしましょう。DDDとは、 Domain-Driven Design(ドメイン駆動設計)の略です。アプリケーションの扱う業務領域に焦点をあてた設計手法であり、エリック・エヴァンスが提唱しました。

DDDといえば、「エリック・エヴァンスのドメイン駆動設計」と「実践ドメイン駆動設計」という2つの書籍が有名です。これらの書籍ですが、内容はDDDの原典としてよく言及される一方で、初心者には理解するのが少しむずかしいという側面もあります。私もその難しさにやられた一人でした。

そんな中で出会ったのが 「ドメイン駆動設計入門」(著: 成瀬 允宣氏) という書籍です。 この本は、DDD に登場するモデリングとパターンの用語うち、パターンを集中的に解説した入門書です。これ 1 冊で DDD の内容を網羅しているわけではありませんが、抽象的な DDD のパターンを具体的なコードとして学ぶことができます。サンプルコードも豊富です。C# で書かれていますが、 C# を知らなくても雰囲気で読めました。

DDD の実装パターンを Rust で書いてみた

DDD の理解を深めるためには自分で書いてみるのが早い、ということ Rust で書いてみました。 コードは「ドメイン駆動設計入門」のサンプルコードをベースに Rust で書き直したものです。

kuwana-kb/ddd-in-rust

実装パターンの紹介

DDD における Value Object とは

実装パターンの紹介に入る前に、Value Object について整理します。 Value Objectには、以下のような特徴があります。

  • システム固有の値をオブジェクトとして定義したもの
    • ex. 金銭や製品番号など
  • プログラミング言語にはプリミティブな値が用意されているが、業務領域で使う値をオブジェクトとして定義することで、業務ルールに反した値を混入させない

また、Value Object の性質として、以下が挙げられます。

この性質をコードにも反映したいと思います。

まずはシンプルに実装してみる

まずは、シンプルに実装してみましょう。 今回は例として、氏名をコードで表現してみます。 氏名は以下のような特徴を持っているとします。

  • 氏名で構成される
  • は個別に出力できるようにする
  • はプリミティブな型とする

以下が実際のコードです。

// サンプルコード
// https://github.com/kuwana-kb/ddd-in-rust/blob/master/chapter02_value_object/src/a1_simple_vo.rs

#[derive(Clone, Debug)]
pub struct FullName {
    first_name: String,
    last_name: String,
}

impl FullName {
    pub fn new(first_name: &str, last_name: &str) -> Self {
        Self {
            first_name: first_name.to_string(),
            last_name: last_name.to_string(),
        }
    }

    pub fn first_name(&self) -> String {
        self.first_name.clone()
    }

    pub fn last_name(&self) -> String {
        self.last_name.clone()
    }
}

// trait PartialEqは半同値関係の性質を表す
// PartialEqを実装することで「==」演算子による比較が可能になる
impl PartialEq for FullName {
    fn eq(&self, other: &Self) -> bool {
        self.first_name() == other.first_name() && self.last_name() == other.last_name()
    }
}

impl Eq for FullName {}

#[test]
fn test_equality_of_vo() {
    let taro_tanaka_1 = FullName::new("taro", "tanaka");
    let taro_tanaka_2 = FullName::new("taro", "tanaka");
    let jiro_suzuki = FullName::new("jiro", "suzuki");

    // 値が同じVOの比較。一致する
    assert_eq!(taro_tanaka_1, taro_tanaka_2); // equal

    // 値が異なるVOの比較。一致しない
    assert_ne!(taro_tanaka_1, jiro_suzuki); // not equal
}

今回は、必要と思われるメソッドや trait を愚直に書きました。値の等価性は、PartialEqEq を実装することで表現しました。また、値の不変性は、 値を immutable にする(= mut な処理を実装しない)ことで表現しました。ひとまずこれで Value Object の性質をコードで表現することができましたね。

※ 今回は FullName の各フィールドに対して getter を実装していますが、Value Object が getter を持つべきかしっかりと検討した方がよいと考えます。無闇に getter をはやすことは、意図しない値の使われ方を招く可能性があるためです。

derive を使って実装を省略する

先程の例で Value Object を表現できたわけですが、ひとつの Value Object を定義するのにいちいち実装を書くのは手間ですね。ということで、次はderiveを用いて実装を省略してみます。

// サンプルコード
// https://github.com/kuwana-kb/ddd-in-rust/blob/master/chapter02_value_object/src/a2_derive_vo.rs

#[derive(Clone, Debug, Getters, PartialEq, Eq)]
pub struct FullName {
    first_name: String,
    last_name: String,
}

impl FullName {
    fn new(first_name: &str, last_name: &str) -> Self {
        Self {
            first_name: first_name.to_string(),
            last_name: last_name.to_string(),
        }
    }
}

derive マクロによって、記述量を削減することができました。 具体的には、PartialEq , Eqderive で自動実装し、 getter を Getters という derive マクロで自動実装しました。

derive マクロは、 std crate 以外にも外部 crate で定義された便利なものがたくさんあります。以下に一部をご紹介します。

  • derive-getters
    • フィールドのgetterを自動実装する derive マクロ
    • 公開したくないフィールドは skip attribute で飛ばせる
  • derive-new
    • コンストラクタを自動実装する derive マクロ
  • strum
    • enumに対する Display, FromStr を自動実装する derive マクロ

derive マクロは便利ですが、時と場合によって使い分ける必要があります。 例えば、先程紹介したderive-new。このマクロは、コンストラクタを自動生成してくれる点で便利ですが、コンストラクタ内にvalidationをはさみたい時は自分で実装する必要があります。

型による制約を与える

さて、これまで登場した例は Value Object の性質を満たしているものの、業務領域特有の値は特に持っていませんでした。今回は、業務領域特有の値を型として表現し、より実践で役立つ形にしたいと思います。

今回の例では、以下のような Value Object を表現してみます。

  • ビジネスの要求として、はアルファベットだけにしたい
  • 具体的には、FullName.first_name, FullName.last_name がアルファベットしか受け付けないようにする

それでは、実際のコードをみてみましょう。

// サンプルコード
// https://github.com/kuwana-kb/ddd-in-rust/blob/master/chapter02_value_object/src/a3_all_vo.rs

/// 氏名
// このケースでは、プリミティブだったフィールドに対して、独自型(Name)を定義している
// 独自型に対して制約を与えることで、「その型である = 制約を満たした値である」ことが保証される
#[derive(Clone, Debug, Getters, PartialEq, Eq)]
pub struct FullName {
    first_name: Name,
    last_name: Name,
}

impl FullName {
    pub fn new(first_name: Name, last_name: Name) -> Self {
        Self {
            first_name,
            last_name,
        }
    }
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Name(String);

impl FromStr for Name {
    type Err = String;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let re = Regex::new(r#"^[a-zA-Z]+$"#).unwrap();
        if re.is_match(s) {
            Ok(Name(s.to_string()))
        } else {
            bail!(MyError::type_error("許可されていない文字が使われています"))
        }
    }
}

#[test]
fn show_full_name() {
    let first_name = "taro".parse().unwrap();
    let last_name = "tanaka".parse().unwrap();
    // この時点でfirst_name, last_nameは型のコンストラクタがもつ制約によりアルファベットであることが保証されている
    let full_name = FullName::new(first_name, last_name);

    println!("{:?}", full_name); // FullName { first_name: Name("taro"), last_name: Name("tanaka") }
}

#[test]
fn test_parse_name() {
    let valid_name = "taro".parse::<Name>();
    let invalid_name_with_num = "taro123".parse::<Name>();
    let invalid_name_with_jpn = "太郎".parse::<Name>();
    assert!(valid_name.is_ok()); // Ok()
    assert!(invalid_name_with_num.is_err()); // Err()
    assert!(invalid_name_with_jpn.is_err()); // Err()
}

今回は、 氏名を構成するに制約を設けました。具体的には、 をプリミティブな型 String から、新しく独自に定義した型 Name に置き換えています。

このName型は、Name::from_str()でregexによる値のチェックをし、不正な値を弾くような実装になっています。したがって、Nameインスタンスを生成できるということは、今回のビジネス要求である「アルファベットのみの値で構成された文字列である」を満たした値になっていることが保証されます。

Value Object にふるまいをもたせる

Value Object は値としての意味だけでなく、ふるまいを持つことができます。 例えば、お金に対して加算のふるまいをもたせてみましょう。

今回実装するのは以下のような Value Object とします。

  • MoneyというValue Objectを定義する
  • このValue Objectに、加算のふるまいを実装する
  • 今回は、同じ通貨である場合のみ加算ができるとする

それでは実際のコードをみてみましょう。

// サンプルコード
// https://github.com/kuwana-kb/ddd-in-rust/blob/master/chapter02_value_object/src/a4_vo_with_behavior.rs

// 振る舞いを持つVO
// 具体的には通貨単位が一致した場合に限り加算が可能
//
// このケースでは通貨単位をフィールドの一部として定義している
// 通貨単位をフィールドではなく、型として表現するケースは`a5_vo_with_phantom`参照
#[derive(Clone, Debug, new, Eq, PartialEq)]
struct Money {
    amount: Decimal,
    currency: String,
}

// Add traitは「+」演算子による加算を表現する
impl Add for Money {
    type Output = Money;

    fn add(self, other: Money) -> Self::Output {
        // 通貨単位のチェック
        // 通貨単位が一致しない場合はpanicを起こす
        // traitのシグネチャ上、Result型として返せないのでこれは仕方ないはず...
        // その意味で、通貨単位を型として表現することでコンパイル時に検査できる方が嬉しいと思われる
        if self.currency != other.currency {
            panic!("Invalid currency")
        }
        let new_amount = self.amount + other.amount;
        Money::new(new_amount, self.currency)
    }
}

今回は、お金の持つ性質として、加算のふるまいを Add trait で実装しました。 また、「同じ通貨である場合のみ加算ができる」という要求を満たすため、 Money.currencyというフィールドを定義し、このフィールドを加算時にチェックするようにしています。

しかし、この実装には問題があります。 それはMoney.currency のチェックを add()メソッド上でしている点です。add()メソッドは返り値がtrait上で Self::Output と指定されており、Result型にできません。したがって、異なる通貨で加算した時のエラーハンドリングはpanic!()せざるを得ないです。panic はアプリケーションを強制終了させてしまうため、本番運用のプロダクトではなるべく利用を避けたいです。

幽霊型を用いて Value Object の型を区別する

さて、先程の実装だと異なる通貨単位同士を加算すると panic してしまうという問題がありました。そこで、通貨を値ではなく型として表現することで、この問題を解決したいと思います。

ここで登場するのが幽霊型(Phantom Type)です。 幽霊型について、Rustの公式のドキュメントをみてみましょう。

幽霊型(Phantom Type)とは実行時には存在しないけれども、コンパイル時に静的に型チェックされるような型のことです。 構造体などのデータ型は、ジェネリック型パラメータを一つ余分に持ち、それをマーカーとして使ったりコンパイル時の型検査に使ったりすることができます。このマーカーは実際の値を何も持たず、したがって実行時の挙動そのものにはいかなる影響ももたらしません。 https://doc.rust-jp.rs/rust-by-example-ja/generics/phantom.html

この幽霊型をラベルのような形で型のパラメータとして用いることで、同じ型も別の型としてコンパイル時に区別することができるようになります。今回の例のMoney<T>型がその一例です。

// サンプルコード
// https://github.com/kuwana-kb/ddd-in-rust/blob/master/chapter02_value_object/src/a5_vo_with_phantom.rs

// 振る舞いを持つVO
// 具体的には通貨単位が一致した場合に限り加算が可能
//
// このケースでは通貨単位を型として表現している
// Money<T>のTで通貨単位を表すようにする
// ここで嬉しいのは、誤った通貨単位同士の加算をコンパイル時に検査できること
// Tはただのラベルとして扱いたいだけだが消費しないと怒られるので、std::marker::PhantomDataを用いる
// 参考: https://keens.github.io/blog/2018/12/15/rustdetsuyomenikatawotsukerupart_1__new_type_pattern/
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Money<T> {
    amount: Decimal,
    currency: PhantomData<T>,
}

impl<T> Money<T> {
    fn new(amount: Decimal) -> Self {
        Self {
            amount,
            currency: PhantomData::<T>,
        }
    }
}

impl<T> Add for Money<T> {
    type Output = Money<T>;

    fn add(self, other: Money<T>) -> Self::Output {
        Self::new(self.amount + other.amount)
    }
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub enum JPY {}

#[derive(Clone, Debug, PartialEq, Eq)]
pub enum USD {}

#[test]
fn test_phantom_money() {
    let jpy_1 = Money::<JPY>::new(Decimal::new(1, 0));
    let jpy_2 = Money::<JPY>::new(Decimal::new(2, 0));

    let _usd = Money::<USD>::new(Decimal::new(3, 0));

    let result = jpy_1 + jpy_2; // コンパイルOk
    assert_eq!(result, Money::<JPY>::new(Decimal::new(3, 0)));
    // let cannot_compile = jpy_1 + usd; //コンパイルエラー
}

今回は、PhantomDataで通貨単位を型として表現しました。この実装によって、異なる通貨 = 異なる型となるため、異なる通貨の加算をコンパイル時にエラー検出できるようになりました。テストを見ていただくと、 jpy 同士の加算はコンパイルが通りますが、 jpy と usd の加算はコンパイルエラーになることがわかると思います。

幽霊型を用いることのメリットは、似たような型を複数作らなくても良くなる点にあります。 例えば、通貨に関する実装として、MoneyUsd, MoneyJpyといった形で通貨ごとにMoneyの型を分けることもできます。しかし、この実装方法は同じような実装が通貨分できてしまうため、あまりうれしくありません。今後扱う通貨の単位が増えた場合を考えるとなおさらです。 幽霊型であれば、通貨の単位を enum で追加するだけです。

まとめ

今回は、Rust で Value Object をどう表現できるか、についてご紹介しました。

Rust の機能によって、Value Object を豊かに表現することができました。 derive マクロ、 trait によるふるまいの実装、型システムによるコンパイル時チェックなどなど... Rustの魅力が少しでも伝われば幸いです。

また、今回のサンプルコードの元となった「ドメイン駆動設計入門」は良書です。ぜひ一緒に DDD に入門しましょう!

参考文献