web-dev-qa-db-ja.com

ライブラリアプリケーションシナリオでのDDD、ルートの集計とエンティティ

ライブラリアプリケーションを構築しています。 図書館に登録されている人から借りるをデフォルトの期間(4週間)許可する必要があるとします。

以下のコードでLoanというAggregateRootを使用してドメインのモデル化を開始しました。

_public class Loan : AggregateRoot<long>
{
    public static int DefaultLoanPeriodInDays = 30;

    private readonly long _bookId;
    private readonly long _userId;
    private readonly DateTime _endDate;
    private bool _active;
    private Book _book;
    private RegisteredLibraryUser _user;

    public Book Book => _book;
    public RegisteredLibraryUser User => _user;
    public DateTime EndDate => _endDate;
    public bool Active => _active;

    private Loan(long bookId, long userId, DateTime endDate)
    {
        _bookId = bookId;
        _userId = userId;
        _endDate = endDate;
        _active = true;
    }

    public static Loan Create(long bookId, long userId)
    {
        var endDate = DateTime.UtcNow.AddDays(DefaultLoanPeriodInDays);
        var loan = new Loan(bookId, userId, endDate);

        loan.Book.Borrow();

        loan.AddDomainEvent(new LoanCreatedEvent(bookId, userId, endDate));

        return loan;
    }

    public void EndLoan()
    {
        if (!Active)
            throw new LoanNotActiveException(Id);

        _active = false;
        _book.Return();

        AddDomainEvent(new LoanFinishedEvent(Id));
    }
}
_

そして、私のBookエンティティは次のようになります。

_public class Book : Entity<long>
{
    private BookInformation _bookInformation;
    private bool _inStock;

    public BookInformation BookInformation => _bookInformation;
    public bool InStock => _inStock;

    private Book(BookInformation bookInformation)
    {
        _bookInformation = bookInformation;
        _inStock = true;
    }

    public static Book Create(string title, string author, string subject, string isbn)
    {
        var bookInformation = new BookInformation(title, author, subject, isbn);
        var book = new Book(bookInformation);

        book.AddDomainEvent(new BookCreatedEvent(bookInformation));

        return book;
    }

    public void Borrow()
    {
        if (!InStock)
            throw new BookAlreadyBorrowedException();

        _inStock = false;

        AddDomainEvent(new BookBorrowedEvent(Id));
    }

    public void Return()
    {
        if (InStock)
            throw new BookNotBorrowedException(Id);

        _inStock = true;

        AddDomainEvent(new BookReturnedBackEvent(Id, DateTime.UtcNow));
    }
}
_

ご覧のとおり、ローンの集計ルートを作成するために、静的なファクトリメソッドを使用して、借りている本のIDと借りているユーザーのIDを渡します。ここでは、IDではなくこれらのオブジェクト(ブックとユーザー)への参照を渡す必要がありますか?どちらのアプローチが優れていますか?ご覧のとおり、私のBookエンティティには、書籍の可用性を示すプロパティ(InStockプロパティ)もあります。次のユースケースで、たとえばLoadCreatedEventのハンドラーで、このプロパティを更新する必要がありますか?または、ここのAggregateRoot内で更新する必要がありますか?ここで更新する必要がある場合は、IDだけでなく、ブック参照全体を渡して、メソッド_book.Borrow()を呼び出せるようにする必要があります。 DDDアプローチでかなり正確に実行したいので、この時点で行き詰まっています。それとも私は間違った側からそれを始めて、私は何かを逃したり、間違った方法で考えたりしていますか?

3
XardasLord

ドメイン主導の設計に関するスケーラブルな値オブジェクト、エンティティ、および集計ルートの構築は、おそらくDDDの概念自体と同じくらい長い間、検討されてきました。 DDDモデリングへの一般的なアプローチは次のとおりです。「モデルにはビジネスロジックが含まれているため、それらは重いものである必要があり、主に書き込み側で使用されるため、これで問題ありません。」ただし、スケーラブルなドメインモデルがないと、長期的にはあなたを傷つける可能性があります。

