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.