1. 序章

このチュートリアルでは、コマンドクエリ責任分離(CQRS)とイベントソーシングのデザインパターンの基本的な概念について説明します。

補完的なパターンとしてよく引用されますが、それらを個別に理解し、最終的にそれらがどのように相互に補完するかを確認します。 これらのパターンを採用するのに役立つAxonなど、いくつかのツールとフレームワークがありますが、基本を理解するためにJavaで簡単なアプリケーションを作成します。

2. 基本概念

これらのパターンを実装する前に、まずこれらのパターンを理論的に理解します。 また、個々のパターンとして非常によく立っているので、それらを混ぜずに理解しようとします。

これらのパターンは、エンタープライズアプリケーションで一緒に使用されることが多いことに注意してください。 この点で、他のいくつかのエンタープライズアーキテクチャパターンからも恩恵を受けています。 それらのいくつかについては、説明しながら説明します。

2.1. イベントソーシング

イベントソーシングは、アプリケーションの状態をイベントの順序付けられたシーケンスとして永続化する新しい方法を提供します。 これらのイベントを選択的にクエリし、いつでもアプリケーションの状態を再構築できます。 もちろん、これを機能させるには、アプリケーションの状態に対するすべての変更をイベントとして再イメージ化する必要があります。


ここでのこれらのイベントは発生した事実であり、変更することはできません。つまり、不変である必要があります。 アプリケーションの状態を再現するには、すべてのイベントを再生するだけです。

これにより、イベントを選択的に再生したり、一部のイベントを逆に再生したりする可能性も広がります。 結果として、アプリケーションの状態自体を二次市民として扱うことができ、イベントログを主要な真実の情報源として扱うことができます。

2.2. CQRS

簡単に言えば、CQRSはアプリケーションアーキテクチャのコマンド側とクエリ側を分離することです。 CQRSは、Bertrand Meyerによって提案されたコマンドクエリ分離(CQS)の原則に基づいています。 CQSは、ドメインオブジェクトに対する操作を、クエリとコマンドの2つの異なるカテゴリに分割することを提案しています。

クエリは結果を返し、システムの監視可能な状態を変更しません。コマンドはシステムの状態を変更しますが、必ずしも値を返すとは限りません。

これは、ドメインモデルのコマンド側とクエリ側を明確に分離することで実現します。 もちろん、データストアの書き込み側と読み取り側を分割して、それらの同期を維持するメカニズムを導入することで、さらに一歩進めることができます。

3. シンプルなアプリケーション

まず、ドメインモデルを構築するJavaの簡単なアプリケーションについて説明します。

このアプリケーションは、ドメインモデルでCRUD操作を提供し、ドメインオブジェクトの永続性も備えています。 CRUDは、Create、Read、Update、およびDeleteの略で、ドメインオブジェクトに対してを実行できる基本的な操作です。

同じアプリケーションを使用して、後のセクションでイベントソーシングとCQRSを紹介します。

このプロセスでは、この例では、ドメイン駆動設計(DDD)概念の一部を活用します。

DDDは、複雑なドメイン固有の知識に依存するソフトウェアの分析と設計に対応します。 これは、ソフトウェアシステムがドメインの十分に開発されたモデルに基づく必要があるという考えに基づいています。 DDDは、パターンのカタログとしてEricEvansによって最初に規定されました。 これらのパターンのいくつかを使用して、例を作成します。

3.1. アプリケーションの概要

ユーザープロファイルの作成と管理は、多くのアプリケーションで一般的な要件です。 永続性とともにユーザープロファイルをキャプチャする単純なドメインモデルを定義します。

ご覧のとおり、ドメインモデルは正規化されており、いくつかのCRUD操作が公開されています。 これらの操作はデモンストレーション用であり、要件に応じて単純または複雑にすることができます。 さらに、ここでの永続性リポジトリはメモリ内にあるか、代わりにデータベースを使用できます。