ドメインモデルは動作をカプセル化する必要があります。これらはビジネスルールのモデリングを進化させたものであり、ロジックをサービスにバックアップするのではなく、それ自体をモデルに組み込む 貧血モデル です。これはまさにドメインモデルの負荷が大きい動作ですが、必ずしもモデル内に格納されているデータも重いことを意味するわけではありません。

私にとって本当に効果的な実績のあるパターンは次のとおりです。

  • 構築時に、必要なすべてのオブジェクトを集約に渡します。
  • 使用されたオブジェクトへの参照として識別子のみを内部的に保存し、
  • 集約ルートへの変更を表すメソッドで、メソッドの呼び出しに必要なサービスをメソッドの引数として渡します。

あなたの場合、ローンの合計は現在持っているものとは裏返しになり、次のようになります。

_public class Loan : AggregateRoot<long>
{
    public static int DefaultLoanPeriodInDays = 30;

    private readonly long _bookId;
    private readonly long _userId;
    private readonly DateTime _endDate;
    private bool _active;

    private Loan(long bookId, long userId, DateTime endDate, bool active)
    {
        _bookId = bookId;
        _userId = userId;
        _endDate = endDate;
        _active = active;
    }

    public static Loan Create(Book book, RegisteredLibraryUser user)
    {
        book.Borrow();

        var endDate = DateTime.UtcNow.AddDays(DefaultLoanPeriodInDays);
        var loan = new Loan(book.Id, user.Id, endDate, true);

        loan.AddDomainEvent(new LoanCreatedEvent(loan._bookId, loan._userId, endDate));

        return loan;
    }

    public void EndLoan(BookLookUpService bookLookUpService)
    {
        if (!Active)
            throw new LoanNotActiveException(Id);

        _active = false;

        bookLookUpService.getById(_bookId).Return();

        AddDomainEvent(new LoanFinishedEvent(Id));
    }
}
_

このように、ドメインレイヤー内からモデルを構築する場合、モデルはその契約を満たすために必要なすべての依存関係を要求します。同時に、モデルはデータベースから構築するのが非常に簡単です。重いオブジェクトは、本当に必要な場合にのみ読み込まれます(これにより、アプリケーションサーバーとデータベースサーバー間のデータフットプリントも削減されます。これは、アプリケーションで最もコストのかかる操作であることが多いです)。

ただし、かなりの問題があります。 Loanモデルの現在の表現では、競合状態から保護されません。このモデルでは、Loanのコンストラクターでbook.Borrows()を呼び出すときに、他のスレッドが同じブックで同じ呼び出しを現在実行していないという保証はありません。この場合の競合状態は次のようになります。

  • Webサイトのボタンをダブルクリックしたことが原因で、おそらく同じユーザーであっても、_Id=1_を使用して本のローンを作成する2つの要求があります。
  • スレッド1は_Book:Id=1_とともに_InStock=true_をロードします。
  • スレッド1が完了する前に、スレッド2は_Book:Id=1_とともに_InStock=true_もロードします。
  • スレッド1と2はどちらも正常にLoanオブジェクトを作成し、渡されたBookオブジェクトの表現でbook.Borrow()を呼び出します。
  • 単純な実装では、両方のLoanオブジェクトを永続化し、1度しか利用できなかった本の複製ローンを正常に作成しました。

問題の明らかな解決策は、lockingを追加することです。したがって、Bookオブジェクトをロードする前に、本の識別子に対してスレッドセーフロックが取得され、他のスレッドのクリティカルセクションがロックされます。プロセスは次のようになります。

  • もう一度、_Id=1_を使用して本のローンを作成する2つのリクエストがあります。
  • スレッド1は_bookId=1_をロックし、_Book:Id=1_を_InStock=true_とともにロードします。
  • スレッド1が完了する前に、セクションが現在ロックされているため、スレッド2は_bookId=1_のロックを取得しようとします。これにより、スレッドが一時停止状態になります。
  • スレッド1は正常にLoanオブジェクトを作成し、渡されたBookオブジェクトの表現に対してbook.Borrow()を呼び出します。
  • スレッド1はLoanを永続化し、同じデータベーストランザクションでBookを変更して_InStock=false_属性を格納し、ロックを解放します。
  • ロックが解放されたため、スレッド2はクリティカルセクションに入り、_Book:Id=1_をロードします。InStockfalseに設定されています。
  • 同じスレッドのスレッド2がLoanを作成しようとしますが、book.Borrow()メソッドの呼び出しで失敗します。
  • その結果、ローンは1つだけ作成されます。

