🏗️

ドメイン層を純粋に〜リポジトリ都合の実装をドメイン層から剥がす〜

2024/10/06に公開

DDDでリポジトリの実装の都合がドメイン層に書かれてしまう問題

ドメイン駆動設計(DDD)では、リポジトリの実装の都合がドメイン層に書かれてしまうと、ドメインで表現したかったことにシステムの都合が混在する形になり、DDDのメリットを100%発揮できなくなってしまいます。

この問題を解決するために、Javaの関数型インターフェース、特にConsumerを使ったリファクタリング方法を紹介します。Consumerとラムダ式を使うことで、ドメイン層とリポジトリの実装を引き離し、堅牢な設計が可能になります。

具体的な例を使って、コードの改善プロセスを一緒に見ていきましょう。

Consumerとは

関数型インターフェースは、ラムダ式を使って簡潔に処理を記述できる便利な仕組みです。中でもConsumer<T>は、1つの引数を受け取って何らかの処理を行うインターフェースで、戻り値はないシンプルなインターフェースです。
例えばConsumer<String>は「文字列を受け取って何かを行う処理」を定義できます。

Consumer<String> printer = message -> System.out.println(message);
printer.accept("コンニチワ!!");

リポジトリの実装の都合が、ドメイン層に書かれている例

Consumerを学んだので、リポジトリの実装の都合がドメイン層に書かれてしまったコードを見てみましょう。

なんらかのコンテンツを操作するContentRepositoryインターフェースとその実装クラスContentRepositoryImplがあるとします。

public interface ContentRepository {
    List<Content> fetchContents();
}

実装クラスは外部APIを利用してデータを取得する処理をします。外部APIの仕様上、1回のリクエストで最大100件のデータしか取得できません。

public class ContentRepositoryImpl implements ContentRepository {
    private final ExternalApi externalApi;

    public ContentRepositoryImpl(ExternalApi externalApi) {
        this.externalApi = externalApi;
    }

    @Override
    public List<Content> fetchContents() {
        return externalApi.fetchContents(100);
    }
}

そしてリポジトリを利用する側の処理であるContentServiceがあるとします。

public class ContentService {
    // この変数はなんらかの手段でDIされているとします
    private final ContentRepository contentRepository;

    public ContentService(ContentRepository contentRepository) {
        this.contentRepository = contentRepository;
    }

    public void run() {
        // 300件のデータを取得したいが、外部APIの都合で100ずつしか取得できないため
        // 3回ループする必要がある。これにより、ドメイン層にリポジトリの実装詳細が漏れてしまう。 
        List<Content> contentsList = new ArrayList<>();
        for (int i = 0; i < 3; i++) {
            contentsList.addAll(contentRepository.fetchContents());
        }
        
        // 300件のデータが格納されたcontentsListを利用して後続処理が続く…
        System.out.println(contentsList);
    }
}

Consumerを使ったリファクタリング

Consumerを使ってリポジトリの実装の都合による実装(3回ループする部分)をContentRepositoryImplに移動します。

まずはContentRepositoryのコードをリファクタリングして、取得したコンテンツに対する処理を外部から渡せるように変更します。

public interface ContentRepository {
    void fetchAndProcessContents(Consumer<List<Content>> contentProcessor, int count);
}

そしてContentRepositoryImplも合わせて変更します。

public class ContentRepositoryImpl implements ContentRepository {
    private final ExternalApi externalApi;

    public ContentRepositoryImpl(ExternalApi externalApi) {
        this.externalApi = externalApi;
    }

    // ■■■■■変更点■■■■■
    // 引数にConsumerと件数をとるようになった。
    // 戻り値が呼出し側で不要になったのでvoidにし、メソッド名も変更した。
    // 300件を取得する実装はこのメソッドでやるようにした。
    @Override
    public void fetchAndProcessContents(Consumer<List<Content>> contentProcessor, int count) {
        // countの数のContentsを取得する
        List<Content> contentsList = new ArrayList<>();
        while (contentsList.size() < count) {  // 指定件数未満なら取得を続ける
            List<Content> fetched = externalApi.fetchContents(100);
            contentsList.addAll(fetched);

            // 取得したコンテンツのリストがcountを超えた場合は、リストをtrimして渡す
            if (contentsList.size() > count) {
                contentProcessor.accept(contentsList.subList(0, count));
                return;
            }
        }

        contentProcessor.accept(contentsList);
    }
}

変更したリポジトリを利用するコードも変更します。

public class ContentService {
    private final ContentRepository contentRepository;

    public ContentService(ContentRepository contentRepository) {
        this.contentRepository = contentRepository;
    }

    public void run() {
        contentRepository.fetchAndProcessContents(contentsList -> {
           // コンテンツを利用した後続処理 
           System.out.println(contentsList);
        }, 300);
    }
}

まとめ

この記事では、リポジトリの実装の都合がドメイン層に書かれてしまう問題に焦点を当て、Javaの関数型インターフェースであるConsumerを活用して、これを解決する方法を紹介しました。

もともとは、外部APIの仕様により、ドメイン層のContentServiceクラスで複数回のAPI呼び出しを直接行い、その結果、ドメイン層にリポジトリの実装詳細が漏れ出すという問題がありました。

この問題に対しConsumerを使うことで、取得したコンテンツに対する処理をリポジトリ側で柔軟に受け渡しできるようにリファクタリングしました。これにより、ContentServiceはAPIの呼び出し回数やデータ取得のロジックを気にせず、純粋にドメイン層のロジックに集中することができました。

他の関数型インターフェースの紹介

Consumer以外にも便利な関数型インターフェースがいくつかあり、これらをうまく使うことで、さらに柔軟にコードを書くことができます。

  • Supplier<T>: 引数を取らずに、何らかの値(T型)を返すインターフェースです。
Supplier<String> messageSupplier = () -> "Hello, World!";
System.out.println(messageSupplier.get());  // Hello, World!
  • Function<T, R>: 引数として1つの値(T型)を受け取り、結果(R型)を返すインターフェースです。データの変換やマッピングに便利です。
Function<String, Integer> lengthFunction = String::length;
System.out.println(lengthFunction.apply("Java"));  // 4
  • Predicate<T>: 引数を1つ受け取り、その条件に基づいてtrueかfalseを返すインターフェースです。streamのfilterで指定するやつです。
Predicate<Integer> isEven = num -> num % 2 == 0;
System.out.println(isEven.test(4));  // true
  • BiConsumer<T, U>: 2つの引数を受け取り、何らかの処理を行うインターフェースです。
BiConsumer<String, Integer> printer = (text, number) -> System.out.println(text + ": " + number);
printer.accept("Count", 10);  // Count: 10

Discussion