3.2. アプリケーションの実装

まず、ドメインモデルを表すJavaクラスを作成する必要があります。 これはかなり単純なドメインモデルであり、イベントソーシングやCQRSのような複雑なデザインパターンを必要としない場合もあります。 ただし、基本を理解することに焦点を当てるために、これは単純なままにしておきます。

public class User {
private String userid;
    private String firstName;
    private String lastName;
    private Set<Contact> contacts;
    private Set<Address> addresses;
    // getters and setters
}

public class Contact {
    private String type;
    private String detail;
    // getters and setters
}

public class Address {
    private String city;
    private String state;
    private String postcode;
    // getters and setters
}

また、アプリケーションの状態を永続化するための単純なメモリ内リポジトリを定義します。 もちろん、これは何の価値も追加しませんが、後のデモンストレーションには十分です。

public class UserRepository {
    private Map<String, User> store = new HashMap<>();
}

次に、ドメインモデルで一般的なCRUD操作を公開するサービスを定義します。

public class UserService {
    private UserRepository repository;
    public UserService(UserRepository repository) {
        this.repository = repository;
    }

    public void createUser(String userId, String firstName, String lastName) {
        User user = new User(userId, firstName, lastName);
        repository.addUser(userId, user);
    }

    public void updateUser(String userId, Set<Contact> contacts, Set<Address> addresses) {
        User user = repository.getUser(userId);
        user.setContacts(contacts);
        user.setAddresses(addresses);
        repository.addUser(userId, user);
    }

    public Set<Contact> getContactByType(String userId, String contactType) {
        User user = repository.getUser(userId);
        Set<Contact> contacts = user.getContacts();
        return contacts.stream()
          .filter(c -> c.getType().equals(contactType))
          .collect(Collectors.toSet());
    }

    public Set<Address> getAddressByRegion(String userId, String state) {
        User user = repository.getUser(userId);
        Set<Address> addresses = user.getAddresses();
        return addresses.stream()
          .filter(a -> a.getState().equals(state))
          .collect(Collectors.toSet());
    }
}

これは、単純なアプリケーションをセットアップするために私たちがしなければならないこととほぼ同じです。 これは実稼働対応のコードにはほど遠いですが、このチュートリアルの後半で検討する重要なポイントのいくつかを公開しています。

3.3. このアプリケーションの問題

イベントソーシングとCQRSとの議論をさらに進める前に、現在のソリューションの問題について議論する価値があります。 結局のところ、これらのパターンを適用することで同じ問題に対処します!

ここで気付く可能性のある多くの問題のうち、2つに焦点を当てたいと思います。

  • ドメインモデル:読み取りと書き込みの操作は同じドメインモデルで行われています。 このような単純なドメインモデルでは問題ありませんが、ドメインモデルが複雑になると悪化する可能性があります。 読み取りおよび書き込み操作の個々のニーズに合わせて、ドメインモデルとその基盤となるストレージを最適化する必要がある場合があります。
  • Persistence :ドメインオブジェクトの永続性には、ドメインモデルの最新の状態のみが保存されます。 これはほとんどの状況で十分ですが、一部のタスクは困難になります。 たとえば、ドメインオブジェクトの状態がどのように変化したかについて履歴監査を実行する必要がある場合、ここでは不可能です。 これを実現するには、ソリューションにいくつかの監査ログを追加する必要があります。

4. CQRSの紹介

アプリケーションにCQRSパターンを導入することにより、前のセクションで説明した最初の問題への対処を開始します。 この一環として、ドメインモデルとその永続性を分離して、書き込み操作と読み取り操作を処理します。 CQRSパターンがアプリケーションをどのように再構築するかを見てみましょう。

この図は、アプリケーションアーキテクチャを書き込み側と読み取り側に明確に分離する方法を説明しています。 ただし、ここでは、理解を深める必要のあるかなりの数の新しいコンポーネントを導入しました。 これらはCQRSに厳密に関連しているわけではありませんが、CQRSはそれらから大きな恩恵を受けることに注意してください。

  • アグリゲート/アグリゲーター

