Question

I'm building a library application. Let's assume that we have a requirement to let registered people in the library to borrow a book for some default period of time (4 weeks).

I started to model my domain with an AggregateRoot called Loan with code below:

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));
    }
}

And my Book entity looks like this:

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));
    }
}

As you can see I'm using a static factory method for creating my Loan aggregate root where I'm passing an identity of the borrowing book and the user identity who is going to borrow it. Should I pass here the references to these objects (book and user) instead of ids? Which approach is better? As you can see my Book entity has also a property which indicates the availability of a book (InStock property). Should I update this property in the next use-case, for example in the handler of LoadCreatedEvent? Or should it be updated here within my AggregateRoot? If it should be updated here inside my aggregate I should pass the entire book reference instead of just an ID to be able to call it's method _book.Borrow(). I'm stucked at this point because I would like to do it pretty correct with the DDD approach. Or am I starting to do it from the wrong side and I'm missing something or thinking in a wrong way of it?

Was it helpful?

Solution

Building scalable value objects, entities and aggregate roots in regard of domain driven design has been on the table for probably as long as the DDD concept itself. The common approach to DDD modeling is: "The models contain business logic, therefore they must be heavy, and since they're mostly used on the write side, this is OK." However not having a scalable domain models is likely to hurt you in the long run.

Domain models should encapsulate behaviour. They're an evolution of modeling of business rules, by putting the the logic into models themselves rather than having them in services backed up anemic models. It's exactly the behaviour, which is the heavy stuff in domain models, but that does not necessarily mean the data stored within the models should be heavy as well.

A proven pattern which works really great for me is:

  • on construction pass all necessary objects to your aggregate,
  • internally store only identifier as references to used objects,
  • on method representing changes to the aggregate root, pass services as arguments to the method which are necessary for invocation of the method.

In your case the loan aggregate would be kind of inside-out from what you're currently having, it would change to the following:

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));
    }
}

this way when constructing the model from within your domain layer, the model asks for all necessary dependencies in order to fulfil its contract, while at the same time the model is very easy to construct from the database, because all that's necessary are identifiers and potentially heavy objects are loaded only when really necessary (this also reduces data footprint between the application server and database server, which is more often than not the most costly operation in applications).

However, there's quite a bit issue. The current representation of the Loan model does not protect you against race conditions. The model does not introduce any guarantee that when calling book.Borrows() in the Loan's constructor, no other thread currently executes the same call on the same book. The race condition in this case would happen like this:

  • There are two requests to create loan a book with the Id=1, perhaps even by the same user, caused by double-clicking a button on a website.
  • Thread 1 loads Book:Id=1 with InStock=true.
  • Before Thread 1 finishes, Thread 2 also loads Book:Id=1 with InStock=true.
  • Both Thread 1 and 2 successfully create their Loan objects, calling book.Borrow() on their representation of Book object which passes.
  • In a naive implementation, you persist both Loan objects and you have successfully created a duplicated loan for a book which was available only once.

The obvious solution to your problem would be to add locking. So before loading a Book object, a thread-safe lock is acquired on the book identifier, locking the critical section for other threads. The process would then look like this:

  • There are once again two requests to create loan a book with the Id=1.
  • Thread 1 locks on bookId=1 and loads Book:Id=1 with InStock=true.
  • Before Thread 1 finishes, Thread 2 tries to acquire a lock on bookId=1 which puts the thread in a suspended state, because the section is currently locked.
  • Thread 1 successfully creates its Loan object, calling book.Borrow() on its representation of Book object which passes.
  • Thread 1 persists the Loan, modifying the Book in the same database transaction to store the InStock=false attribute, and releases the lock.
  • Since lock is release, Thread 2 now enters the critical section and loads the Book:Id=1 with InStock now set to false.
  • The same thread, Thread 2, tries to create a Loan, which now fails on the call to the book.Borrow() method.
  • The result is, only a single loan is created.

While this looks promising, locks are generally a problem, they do slow down system's operations, introduce unnecessary load by blocking threads, and when not correctly implemented are not very user-friendly. In this case it's not such a big deal, since we're locking only on a single entity, but going this road with a more complicated aggregates roots containing several referenced entities could put a severe performance issue on your system and potentially lead to deadlocks.