これは有望に見えますが、ロックは一般的に問題です。ロックはシステムの動作を遅くし、スレッドをブロックすることで不要な負荷を引き起こします。正しく実装されていない場合、ユーザーフレンドリーではありません。この場合、単一のエンティティでのみロックしているため、それほど大したことではありませんが、参照された複数のエンティティを含むより複雑な集約ルートでこの道を進むと、システムに重大なパフォーマンスの問題が発生し、デッドロックにつながる可能性があります。

ロックなしの可能な解決策は、楽観的ロックを導入することです。この場合、ロックは不要であり、オブジェクトの正しい使用は永続化の際に処理されます。 processは次のようになります。

  • もう一度、_Id=1_を使用して本のローンを作成する2つのリクエストがあります。
  • スレッド1は_Book:Id=1_を_InStock=true_および_Version=1_とともにロードします。
  • スレッド1が完了する前に、スレッド2は_Book:Id=1_と_InStock=true_および_Version=1_もロードします。
  • スレッド1と2はどちらも正常にLoanオブジェクトを作成し、渡されたBookオブジェクトの表現でbook.Borrow()を呼び出します。
  • スレッド1は、SQLレベルのBook条件を利用して_InStock=false_を設定することにより、変更されたWHEREオブジェクトも永続化します:_UPDATE book SET in_stock = book.inStock, version = version + 1 WHERE id = book.id AND version = book.version_。これにより、更新された1行が正常に返され、スレッド1トランザクションがコミットされます。
  • スレッド2は同じデータベース更新を実行しようとします:スレッド2の_UPDATE book SET in_stock = book.inStock, version = version + 1 WHERE id = book.id AND version = book.version_が1であるため、現在は更新された行0を返す_book.version_ですが、データベースでは本のバージョンは2です、スレッド1によって変更されたため。スレッド2の実行は失敗し、同時実行ヒットによりロールバックされます。

残念ながら、どちらのソリューションも、プログラマーが気付くという事実に依存していますが、Bookオブジェクトも永続化する必要があります(ただし、技術的には、上記のシステム変更時にLoanオブジェクトのみを使用しています)。これにより、操作が不明確になり、ブックオブジェクトの永続化が簡単に忘れられ、他の問題が発生する可能性があります。

幸いにも、まだ考えていない3番目の解決策があるかもしれません。あなたはすでにイベントについて考えていますが、これまでのところ、それらを再利用することはしていません。ただし、イベントは、システムの変更をコードの他の部分に伝達するための優れた方法です。これまでは、ローンを対象とすることで制限されていました。しかし、本を予約することは、ローンの作成につながる実際のプロセスではありませんか?おそらく、予約プロセスは同じ方法でモデル化する必要がありますか?

幸いなシナリオでは、融資プロセスであるBookLoanProcessは、次のドメインイベントを使用してモデル化できます。

  • BookLoanRequestedEvent
  • BookBorrowedEvent
  • BookLoanRequestAcceptedEvent

ビジネス分析の決定に基づいて、最初にLoanを保留状態で作成し、BookLoanRequestAcceptedEventがシステムに公開された後にのみ完了するか、プロセス/サーガとして機能する別のクラスを作成して、実際にLoanオブジェクトを作成するのは、システムでBookLoanRequestAcceptedEventが発生した後でのみです。

これにより、各モジュールの責任が効果的に分割されます。 BookLoanRequestedEventが発生すると、Bookモジュールは指定されたイベントをリッスンし、_Id=BookLoanRequestedEvent.BookId_を含む本をスレッドセーフな方法でBorrowに書き込もうとします。この操作が成功すると、BookBorrowedEventモジュールがパブリッシュされ、BookLoanProcessモジュールが反応します。

  • iD _BookBorrowedEvent.BookId_の本のアクティブな本予約プロセスを見つけてください。
  • 見つかったプロセスで、AcceptLoanRequestを発行したBookLoanRequestAcceptedEventメソッドを呼び出します。