アグリゲートは、ドメイン駆動設計(DDD)で説明されているパターンであり、エンティティをアグリゲートルートにバインドすることにより、さまざまなエンティティを論理的にグループ化します。 集約パターンは、エンティティ間のトランザクションの一貫性を提供します。

CQRSは当然、書き込みドメインモデルをグループ化し、トランザクション保証を提供する集約パターンの恩恵を受けます。 アグリゲートは通常、パフォーマンスを向上させるためにキャッシュされた状態を保持しますが、それがなくても完全に機能します。

  • プロジェクション/プロジェクター

投影は、CQRSに大きなメリットをもたらすもう1つの重要なパターンです。 投影は、基本的に、ドメインオブジェクトをさまざまな形状と構造で表現することを意味します

これらの元のデータの予測は読み取り専用であり、高度に最適化されており、読み取りエクスペリエンスが向上しています。 パフォーマンスを向上させるためにプロジェクションをキャッシュすることもできますが、それは必須ではありません。

4.1. アプリケーションの書き込み側の実装

まず、アプリケーションの書き込み側を実装しましょう。

まず、必要なコマンドを定義します。 コマンドは、ドメインモデルの状態を変更することを目的としています。 成功するかどうかは、構成するビジネスルールによって異なります。

コマンドを見てみましょう:

public class CreateUserCommand {
    private String userId;
    private String firstName;
    private String lastName;
}

public class UpdateUserCommand {
    private String userId;
    private Set<Address> addresses;
    private Set<Contact> contacts;
}

これらは、変更する予定のデータを保持する非常に単純なクラスです。

次に、コマンドの取得と処理を担当するアグリゲートを定義します。 アグリゲートは、コマンドを受け入れるか拒否することができます。

public class UserAggregate {
    private UserWriteRepository writeRepository;
    public UserAggregate(UserWriteRepository repository) {
        this.writeRepository = repository;
    }

    public User handleCreateUserCommand(CreateUserCommand command) {
        User user = new User(command.getUserId(), command.getFirstName(), command.getLastName());
        writeRepository.addUser(user.getUserid(), user);
        return user;
    }

    public User handleUpdateUserCommand(UpdateUserCommand command) {
        User user = writeRepository.getUser(command.getUserId());
        user.setAddresses(command.getAddresses());
        user.setContacts(command.getContacts());
        writeRepository.addUser(user.getUserid(), user);
        return user;
    }
}

アグリゲートはリポジトリを使用して現在の状態を取得し、変更を保持します。 さらに、すべてのコマンドを処理する際のリポジトリへのラウンドトリップコストを回避するために、現在の状態をローカルに保存する場合があります。

最後に、ドメインモデルの状態を保持するためのリポジトリが必要です。 これは通常、データベースまたはその他の耐久性のあるストアになりますが、ここでは、それらをメモリ内のデータ構造に置き換えるだけです。

public class UserWriteRepository {
    private Map<String, User> store = new HashMap<>();
    // accessors and mutators
}

これで、アプリケーションの書き込み側は終了です。

4.2. アプリケーションの読み取り側の実装

ここで、アプリケーションの読み取り側に切り替えましょう。 まず、ドメインモデルの読み取り側を定義します。

public class UserAddress {
    private Map<String, Set<Address>> addressByRegion = new HashMap<>();
}

public class UserContact {
    private Map<String, Set<Contact>> contactByType = new HashMap<>();
}

読み取り操作を思い出すと、これらのクラスがそれらを処理するために完全にマップされていることを確認するのは難しくありません。 それが、私たちが持っているクエリを中心としたドメインモデルを作成することの美しさです。

次に、読み取りリポジトリを定義します。 繰り返しになりますが、実際のアプリケーションではより耐久性のあるデータストアになりますが、メモリ内のデータ構造を使用します。

