LoginSignup
0
0

「エリック・エヴァンスのドメイン駆動設計」勉強会 Part2

Last updated at Posted at 2023-07-22

Part1 はこちら

Part1 に続き、こちらの記事は社内勉強会を意図して執筆したものを一部改良したものでありますので、その点だけご了承いただければと思います🙇‍♂️

Part2でまとめる内容

主に書籍第3部「より深い洞察へ向かうリファクタリング」を扱います。

なお、Part1では、第3・4部を扱うとお伝えしておりましたが、以下の理由により、第4部は割愛させていただきます。

  • 第3部は、モデル駆動設計を実行するにあたっての実装面・開発者視点での記述が多く、役立たせやすい学びが多い。
  • 第4部は、もっと大規模で複雑でドメインに役立つものが多いと思った。
  • 第1部〜第2部が180ページほど、第3部〜第4部が320ページほどの量があり、どう考えても1時間の勉強会に収められるわけがないと判断した💦

前回の内容の簡単な復習

  • ソフトウェアが対象とするドメインの理解に努めよ。その際、ドメインエキスパートと会話をせよ。
  • ドメインの理解を図などに表現し、そのままコードに落とし込め。
  • Entity, Value Object, Service, Aggregation, Repository など、よくあるパターンを引き出しとして持っておき、それらもヒントにモデルを磨き上げる。

Part2 の概要

今回は、よりモデルを深く精緻にしていくためのヒントを示してくれている第3部を解説していきます。

特に今回は、開発者視点でのよくあるパターンを学習し、その視点を持ってよりドメインモデルを豊かなものにしていくという意識で読んでみると個人的には理解しやすかったです。

ただし注意してほしいのは、あくまで全てはドメインモデルが正義であるべきだという点です。

モデル駆動設計では、あくまでモデルを中心とし、ソースコードは図はその忠実な投影であるべきです。

実装を思考の出発点としても、それでもドメインモデルを念頭に置いたものだということに注意してください。

概念を発見して命名する

しばしば業務やコーディングにおいて、実はドメインエキスパートや開発者の頭の中には存在しているが、暗黙的にしか認識されていないルールなどがあることは多いのではないでしょうか。

暗黙的な概念の例1:制約

あるバケツの内容量は最大容量を超えてはならないという制約(不変条件)を実装するようなクラスはたとえば以下のように書けます。

/**
 * バケツを表すクラス
 */
class Bucket {
    /**
     * 最大容量
     */
    private readonly capacity = 100;
    
    /**
     * 内容量
     */
    private contents = 0;    
    
    /**
     * 注ぐ
     */
    pourIn(addedVolume: number): void {
        /**
         * 最大容量を超えられないことを保証する
         */
        if (this.contents + addedVolume > this.capacity) {
            this.contents = this.capacity;
        } else {
            this.contents = this.contents + addedVolume;
        }
    }
}

これは非常に簡単な制約の例ですが、pourInというメソッドの中にこそっとルールが書かれています。
これは、ドメインモデルにこういった制約があることを表現しにくい実装になっているのではないでしょうか?(思い出してください。実装はドメインモデルの忠実な投影の1つです)

もっとドメインのルールが複雑で、ドメインのいろんなところに適用されるべきルールになってくればどうでしょう?

それほどこの『制約』に意味があるのであれば、明示的にその概念にラベルを与え、実装を与えるべきではないでしょうか?

たとえば簡単な方法は、制約を表すメソッドを定義することです。

    pourIn(addedVolume: number): void {
        this.contents = this.constrainedToCapacity(this.contents + addedVolume);
    }
    
    /**
     * 最大容量以下に制限する。
     * @param volumePlacedIn 入れられた量
     * @returns 
     */
    private constrainedToCapacity(volumePlacedIn: number): number {
        if (this.contents + volumePlacedIn > this.capacity) {
            return this.capacity;
        } else {
            return volumePlacedIn;
        }
    }

副次的なメリットは、pourInというメソッドが本来やるべきことに注力できているということです。
良いリファクタリングができているのではないでしょうか。

本書では、制約を実装するオブジェクトの設計が歪んでいる兆候を以下のように述べています:

  1. 制約を評価するために、本来であればオブジェクトの定義に合わないデータが必要になる。
  2. 関連するルールが複数のオブジェクトに出てきていて、コードを重複させなければならなくなったり、本来であれば同じ系統のオブジェクトを継承しなければならなくなったりする。
  3. 設計や要求に関する多くの会話が制約を巡って行われるが、実装では制約が手続き型のコードの中に隠されてしまっている。

