DDDで開発する際におさえておきたい4つの基本事項

23252 ワード

DDDで開発しようと思って、入門書を勉強して理解した気になっても、いざコードを書こうとすると、なかなか実装のイメージがつかなくて手が止まる、といったケースはあるかと思います。少なくとも、私はそうでした。
この記事では、一旦、DDDのモデリングの部分は置いておいて、コードを実装する上で知っておいた方が良さそうなことをいくつかピックアップして紹介していきたいと思います。いずれも基本的な内容のため、DDDを習得している方にはあまり新しい発見はないかもしれません。
なお、例として用いる言語はTypeScriptです。

DDDで実装してみてなにが良かったか

本題に入る前に、まずはDDDで実装するモチベーションを上げていただくために、実際にDDDで実装してみてよかった点をいくつかあげます。

  • ロジックを書く場所に悩まない・チームで統一できる
    • 例えばMVCなど、ドメイン層を用意しないアーキテクチャで実装した場合、ロジックをどこに書こうか悩みが生じます。結果として、チームで開発していると特に、人によってロジックを書く箇所がバラバラになり、統一感の欠けるコードになります。一方で、DDDで実装していれば、まずはドメイン層で書けないかな?という思考が働き、ロジックの分散を防ぐことができます。
  • ドメイン層のテストが書きやすい
    • ドメイン層はテストがとにかく書きやすいので、どんどんテストを書きたくなります。ドメイン層のコードはどこにも依存していないので、事前の準備がほとんど不要で、テストしたいことに集中できます。
  • ユースケースが理解しやすい
    • ユースケースにロジックを詰め込んでしまうと、見通しが悪くなり、全体として何をやっているかがわからなくなります。DDDでは、ユースケースはドメインオブジェクトのメソッドを呼び出す単純な処理がメインになるので、何をやっているかがコードを見ただけで明確にわかります。
  • APIのエンドポイントがORMのエンティティに振り回されない
    • ドメインオブジェクトを用意しない実装の場合、ORMのエンティティを中心とした、DBのデータを取ってきて返す・登録するだけのAPIになりがちです。もちろん単純な要件の場合はこれでも良いのですが、要件が複雑になってくると、呼び出し側の処理が必要以上に複雑になってしまいます。DDDでは、ORMのエンティティとは別に、ビジネスとして意味を持ったまとまりをオブジェクトとして設計するので、結果として、呼び出し側の処理が必要以上に複雑になる現象は起きにくいです。

一応、よくなかった?点も書いておきます。

  • 学習コストが高い
  • データの詰め替え処理のコード量は増える
  • 更新時の処理はパフォーマンスが落ちる
    • 基本的に、DDDではパフォーマンスよりもデータの整合性を重視します。特に、更新時は一発でデータを更新するのではなく、一度selectしてから更新をかける実装になるので、発行するSQL自体は増えてしまいます。

おさえておきたい4つの基本事項

1. ORMのエンティティとは別にドメイン層にエンティティを用意する

ドメインオブジェクトは、基本的にエンティティを使って表現します。エンティティと聞くと、ORMのエンティティが思い浮かぶかもしれませんが、全く別物です。ORMのエンティティは、TypeORMなどの特定のライブラリに依存するものですが、ドメイン層のエンティティはプレーンなオブジェクトで、何にも依存してはいけません。ORMのエンティティにロジックを書く、といった横着はせず、諦めてドメイン層に別のエンティティを用意しましょう。
以下、シンプルな例です。

  • ドメイン層のエンティティ
    domain/entity/User.ts
    export default class User {
      id: number;
      familyName: string;
      givenName: string;
    
      constructor(id: number, familyName: string, givenName: string) {
        this.id = id;
        this.familyName = familyName;
        this.givenName = givenName;
      }
    }
    
  • ORMのエンティティ
    infra/entity/User.ts
    import { Entity, PrimaryGeneratedColumn, Column } from "typeorm";
    
    @Entity()
    export default class User {
      @PrimaryGeneratedColumn()
      id: number;
    
      @Column({ type: "varchar", default: "" })
      familyName: string;
    
      @Column({ type: "varchar", default: "" })
      givenName: string;
    
      constructor(id: number, familyName: string, givenName: string) {
        this.id = id;
        this.familyName = familyName;
        this.givenName = givenName;
      }
    }
    