public class UserReadRepository {
    private Map<String, UserAddress> userAddress = new HashMap<>();
    private Map<String, UserContact> userContact = new HashMap<>();
    // accessors and mutators
}

次に、サポートする必要のあるクエリを定義します。 クエリはデータを取得することを目的としています—必ずしもデータになるとは限りません。

クエリを見てみましょう:

public class ContactByTypeQuery {
    private String userId;
    private String contactType;
}

public class AddressByRegionQuery {
    private String userId;
    private String state;
}

繰り返しますが、これらはクエリを定義するためのデータを保持する単純なJavaクラスです。

今必要なのは、これらのクエリを処理できるプロジェクションです。

public class UserProjection {
    private UserReadRepository readRepository;
    public UserProjection(UserReadRepository readRepository) {
        this.readRepository = readRepository;
    }

    public Set<Contact> handle(ContactByTypeQuery query) {
        UserContact userContact = readRepository.getUserContact(query.getUserId());
        return userContact.getContactByType()
          .get(query.getContactType());
    }

    public Set<Address> handle(AddressByRegionQuery query) {
        UserAddress userAddress = readRepository.getUserAddress(query.getUserId());
        return userAddress.getAddressByRegion()
          .get(query.getState());
    }
}

ここでのプロジェクションは、前に定義した読み取りリポジトリを使用して、クエリに対処します。 これで、アプリケーションの読み取り側もほぼ終了します。

4.3. 読み取りデータと書き込みデータの同期

このパズルの一部はまだ解決されていません。書き込みリポジトリと読み取りリポジトリを同期するものは何もありません。

ここで、プロジェクターと呼ばれるものが必要になります。 プロジェクターには、書き込みドメインモデルを読み取りドメインモデルに投影するロジックがあります。

これを処理するためのはるかに洗練された方法がありますが、比較的単純に保ちます。

public class UserProjector {
    UserReadRepository readRepository = new UserReadRepository();
    public UserProjector(UserReadRepository readRepository) {
        this.readRepository = readRepository;
    }

    public void project(User user) {
        UserContact userContact = Optional.ofNullable(
          readRepository.getUserContact(user.getUserid()))
            .orElse(new UserContact());
        Map<String, Set<Contact>> contactByType = new HashMap<>();
        for (Contact contact : user.getContacts()) {
            Set<Contact> contacts = Optional.ofNullable(
              contactByType.get(contact.getType()))
                .orElse(new HashSet<>());
            contacts.add(contact);
            contactByType.put(contact.getType(), contacts);
        }
        userContact.setContactByType(contactByType);
        readRepository.addUserContact(user.getUserid(), userContact);

        UserAddress userAddress = Optional.ofNullable(
          readRepository.getUserAddress(user.getUserid()))
            .orElse(new UserAddress());
        Map<String, Set<Address>> addressByRegion = new HashMap<>();
        for (Address address : user.getAddresses()) {
            Set<Address> addresses = Optional.ofNullable(
              addressByRegion.get(address.getState()))
                .orElse(new HashSet<>());
            addresses.add(address);
            addressByRegion.put(address.getState(), addresses);
        }
        userAddress.setAddressByRegion(addressByRegion);
        readRepository.addUserAddress(user.getUserid(), userAddress);
    }
}

これはかなりこれを行うための非常に大雑把な方法ですが、CQRSが機能するために何が必要かについて十分な洞察を与えてくれます。 さらに、読み取りリポジトリと書き込みリポジトリを異なる実店舗に配置する必要はありません。 分散システムには独自の問題があります。

書き込みドメインの現在の状態を別の読み取りドメインモデルに投影するのは便利ではないことに注意してください。 ここで取り上げた例はかなり単純なので、問題は発生しません。