これで、LoanモジュールはBookLoanRequestAcceptedEventをリ​​ッスンし、スレッドセーフな方法でBookLoanProcessをロードします。反応として、次に、Loanオブジェクトから必要なデータを取得するBookLoanProcessオブジェクトを作成します。

このようなプロセスを使用して本の貸出をモデル化すると、ビジネス開発者にとって他の利点が得られる可能性があります。つまり、貸出プロセス中のすべてのステップに関する情報を保持しながら、貸出プロセスを適切にロールバックする機能を導入し、許可のみを行う可能性を導入します進行中のプロセス中のローンに対する特定のアクション。ただし、ローンの作成後にローンを変更することはできず、事実上不変として扱われます。


結局のところ、ドメイン主導の設計は、プログラム化されたあなただけでなく、利害関係者も知っている統一言語でのモデリングに関するものです。そのため、コードは会社のプロセスを表す必要があります。 BookLoanProcessプロセスが会社で意味をなさない場合は、モデルとしないでください。コードとビジネスアナリストの間に不一致が生じるだけです。

2
Andy

提供した例を具体的に説明する前に、DDDの目的を思い出す必要があります。システムの動作要件の有用な抽象化を提供するため。

投稿の最初の文は、非常に明確なユースケースの概要を示しています(ユビキタス言語のコンポーネントを強調表示して完了しています)。その後、バラバラになり始めます!あなたが話しているLoanのことは何ですか?それはユースケースの一部ではありませんでした。私たちがあなたの意図に少し近いものを導き出すことができないかどうか見てみましょう。

ルールから始めましょう。私の知る限り、このシステムには2つの不変式しかありません。

  • 書籍を再度借りるには、「在庫がある」(まだ借りていない)必要があります。
  • 書籍を返品するには、「在庫切れ」(すでに借りている)である必要があります。

上記を踏まえて、各ユースケースのコマンドハンドラーがどのようになるかを書きましょう。


// BorrowBookHandler

var registeredUser = users.Find(cmd.UserId);

var borrowingCard = catalogue.FindAvailable(cmd.Isbn); // may throw "Book is not available"

var entry = registeredUser.FillOutCard(borrowingCard, cmd.FromDate, cmd.ToDate); // may throw "Book is reserved during date range"

catalogue.RecordEntry(entry); // save changes

そして:


// ReturnBookHandler

var entry = catalogue.LookUpEntry(cmd.BookId); // may throw "Card entry not found"

entry.MarkReturned(cmd.ReturnDate); // may throw "Book already returned"

catalogue.RecordEntry(entry); // save changes

ここで最初に気づくのは、Bookエンティティが含まれていないことです。これは理にかなっていますか?著者が本を借りることと一体何をしているのですか?代わりに、このプロセスを管理するために使用される新しい概念BorrowingCardを導入しました(おそらく、図書館の本の表紙または裏表紙にある小さなポケットに挿入されたカードを覚えるのに十分古いものです)。

本を借りるのは簡単です。登録ユーザーがいることを確認します。次に、データストアをチェックして、ISNBを指定すると、最初に利用可能なBorrowingCardを返します(ユーザーは必ずしもチェックしませんどの本をチェックします) out-ただし、このプロセスが直接行われている場合、システムはBookIdを使用できます)。次に、RegisteredUserに新しいBorrowingEntryを生成させ、必要な情報を記録します(暗黙的に時間を扱わないでください!)。最後に、新しいBorrowingEntryを永続ストレージに記録します。

本を返すのはさらに簡単です。返される本に関連付けられているBorrowingEntryを調べ、返されたものとしてマークを付け、保存します(RegisteredUserを実行します) not返品を容易にする必要があります)。

ここでの重要な洞察は、「本を借りる」と「本を返す」はユースケースであり、アプリケーションの観点からは、ドメインにdetailsを実装します。

上記は、個々のエンティティのメカニズムを完全に説明することなく十分であると私は信じています。隙間を埋めさせていただきます。

1
king-side-slide