特に3点目は思い当たるところが多いのではないでしょうか。

ある処理を実現するときにダラダラと手続きを書き連ね、よ〜〜〜く読んだら、処理のこの部分に仕様に関する重要な制約・ルールが記載されていることがある気がしますね。

たくさんのifを書いてるところは特に怪しいですね。

そうした、明らかに開発者が頭の中で認識していることをまずきちんと言語化しましょう、ということを本書ではアドバイスしてくれています。

そうした概念を実装するパターンとして、本書では『仕様(Specification)』というパターンを紹介しています。

仕様(Specification)

先ほどの例では、ルールが単純だったことと、1つのクラスで完結していたので単なるメソッドに制約をまとめました。

ところが、複数のオブジェクトが関連するようなものであったり、適用範囲が広いものになっていくと、1つのオブジェクトの振る舞い(=メソッド)として実装するには相応しくないケースが出てきます。

そこで、その種の制約を表現する"値オブジェクト"を明示的に作成することが良いアプローチです。

このオブジェクトは、何かしらの基準を満たしているかどうかを判定するロジックを実装するべきであり、そうした『述語(predicate)』のことを『仕様』と呼びます。

めちゃくちゃざっくり言うと、『仕様』が実装すべき述語は boolean を返すなど、AND, ORなどで結合できる形であるべきだということです。(そうすることで、複数の述語を組み合わせてより複雑な仕様を表現することができます)

仕様の適用例1:検証

請求書の種類に応じて、その請求書の期日が超過しているかどうかであったり、を判定することが業務上必要だとします。

このルール・仕様をモデリングしてみましょう。

// 請求書
class Invoice {
    // 金額
    price: number
    // 期日
    dueDate: Date
}

interface InvoiceSpecification {
    /**
     * 請求書が条件を満たしているかどうかを判定する。
     */
    isSatisfiedby(invoice: Invoice): boolean;
}

class DelinquentInvoiceSpecification implements InvoiceSpecification {
   constructor(private currentDate: Date) {}
   
   isSatisfiedby(invoice: Invoice): boolean {
       // シンプルな制約なら、以下のような実装でも良いだろう
       return invoice.dueDate < this.currentDate;

       // 本書では、「優良顧客なら期日に余裕を持たせる」という特別ルールがあるケースを紹介している。
       // そうなれば、以下のような実装もあるだろう。
       // const gracePeriod = invoice.customer().getPaymentGracePeriod();
       // const firmDeadline = invoice.dueDate.addDays(gracePeriod);
       // return this.currentDate > firmDeadline;

       // 注意:↑それっぽいコードを書いてますが、コンパイル通りません。雰囲気わかればここでは良い。
   }
}

こうすれば、制約に違反したオブジェクトを見つけるような処理を書こうとしたとき、以下のようにシンプルに書くことができるでしょう:

const invoices: Invoice[] = [/** */];

const delinquentInvoices = invoices.filter(invoice => {
    const spec = new DelinquentInvoiceSpecification(new Date());
    return spec.isSatisfiedby(invoice);
});

これだけでもいくつかメリットはあります:

  • 『制約』を明示的に表現し、コード上に"構造"を生み出したため、より深くコードを理解することができる。
  • この仕様を単独でテストすることができる。
  • 仕様の変更があった場合に、呼び出し側のコードを変更せずに改修をすることができる。
    • →もっと言うと、ドメイン層の変更に限定できる。アプリケーション層に変更が及ばない。

仕様の適用例2:選択

すでにメモリ上にオブジェクトのコレクション(Array, List とか)が存在するならば、よくある.filterなどの高階関数を使い、仕様を渡してやることで簡単に実現ができるでしょう。

たとえば以下のような Repository を実装できます:

class InvoiceRepository {
    invoices: Invoice[] = [/* 何かしらの手段でメモリに保持していたとする. */];
    
    selectSatisfying(spec: InvoiceSpecification): Invoice[] {
        return this.invoices.filter(spec.isSatisfiedby); 
    }
}

これはこれで良い実装だと思います。
システムに要求される仕様と、それを実際にどう適用するかが分離しているからです。