ただし、書き込みモデルと読み取りモデルがより複雑になるにつれて、投影がますます困難になります。 これは、イベントソーシングを使用した状態ベースのプロジェクションではなく、イベントベースのプロジェクションによって対処できます。 これを実現する方法については、チュートリアルの後半で説明します。

4.4. CQRSの利点と欠点

CQRSパターンについて説明し、一般的なアプリケーションに導入する方法を学びました。 私たちは、読み取りと書き込みの両方を処理する際のドメインモデルの剛性に関連する問題に断固として対処しようとしました。

ここで、CQRSがアプリケーションアーキテクチャにもたらすその他の利点のいくつかについて説明しましょう。

  • CQRSは、書き込みおよび読み取り操作に適した個別のドメインモデルを選択するための便利な方法を提供します。 両方をサポートする複雑なドメインモデルを作成する必要はありません
  • 書き込みの高スループットや読み取りの低レイテンシなど、読み取りおよび書き込み操作の複雑さを処理するために個別に適したリポジトリを選択するのに役立ちます
  • 関心の分離とより単純なドメインモデルを提供することにより、分散アーキテクチャでイベントベースのプログラミングモデルを自然に補完します

ただし、これは無料ではありません。 この単純な例から明らかなように、CQRSはアーキテクチャにかなりの複雑さを追加します。 多くのシナリオでは、それは適切ではないか、苦痛に値するものではない可能性があります。

  • 複雑なドメインモデルのみが、このパターンの複雑さの追加からの恩恵を受けることができます。 単純なドメインモデルは、これらすべてなしで管理できます
  • 当然のことながら、はある程度コードの重複につながります。これは、それがもたらす利益と比較して許容できる悪です。 ただし、個別の判断をお勧めします
  • 個別のリポジトリは一貫性の問題を引き起こし、書き込みリポジトリと読み取りリポジトリを常に完全に同期させることは困難です。 結果整合性のために解決しなければならないことがよくあります

5. イベントソーシングの紹介

次に、単純なアプリケーションで説明した2番目の問題について説明します。 思い出すと、それは永続性リポジトリに関連していました。

この問題に対処するために、イベントソーシングを紹介します。 イベントソーシングは、アプリケーション状態ストレージの考え方を劇的に変えます。

それがリポジトリをどのように変更するかを見てみましょう。

ここでは、ドメインイベントの順序付きリストを格納するためにリポジトリを構築しました。 ドメインオブジェクトへのすべての変更はイベントと見なされます。 イベントの粗さまたは細かさは、ドメイン設計の問題です。 ここで考慮すべき重要なことは、イベントには時間的な順序があり、不変であるということです。

5.1. イベントとイベントストアの実装

イベント駆動型アプリケーションの基本的なオブジェクトはイベントであり、イベントソーシングも例外ではありません。 前に見たように、イベントは、特定の時点でのドメインモデルの状態の特定の変化を表します。 したがって、単純なアプリケーションの基本イベントを定義することから始めます。

public abstract class Event {
    public final UUID id = UUID.randomUUID();
    public final Date created = new Date();
}

これにより、アプリケーションで生成するすべてのイベントが一意のIDと作成のタイムスタンプを確実に取得します。 これらはさらに処理するために必要です。

もちろん、イベントの来歴を確立するための属性など、私たちが興味を持つ可能性のある他のいくつかの属性が存在する可能性があります。

次に、この基本イベントを継承するドメイン固有のイベントをいくつか作成しましょう。

public class UserCreatedEvent extends Event {
    private String userId;
    private String firstName;
    private String lastName;
}

public class UserContactAddedEvent extends Event {
    private String contactType;
    private String contactDetails;
}

public class UserContactRemovedEvent extends Event {
    private String contactType;
    private String contactDetails;
}

public class UserAddressAddedEvent extends Event {
    private String city;
    private String state;
    private String postCode;
}

public class UserAddressRemovedEvent extends Event {
    private String city;
    private String state;
    private String postCode;
}