2. リポジトリの取得メソッドはドメインオブジェクトに詰め替えて値を返す

リポジトリの取得系のメソッド(findなど)は、DBから取得した値をそのまま返すのではなく、ドメインオブジェクトに詰め替えてから返します。これにより、リポジトリから値を受け取ったユースケースは、すぐにドメインオブジェクトのメソッドを呼び出すことが可能になります。以下、例です。

infra/repository/User.ts
public async findById(id: number): Promise<UserEntity | undefined> {
  const user = await this.repository.findOne({ id }); // this.repositoryはTypeORMのリポジトリ
  if (user == null) return;

  return this.makeEntity(user);
}

// DBから取得した値をドメインオブジェクトに詰め替える処理
makeEntity(infraUser: User): UserEntity {
  const user = new UserEntity(
    infraUser.id,
    infraUser.familyName,
    infraUser.givenName
  );
  return user;
}

3. ドメインオブジェクトとリポジトリは1対1の関係ではない

2の説明を見て、基本的にドメインオブジェクトとリポジトリは1対1で紐づくのかなと、誤解されるかもしれませんが、違います。リポジトリと1対1で紐づくのは集約という概念です。集約とは、教科書的には「整合性が必要なオブジェクトのまとまり」です。
詳細な説明は省き、例を示します。
下記の場合、Article(記事)エンティティ・Section(章)エンティティがそれぞれ単体で存在することはあり得ず、必ずセットで作成されるという前提の場合、同じ集約となり、その集約単位でリポジトリが一つ作成されることになります。

  • 集約ルートとなるArticle(記事)エンティティ

    domain/entity/Article.ts
    import Section from "./Section";
    
    export default class Article {
      id: number | null;
      title: string;
      sections: Section[];
    
      constructor(id: number | null, title: string, sections: Section[]) {
        this.id = id;
        this.title = title;
        this.sections = sections;
      }
    }
    
  • 集約ルートに紐づくSection(章)エンティティ

    domain/entity/Section.ts
    export default class Section {
      id: number | null;
      title: string;
    
      constructor(id: number | null, title: string) {
        this.id = id;
        this.title = title;
      }
    }
    
  • 集約ルートのリポジトリ

    infra/repository/Article.ts
      public async save(article: ArticleEntity): Promise<void> {
        // Article(記事)とそれに紐づくSection(章)をセットで保存する
        const result = await this.repository.save(article);
        return;
      }
    

4. ドメインオブジェクトの生成メソッドとDBの値から再構築するメソッドは分ける

ドメインオブジェクトを生成するメソッドに初期値などのルールを設定していくと、そのメソッドを使ってリポジトリ内でDBの値からインスタンスを組み立てることはできなくなります。
こうした場合は、ドメインオブジェクトの生成メソッドと、DBの値から再構築するメソッドを分けます。
以下、例です。
こちらの例では、ドメインオブジェクトの生成時はcreateメソッドを呼び出し、DBからの再構築時はコンストラクタを呼ぶようにしています。

domain/entity/User.ts
export default class User {
  id?: number;
  familyName: string;
  givenName: string;
  status: number;

  constructor(
    id: number | undefined,
    familyName: string,
    givenName: string,
    status: number
  ) {
    this.id = id;
    this.familyName = familyName;
    this.givenName = givenName;
    this.status = status;
  }

  create(familyName: string, givenName: string): User {
    return new User(
      undefined, // 初期生成時にはidは決まらない
      familyName,
      givenName,
      1 // 初期生成時のstatusは必ず1
    );
  }
}

おまけ

最終的に、以下のようなディレクトリ構成になりました。

.
├── domain
│   ├── entity
│   ├── repository
├── infra
│   ├── entity
│   └── repository
├── presentation
│   ├── middleware
│   ├── route
├── tests
│   ├── factory
│   ├── feature
│   └── unit
└── use-case