A possible solution without locking would be to introduce optimistic locking. The locks are then not necessary, and correct usage of objects is handled upon persistence. The process then looks like this:

  • There are once again two requests to create loan a book with the Id=1.
  • Thread 1 loads Book:Id=1 with InStock=true and Version=1.
  • Before Thread 1 finishes, Thread 2 also loads Book:Id=1 with InStock=true and Version=1.
  • Both Thread 1 and 2 successfully create their Loan objects, calling book.Borrow() on their representation of Book object which passes.
  • Thread 1 also persists the changed Book object, by setting the InStock=false, utilising a WHERE condition on the SQL level: UPDATE book SET in_stock = book.inStock, version = version + 1 WHERE id = book.id AND version = book.version. This successfully returns 1 updated row, and the Thread 1 transaction is commited.
  • The Thread 2 tries to perform the same database update: UPDATE book SET in_stock = book.inStock, version = version + 1 WHERE id = book.id AND version = book.version, which now returns 0 updated rows, since the book.version of the Thread 2 is 1, but in the database the version of the book is now 2, because it was changed by the Thread 1. The Thread 2 execution fails and is rolled back due to concurrency hit.

Unfortunately, both solutions rely on the fact that a programmer realises, the Book object must be persisted too (although we're technically working only with a Loan object during said system change). This makes the operation less clear, persisting the book object could be easily forgotten which could lead to some other issues.

Fortunately, there might be a third solution you haven't though of. You are already thinking about events but so far we haven't rally used those. But events are a great way how to propagate system changes to other parts of a code. Up until now, we've been limited by looking at the loan as an object. But shouldn't booking a book be really a process, which leads to loan creation? Perhaps the booking process should be modelled in the same way?

In a happy scenario, a loaning process, BookLoanProcess could be modelled using the following domain events:

  • BookLoanRequestedEvent,
  • BookBorrowedEvent,
  • BookLoanRequestAcceptedEvent.

Based on the decision from your business analytics, you could then either create a Loan in a pending state at the beginning, only completing it once BookLoanRequestAcceptedEvent is published to your system, or you could even create a separate class acting as a process/saga and actually creating the Loan object only after BookLoanRequestAcceptedEvent happens in your system.

This effectively splits the responsibilities of each module. When BookLoanRequestedEvent happens, the Book module listens for the given event and tries to Borrow a book with the Id=BookLoanRequestedEvent.BookId in a thread-safe way. If this operation succeeds, the BookBorrowedEvent is published, to which the BookLoanProcess module reacts, by:

  • find me an active book booking process for the book with the id BookBorrowedEvent.BookId,
  • on the found process, invoke a AcceptLoanRequest method, which published the BookLoanRequestAcceptedEvent.

Now the Loan module listens to the BookLoanRequestAcceptedEvent and in a thread-safe way loads the BookLoanProcess. As a reaction, it then creates a Loan object taking necessary data from the BookLoanProcess object.

Modeling loaning a book using such process might have other advantages to your business developers, that is introducing the ability to gracefully roll back the loaning process while still keeping information about all the steps during the loaning process, as well as introducing the possibility to only allow certain actions on the loan during the ongoing process but not allowing to modify the loan after its creation, effectively treating as immutable.


In the end, domain driven design is about modeling in a unified language which not only you as a programmed but also the stakeholders know. And as such, the code should represent company's processes. If the BookLoanProcess process does not make sense in your company, do not model it, as it would only introduce discrepancies between the code and business analysts.

OTHER TIPS

Before we specifically address the examples you have provided we need to remind ourselves the purpose of DDD: To provide a useful abstraction of the behavioral requirements of a system.

The first sentence of your post outlines a very clear use-case (complete with highlighting components of your ubiquitous language). Then it starts to fall apart! What is this Loan thing you are talking about? That wasn't part of the use-case. Let's see if we can't derive something a little closer to your intention.

Starting with your rules. As far as I can tell, you only have two invariants in this system:

  • A book must be "in stock" (not already borrowed) in order to be borrowed again.
  • A book must be "out of stock" (already borrowed) in order to be returned.

Given the above let us write out what a command handler for each use-case might look like:


// 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

And:


// 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

The first thing you are going to notice here is that there is no Book entity involved! This makes sense right? What the heck does an author have to do with borrowing a book? Instead we have introduced a new concept, BorrowingCard, that is used to manage this process (maybe you are old enough to remember those cards inserted into a little pocket on the front or back cover of library books).

Borrowing a book is simple: We find and verify that we have a registered user. Then we check our data store to return the first available BorrowingCard given an ISNB (a user doesn't necessarily care which book they check out - though a system could use BookId if this process is happening in-person). Next we have our RegisteredUser generate a new BorrowingEntry recording the necessary information (do not treat time implicitly!). Lastly, we record our new BorrowingEntry to persistent storage.

Returning a book is even simpler: We look up the BorrowingEntry associated with the book being returned, mark it as returned, and save (notice a RegisteredUser does not need to facilitate a return).

A key insight here is that "borrowing a book" and "returning a book" are use-cases from the perspective of your application that require your domain to implement the details.

I believe the above should suffice without fully illustrating the mechanics of any individual entity. I'll let you fill in any gaps.

Licensed under: CC-BY-SA with attribution
scroll top