DDD, Aggregate Root and entities in library application scenario
https://softwareengineering.stackexchange.com/questions/408389
-
09-03-2021 - |
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?
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
withInStock=true
. - Before Thread 1 finishes, Thread 2 also loads
Book:Id=1
withInStock=true
. - Both Thread 1 and 2 successfully create their
Loan
objects, callingbook.Borrow()
on their representation ofBook
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 loadsBook:Id=1
withInStock=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, callingbook.Borrow()
on its representation ofBook
object which passes. - Thread 1 persists the
Loan
, modifying theBook
in the same database transaction to store theInStock=false
attribute, and releases the lock. - Since lock is release, Thread 2 now enters the critical section and loads the
Book:Id=1
withInStock
now set tofalse
. - The same thread, Thread 2, tries to create a
Loan
, which now fails on the call to thebook.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
withInStock=true
andVersion=1
. - Before Thread 1 finishes, Thread 2 also loads
Book:Id=1
withInStock=true
andVersion=1
. - Both Thread 1 and 2 successfully create their
Loan
objects, callingbook.Borrow()
on their representation ofBook
object which passes. - Thread 1 also persists the changed
Book
object, by setting theInStock=false
, utilising aWHERE
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 thebook.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 theBookLoanRequestAcceptedEvent
.
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.