これらは、ドメインイベントの詳細を含むJavaの単純なPOJOです。 ただし、ここで注意すべき重要なことは、イベントの粒度です。

ユーザーの更新用に単一のイベントを作成することもできましたが、代わりに、住所と連絡先の追加と削除のために個別のイベントを作成することにしました。 選択は、ドメインモデルでの作業をより効率的にするものにマッピングされます。

当然のことながら、ドメインイベントを保持するためのリポジトリが必要です。

public class EventStore {
    private Map<String, List<Event>> store = new HashMap<>();
}

これは、ドメインイベントを保持するための単純なメモリ内データ構造です。 実際には、 ApacheDruidのようなイベントデータを処理するために特別に作成されたソリューションがいくつかあります。 KafkaCassandraなど、イベントソーシングを処理できる汎用分散データストアは多数あります。

5.2. イベントの生成と消費

そのため、すべてのCRUD操作を処理するサービスが変更されます。 これで、移動するドメインの状態を更新する代わりに、ドメインイベントを追加します。 また、同じドメインイベントを使用してクエリに応答します。

これをどのように達成できるか見てみましょう。

public class UserService {
    private EventStore repository;
    public UserService(EventStore repository) {
        this.repository = repository;
    }

    public void createUser(String userId, String firstName, String lastName) {
        repository.addEvent(userId, new UserCreatedEvent(userId, firstName, lastName));
    }

    public void updateUser(String userId, Set<Contact> contacts, Set<Address> addresses) {
        User user = UserUtility.recreateUserState(repository, userId);
        user.getContacts().stream()
          .filter(c -> !contacts.contains(c))
          .forEach(c -> repository.addEvent(
            userId, new UserContactRemovedEvent(c.getType(), c.getDetail())));
        contacts.stream()
          .filter(c -> !user.getContacts().contains(c))
          .forEach(c -> repository.addEvent(
            userId, new UserContactAddedEvent(c.getType(), c.getDetail())));
        user.getAddresses().stream()
          .filter(a -> !addresses.contains(a))
          .forEach(a -> repository.addEvent(
            userId, new UserAddressRemovedEvent(a.getCity(), a.getState(), a.getPostcode())));
        addresses.stream()
          .filter(a -> !user.getAddresses().contains(a))
          .forEach(a -> repository.addEvent(
            userId, new UserAddressAddedEvent(a.getCity(), a.getState(), a.getPostcode())));
    }

    public Set<Contact> getContactByType(String userId, String contactType) {
        User user = UserUtility.recreateUserState(repository, userId);
        return user.getContacts().stream()
          .filter(c -> c.getType().equals(contactType))
          .collect(Collectors.toSet());
    }

    public Set<Address> getAddressByRegion(String userId, String state) throws Exception {
        User user = UserUtility.recreateUserState(repository, userId);
        return user.getAddresses().stream()
          .filter(a -> a.getState().equals(state))
          .collect(Collectors.toSet());
    }
}

ここでは、ユーザーの更新操作の処理の一部として、いくつかのイベントを生成していることに注意してください。 また、これまでに生成されたすべてのドメインイベントを再生することにより、ドメインモデルの現在の状態を生成していることに注目するのも興味深いことです。

もちろん、実際のアプリケーションでは、これは実行可能な戦略ではありません。毎回状態が生成されないように、ローカルキャッシュを維持する必要があります。 プロセスをスピードアップできるイベントリポジトリのスナップショットやロールアップのような他の戦略があります。

これで、単純なアプリケーションにイベントソーシングを導入するための取り組みは終わりです。

5.3. イベントソーシングのメリットとデメリット