しかし、実際よくあるケースとしては、DBから所望のデータをフィルタリングして取得するケースだと思います。(よもや、テキトーにフルスキャンしてメモリ上で.filterとかすればいいじゃんとか考えている雑なエンジニアはいないでしょうね)

ここでは、DBから仕様を満たしたオブジェクトを取得する実装例について紹介します。

なお、以下では RDB を使っていることを想定して SQL を使って説明します。
SQL がよくわからないという方は、DynamoDB の Scan だと思っておいてください。

(注:必ずしもSELECT = Scanではないです。RDBはもっと奥が深いです)

惜しいアイデア:仕様オブジェクトにSQLを書いてしまう

延滞請求書仕様オブジェクトにSQLを書いてみましょう:

class DelinquentInvoiceSpecification implements InvoiceSpecification {
   constructor(private currentDate: Date) {}
   
   isSatisfiedby(invoice: Invoice): boolean {
       //.....
   }
   
   asSQL(): string {
       return `SELECT * FROM invoices WHERE due_date < ${this.currentDate.toISOString()}`;
   }
}

仕様を満たすべきSQLをうまく管理することができたので、あとは、.asSQL()で取得した SQL をDBに渡してあげればいいだけです。

しかしこれはよくない点があります。
それは、『テーブル構造の詳細がドメイン層に流出している』ことです。

ドメイン層はあくまでドメインモデルのことだけを知っているべきで、実際にどんな技術を使ってシステムが構築されているかは極力知らないでいるべきです。なぜならドメインはどんなDBを使っているかに関わらず存在しているものだからです。

改善案:SQL の詳細は Repositoryに、どの SQL を使うかは仕様に書く

簡単な改善は、Repositoryに特殊なクエリを記載することです。
もちろんこのとき、特殊なドメインのルールが Repository に取り込まれないよう、クエリを出力するメソッドは汎用的であるべきです。また、仕様に基づいたクエリを発行するために、ダブルディスパッチ1というテックニックが本書では紹介されています:


class InvoiceRepository {
    selectWhereGracePeriodPast(date: Date): Invoice[] {
        const sql = this.whereGracePeriodPastSQL(date);
        const result = this.executeQuery(sql);
        return this.buildInvoicesFromResultSet(result);
    }

    whereGracePeriodPastSQL(date: Date): string {
        // これはルールではなく、ただの特殊なクエリ
        return `SELECT * FROM invoices WHERE due_date < ${date.toISOString()}`;
    }
    
    // SQL の結果を Invoice オブジェクトに変換する。
    private buildInvoicesFromResultSet(result: any): Invoice[] {
        // TODO....
    }
    
    // sql を実行する。
    private executeQuery(sql: string): any {
        // TODO....
    }
    
    // ダブルディスパッチにより、Specification に基づいたデータの取得を実施する。
    selectSatisfying(spec: InvoiceSpecification): Invoice[] {
        return spec.satisfyingElementsFrom(this);
    }
}

interface InvoiceSpecification {
    /**
     * 請求書が条件を満たしているかどうかを判定する。
     */
    isSatisfiedby(invoice: Invoice): boolean;

    satisfyingElementsFrom(repository: InvoiceRepository): Invoice[]
}

// 延滞請求書仕様
class DelinquentInvoiceSpecification implements InvoiceSpecification {
   constructor(private currentDate: Date) {}
   
   isSatisfiedby(invoice: Invoice): boolean {
       //.....
   }
   
   satisfyingElementsFrom(repository: InvoiceRepository): Invoice[] {
       // 「延滞ルール」とは以下のように定義される:
       // "現日付を基点として経過した支払い猶予期間"
       return repository.selectWhereGracePeriodPast(this.currentDate);
   }
}

こうすることによって、仕様オブジェクト側でどんなクエリを発行するべきかを制御することができ、Repository 側では言われた通りのクエリを発行するだけという責務の分離ができています。

ここでは、ドメイン層とインフラ層を分離することを意図しているということに留意してもらえれば良いと思います。

仕様の適用例3:要求に基づいたオブジェクトの生成

これについては、実装は上述の2つの例と似たようなものになると思います。

ただし、こちらは『まだ存在していない、これからメモリ上に生成されるオブジェクトに対する条件を定義する』ことを意図したものになります。