これで、イベントソーシングを使用してドメインオブジェクトを保存する別の方法を採用することに成功しました。 イベントソーシングは強力なパターンであり、適切に使用すると、アプリケーションアーキテクチャに多くのメリットがもたらされます。

  • 読み取り、更新、書き込みが不要なため、書き込み操作がはるかに高速になります。 書き込みは、単にイベントをログに追加するだけです
  • オブジェクト関係インピーダンスを削除します。したがって、複雑なマッピングツールが必要になります。 もちろん、オブジェクトを再作成する必要があります
  • は、完全に信頼できる副産物として監査ログを提供します。 ドメインモデルの状態がどのように変化したかを正確にデバッグできます
  • 一時的なクエリをサポートし、タイムトラベル(過去のある時点でのドメイン状態)を達成することが可能になります!
  • メッセージを交換することによって非同期的に通信するマイクロサービスアーキテクチャで、緩く結合されたコンポーネントを設計するのに自然な適合です

ただし、いつものように、イベントソーシングでさえ特効薬ではありません。 それは私たちにデータを保存するために劇的に異なる方法を採用することを強制します。 これは、いくつかのケースでは役に立たない場合があります。

  • 学習曲線が関連付けられており、イベントソーシングを採用するには必要な考え方の変化があります。 そもそも直感的ではありません
  • ローカルキャッシュに状態を保持しない限り、状態を再作成する必要があるため、通常のクエリの処理がかなり困難になります。
  • どのドメインモデルにも適用できますが、イベント駆動型アーキテクチャのイベントベースモデルにはより適切です。

6. イベントソーシングを使用したCQRS

イベントソーシングとCQRSを単純なアプリケーションに個別に導入する方法を確認したので、次はそれらをまとめます。 これらのパターンが互いに大きな利益を得ることができるようになったので、かなり直感的であるはずです。 ただし、このセクションでは、より明確にします。

まず、アプリケーションアーキテクチャがそれらをどのように統合するかを見てみましょう。

これは今のところ驚くべきことではありません。 リポジトリの書き込み側をイベントストアに置き換えましたが、リポジトリの読み取り側は引き続き同じです。

アプリケーションアーキテクチャでイベントソーシングとCQRSを使用する方法はこれだけではないことに注意してください。 私たちは非常に革新的であり、これらのパターンを他のパターンと一緒に使用して、いくつかのアーキテクチャオプションを考え出すことができます。

ここで重要なのは、単に複雑さをさらに増やすのではなく、それらを使用して複雑さを管理することです。

6.1. CQRSとイベントソーシングを統合する

イベントソーシングとCQRSを個別に実装したので、それらをどのように組み合わせることができるかを理解するのはそれほど難しいことではありません。

CQRSを導入したアプリケーションから開始し、関連する変更を行って、イベントソーシングを実行します。 また、イベントソーシングを導入したアプリケーションで定義したものと同じイベントとイベントストアを活用します。

いくつかの変更があります。 状態を更新する代わりに、集約を生成イベントに変更することから始めます。

public class UserAggregate {
    private EventStore writeRepository;
    public UserAggregate(EventStore repository) {
        this.writeRepository = repository;
    }

    public List<Event> handleCreateUserCommand(CreateUserCommand command) {
        UserCreatedEvent event = new UserCreatedEvent(command.getUserId(), 
          command.getFirstName(), command.getLastName());
        writeRepository.addEvent(command.getUserId(), event);
        return Arrays.asList(event);
    }

    public List<Event> handleUpdateUserCommand(UpdateUserCommand command) {
        User user = UserUtility.recreateUserState(writeRepository, command.getUserId());
        List<Event> events = new ArrayList<>();

        List<Contact> contactsToRemove = user.getContacts().stream()
          .filter(c -> !command.getContacts().contains(c))
          .collect(Collectors.toList());
        for (Contact contact : contactsToRemove) {
            UserContactRemovedEvent contactRemovedEvent = new UserContactRemovedEvent(contact.getType(), 
              contact.getDetail());
            events.add(contactRemovedEvent);
            writeRepository.addEvent(command.getUserId(), contactRemovedEvent);
        }
        List<Contact> contactsToAdd = command.getContacts().stream()
          .filter(c -> !user.getContacts().contains(c))
          .collect(Collectors.toList());
        for (Contact contact : contactsToAdd) {
            UserContactAddedEvent contactAddedEvent = new UserContactAddedEvent(contact.getType(), 
              contact.getDetail());
            events.add(contactAddedEvent);
            writeRepository.addEvent(command.getUserId(), contactAddedEvent);
        }

        // similarly process addressesToRemove
        // similarly process addressesToAdd

        return events;
    }
}