何かしらのファクトリメソッドに組み合わせて実装するのがいいのではないかと想像しています。

しなやかな設計

ドメイン駆動設計では、リファクタリングおよびテストの重要性について度々言及されます。

なぜなら、ドメインモデルはエキスパートとの会話や実装を進める中で継続的に磨くものであり、常々変更があるものという前提だからです。一発でドメインについての完全な理解を得られるほど人間の頭は賢くないという前提と僕は理解しています。2

それを推し進めるためには、ソフトウェアは理解が容易で変更に対してしなやかでなければなりません。

本書では、ドメインモデルをより深めていくのに役立つ技術視点のパターンを解説しています。

ただし繰り返しになりますが、あくまでドメインモデルを念頭に置き、ドメインモデルがあってこその実装だという前提は忘れてはいけません。

Intention Revealing Interfaces (意図の明白なインターフェース)

目的を処理を達成するために、1ステップずつコードを読み進めながら、何をやろうとしているかの"意味"を汲み取らなければいけないコードは、果たして理解が容易でしょうか?

本書では以下のように言及されています:

オブジェクトのインターフェースを見ても、クライアント開発者がオブジェクトを有効に使用する上で何を知っているべきかがわからなければ、詳細をなんとか理解するためにコードの内部を掘り下げなければならなくなる。クライアントコードを読む人も同じことをしなければならないだろう。それでは、カプセル化の価値のほとんどが失われてしまう。我々は常に、認識の過負荷と闘っているのだ。

特にDDDにおいては、実装はモデルの投影であるべきでした。そしてモデルは、ドメイン(現実世界)を開発者やエキスパートがどう見ているか・理解しているかを表現したものです。

であるならば、手続き的に処理がダラダラ書いてあるコードは、DDDを実践しているとは言えません。
業務上の手続きには何か特定の概念に紐づいた意味のあるオペレーションであり、それはドメインの言葉で記述できるからです。そして、それを意味のあるメソッドやクラスにまとめるべきです。

こうした"意味のある"単位で処理をまとめなければ、理解は深まりません。頭に残りません。

『意図の明白なインターフェース』の指針について、本書では以下のように述べられています:

クラスと操作には、その効果と目的を記述する名前をつけ、約束したことを実行する手段には言及しないこと。そうすることで、クライアント開発者はインターフェースの内部を理解しなくてよくなる。こうした名前はユビキタス言語に従っていなければならない。そうすることで、チームメンバーがすぐ意味を推測できるようになるからだ。振る舞いを作成する前にそのテストを書いて、自分がクライアント開発者の視点で考えられるようにすること。

実装例

塗料店向けのアプリケーションの簡単な実装を顧客に見せてみることを考えてみましょう。
初期のクラスはこんな実装でした:

class Paint {
    v: number
    r: number
    y: number
    b: number
    
    paint(paint: Paint): void {
       this.v = this.v + paint.v;      
       // ↓何かしらのロジックで色を混ぜ合わせていることをしてるものとする。
       this.r = this.r + paint.r;
       this.y = this.y + paint.y;
       this.b = this.b + paint.b;
    }
} 

『混ぜ合わせていること』は、メソッド名からはあまりよく伝わりません。paint は『塗る』という意味の動詞です。3

業務上『混ぜ合わせる』ことに意味があるのなら、それが伝わる名前にしなければいけません。なぜなら、実装はドメインモデルの投影の1つなので。

class Paint {
    volume: number
    red: number
    yellow: number
    blue: number
    
    mixIn(paint: Paint): void {
        this.volume = this.volume + paint.volume;       
       // ↓何かしらのロジックで色を混ぜ合わせていることをしてるものとする。
    }
} 

Side Effect Free Functions (副作用のない関数)

コーディングをする際の"操作"は大きく2種類に分けられます:

  • Query(クエリ): システム(DBやファイルなど)から情報を取得する操作。副作用なし。
  • Command(コマンド): システムに対して変更を加える操作。副作用あり

副作用が限定的であれば、コードは予測しやすい形で振る舞ってくれます。
しかし、副作用が多重的に引き起こされる場合は、しんどいです。Command のなかでさらに別の Command を実行しているような場合です。

それゆえ本書では、目的を達成するためのロジックの多くの部分を純粋関数(Pure Function)4においておき、コマンドは厳密に分離して、ドメインの情報を返さないようなシンプルな実装にすることを推奨しています。(←この考え方は、めちゃくちゃ関数型言語の世界由来だと思います)