必要な他の唯一の変更はプロジェクターであり、ドメインオブジェクトの状態の代わりにイベントを処理する必要があります。

public class UserProjector {
    UserReadRepository readRepository = new UserReadRepository();
    public UserProjector(UserReadRepository readRepository) {
        this.readRepository = readRepository;
    }

    public void project(String userId, List<Event> events) {
        for (Event event : events) {
            if (event instanceof UserAddressAddedEvent)
                apply(userId, (UserAddressAddedEvent) event);
            if (event instanceof UserAddressRemovedEvent)
                apply(userId, (UserAddressRemovedEvent) event);
            if (event instanceof UserContactAddedEvent)
                apply(userId, (UserContactAddedEvent) event);
            if (event instanceof UserContactRemovedEvent)
                apply(userId, (UserContactRemovedEvent) event);
        }
    }

    public void apply(String userId, UserAddressAddedEvent event) {
        Address address = new Address(
          event.getCity(), event.getState(), event.getPostCode());
        UserAddress userAddress = Optional.ofNullable(
          readRepository.getUserAddress(userId))
            .orElse(new UserAddress());
        Set<Address> addresses = Optional.ofNullable(userAddress.getAddressByRegion()
          .get(address.getState()))
          .orElse(new HashSet<>());
        addresses.add(address);
        userAddress.getAddressByRegion()
          .put(address.getState(), addresses);
        readRepository.addUserAddress(userId, userAddress);
    }

    public void apply(String userId, UserAddressRemovedEvent event) {
        Address address = new Address(
          event.getCity(), event.getState(), event.getPostCode());
        UserAddress userAddress = readRepository.getUserAddress(userId);
        if (userAddress != null) {
            Set<Address> addresses = userAddress.getAddressByRegion()
              .get(address.getState());
            if (addresses != null)
                addresses.remove(address);
            readRepository.addUserAddress(userId, userAddress);
        }
    }

    public void apply(String userId, UserContactAddedEvent event) {
        // Similarly handle UserContactAddedEvent event
    }

    public void apply(String userId, UserContactRemovedEvent event) {
        // Similarly handle UserContactRemovedEvent event
    }
}

状態ベースの投影を処理しているときに説明した問題を思い出すと、これはその解決策になる可能性があります。

イベントベースのプロジェクションはかなり便利で実装が簡単です。 発生するすべてのドメインイベントを処理し、それらをすべての読み取りドメインモデルに適用するだけです。 通常、イベントベースのアプリケーションでは、プロジェクターは関心のあるドメインイベントをリッスンし、誰かが直接呼び出すことに依存しません。

これは、イベントソーシングとCQRSを単純なアプリケーションにまとめるために必要なことのほとんどすべてです。

7. 結論

このチュートリアルでは、イベントソーシングとCQRSデザインパターンの基本について説明しました。 簡単なアプリケーションを開発し、これらのパターンを個別に適用しました。

その過程で、私たちはそれらがもたらす利点とそれらがもたらす欠点を理解しました。 最後に、これらのパターンの両方をアプリケーションに組み込む理由と方法を理解しました。

このチュートリアルで説明した単純なアプリケーションは、CQRSとイベントソーシングの必要性を正当化することすらできません。 私たちの焦点は基本的な概念を理解することでした。したがって、例は簡単でした。 ただし、前述のように、これらのパターンの利点は、かなり複雑なドメインモデルを持つアプリケーションでのみ実現できます。

いつものように、この記事のソースコードはGitHubにあります。