特に、複雑な操作が値オブジェクトの責務にマッチする場合には、値オブジェクトのメソッドとして実装することを推奨しています。値オブジェクトはその定義から、Immutable(不変)であるべきなので、副作用を分離するという意味でマッチします。

実装例

先ほどのPaintクラスを考えてみると、.mixInメソッドは副作用があるメソッドになっています。つまり、コマンドです。

副作用を分離するという点や、ドメインの情報を返さないという点でこれはうまく実装できているように思えます。

しかし変な点は、引数に渡されたpaintオブジェクトの容量がどうなるかについて全く言及しない点です。色を混ぜ合わせるという操作を考えたときに、一方の塗料だけ増えて、もう一方はそのままって、物理的にありえないですよね。つまりそういうことです。

ここで、"色"について考えてみます。
ここでの色は値オブジェクトとして扱って問題なさそうです。色が全く一緒であれば、同じ色として扱って問題がなさそうなので。(思い出そう、値オブジェクトは識別子ではなく、その属性に基づいて区別されうるオブジェクトであった)

また、『色を混ぜる』という操作は、色を表すオブジェクトの責務として定義しても不自然ではなさそうです。
よって、以下のような実装ができるでしょう:

// 顔料色
class PigmentColor {
    // ↓typescript では READONLY にすることで副作用を防ぐことができる。
    private readonly red: number
    private readonly yellow: number
    private readonly blue: number

    constructor(red: number, yellow: number, blue: number) {
        this.red = red
        this.yellow = yellow
        this.blue = blue
    }
    
    mixedWith(other: PigmentColor, ratio: number): PigmentColor {
        // ↓新しいオブジェクトを返すことで副作用を防ぐ!
        return new PigmentColor(
            this.red + other.red,
            this.yellow + other.yellow,
            this.blue + other.blue
        )
    }
}

このオブジェクトをPaintオブジェクトから利用するようにすれば、色を混ぜ合わせるという副作用のない処理を分離して、よりドメインの理解を深めたコーディングに落とし込むことができます。

また、こうしたPigmentColorのような実装は副作用がないことでテストのしやすいコードになっています。

副作用を厳密に分離し、よりテストのしやすいコードにしていきましょう。

心残り

Strategy Pattern, Composite Pattern というデザインパターンについても触れられていましたが、少し量が多くなってしまったかなと思ったので割愛させていただきました。

興味のある方は、増補改訂版Java言語で学ぶデザインパターン入門などを読んでみてください。

まとめ

今回は、ドメイン駆動設計に活かせる実装パターンについて紹介しました。

繰り返しになりますが、あくまでドメインモデルを成熟させていくためによく使われるパターンだということ、モデル駆動設計はドメインと実装を相互に取り込んだものであるべきという根本の考え方が重要です。

今後の実装の際の引き出しの1つになれば幸いです。

参考文献

  • エリック・エヴァンスのドメイン駆動設計 (IT Architects’Archive ソフトウェア開発の実践) 翔泳社 エリック・エヴァンス (著), 今関 剛 (監修), 和智 右桂 (翻訳), 牧野 祐子 (翻訳)
  1. ダブルディスパッチが意図していることは、「データ構造と処理の分離」です。ここでは、仕様オブジェクトというデータ構造と、DBからデータを取得する処理を分離することを意図しています。典型的には、Visitor パターンというデザインパターンの1つで使われているテクニックです。

  2. エンジニアリングでもなんでもそうですが、人間の頭はあまりよくないことを前提に考えた方が建設的だと思ってます。もっと言うと、短期記憶・ワーキングメモリに負荷をかけることを強要するコードは悪だと思ってます。仮に、手続きがダラダラ書かれた1万行のソースコードが問題なく理解できてミスなく保守する能力を人類が持っているなら、種々の設計技法の概念はそもそも生まれていないと思います。

  3. 英語をちゃんと読むことが基本ですけど、重要です。

  4. 本書では、副作用のない処理の単位を単に関数(Function) と呼んでいます。まあ数学に親しみあればこの呼び方はしっくりくるのですが、エンジニア視点で考えたいので、この記事では、もうちょい明確に純粋関数(Pure Function) という名前を使うことにします。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0