Domain-Driven Design (DDD) is a software development approach first described by Eric Evans. In my previous post, I described that the idea of Event Sourcing, CQRS, and DDD complement each other very well. Feel free to check out my previous post for more information.
Today, I would like to focus on combining Event Sourcing with DDD. Additionally, I want to illustrate that the “domain model” approach fits well with the clean architecture style described by Robert C. Martin. Depending on the source of reference, the clean architecture style is also known as onion architecture or hexagonal architecture. Although those notions might differ in detail, all of them focus on the separation of concerns by organizing software code into multiple layers, with the domain layer taking the center place.
DDD does not require Event Sourcing. If we, however, decide to use Event Sourcing for our domain model, we refer to our domain model as an event-sourced domain model. This definition, which I first read about in Learning Domain-Driven Design (Vlad Khononov), emphasizes that the domain model is stored as a sequence of events rather than its current state.
The example scenario
Domain-Driven Design is most suited for complex domains. In our case, we use a (rather simple) library domain for lending and returning books. A reader can borrow a book, return a book, reserve a book, clear a reservation, etc.
In a real-life application, there are many subdomains and bounded contexts to consider, for example, lending, shipping, catalog, charging, reader management, and the like. Today, we just focus on the lending subdomain.
The lending subdomain knows about books and readers. In addition, a book is associated with its loans. Both book and reader represent aggregates, while the concept of a loan is part of a book. It is to be noted that there are several options to build such a domain model, with each approach coming with a different set of trade-offs. Ultimately, the most appropriate solution strongly depends on the concrete business environment.
Fundamentals
Alright! Let’s jump into some source code. The following code snippets are written using the Kotlin programming language, nevertheless, the source code is rather straightforward and should look similar to many other higher-level programming languages. The full source code is available here.
The first thing to define is the concept of an aggregate. An aggregate can be clearly identified by its identifier (ID). A possible contract of an aggregate might look as follows:
interface Aggregate<ID> {
fun getId(): ID
}
An event-sourced aggregate needs to be able to load its state from the previous sequence of events and return any changes (events) that have not yet been saved. Therefore, the concept of an event-sourced aggregate requires at least two more contracts.
interface EventSourcedAggregate<ID, EventType> : Aggregate<ID> {
/**
* Builds the current state of that aggregate
* from the history of previously stored events.
*/
fun loadFromHistory(events: List<EventType>)
/**
* Returns a list of events that have occurred
* after the aggregate got initialized.
*/
fun getChanges(): List<EventType>
}
Note about versioning: Event Sourcing often uses optimistic locking for appending events to an event store. This requires applying some kind of versioning to our aggregates in order to check if the aggregate was updated simultaneously, e.g. by another thread or process. In this blog, however, we omit the versioning aspect for reasons of clarity. Refer to my more feature-rich library implementation for how versioning can be implemented.
A straightforward base class implementation of that interface might look like the following class:
abstract class BaseAggregate<ID, EventType> (private val id: ID): EventSourcedAggregate<ID, EventType> {
/**
* The list changes (events) stored as a mutable list.
*/
private val changes = mutableListOf<EventType>()
override fun getId(): ID {
return this.id
}
override fun loadFromHistory(events: List<EventType>) {
events.forEach { handleEvent(it) }
}
override fun getChanges(): List<EventType> {
return this.changes.toList()
}
/**
* Adds the specified event to the list
* of changes and invokes the handleEvent()
* method for applying that event.
*/
fun registerEvent(event: EventType) {
changes.add(event)
handleEvent(event)
}
/**
* This method is invoked whenever a new event is
* registered. Implement logic here to change current
* state of the aggregate.
*/
protected abstract fun handleEvent(event: EventType)
}
This is everything required to implement a straightforward event-sourced domain model. Now, it is time to use that base class and implement a concrete aggregate.
The Book aggregate
I like to separate an aggregate’s contract and implementation, as it allows concentrating more on its behavior and what an aggregate should look like from the outside. The contract of the Book aggregate looks as follows:
sealed interface Book : EventSourcedAggregate<BookId, BookEvent> {
fun borrowBook(readerId: ReaderId, startDate: LocalDate, policy: BorrowBookPolicy): Result<Book>
fun returnBook(returnDate: LocalDate): Result<Book>
fun reserveBook(readerId: ReaderId, reservationDate: LocalDate): Result<Book>
fun clearReservation(): Result<Book>
}
Note that this source code is very explicit and uses domain-specific types rather than generic types, wherever appropriate. For example, instead of specifying a UUID as a reader id, we define a special type ReaderId
that wraps the corresponding UUID. This approach respects the ubiquitous language principle from DDD.
We use Kotlin’s Result
type to make behavior even more specific. Instead of throwing exceptions in case of validation errors, which are often invisible from the invoking site, we return a result object that indicates the outcome of an executed command. The caller can then handle both successful executions and error conditions.
The book aggregate implementation contains all domain logic for executing commands. This is where all the complex domain logic lives. The following code snippet shows the book aggregate class and the borrow method.
class BookAggregate(id: BookId) : Book, BaseAggregate<BookId, BookEvent>(id) {
private var currentLoan: Loan = NoLoan
private var currentReservation: Reservation = NoReservation
override fun borrowBook(readerId: ReaderId, startDate: LocalDate, policy: BorrowBookPolicy): Result<Book> {
// check if this book is already loan
if (currentLoan is ActiveLoan) {
return Result.failure(BookAlreadyLoan(getId()))
}
// check if there is a reservation that was made by another reader
if (currentReservation is ActiveReservation) {
val reservation = (currentReservation as ActiveReservation)
if (reservation.readerId != readerId) {
return Result.failure(BookReservedByOtherReader(readerId, reservation.readerId))
}
}
// validate student borrow policy (max number of lent books reached etc.
val result = policy.validateIfStudentIsAllowedToBorrowBook(readerId)
result.onFailure { return Result.failure(it) }
// the book can be borrowed, thus an event is created
val loanId = LoanId(UUID.randomUUID())
val endDate = startDate.plusWeeks(6)
val event = BookBorrowed(
getId(),
readerId,
loanId,
startDate,
endDate
)
registerEvent(event)
// clear reservation if book was reserved
if (currentReservation is ActiveReservation) {
clearReservation()
}
// return current instance
return Result.success(this)
}
// ... other methods omitted for clarity reasons ...
}
First, we validate whether the command can be executed or not. In case of a validation error, we return a result object with a well-defined business exception indicating what went wrong.
After successful validation, a BookBorrowed
event is created and added to the list of changes. The registerEvent(event)
method in turn invokes the handleEvent()
method, which applies changes to the current state of the aggregate. The following code snippet shows the handle method, which performs an exhaustive pattern match of the specified event:
override fun handleEvent(event: BookEvent) { // <<--- invoked by registerEvent()
when (event) {
is BookRegistered -> handle(event)
is BookBorrowed -> handle(event)
is BookReturned -> handle(event)
is BookReserved -> handle(event)
is ReservationCleared -> handle(event)
}
}
private fun handle(event: BookBorrowed) {
this.currentLoan = ActiveLoan( // <<<---- apply changes to current state
event.loanId,
event.readerId,
event.loanDate,
event.loanEndDate,
0
)
}
Note that we first register an event and then apply changes to the current state by handling that event. Only then, we can ensure that our application state is built exclusively from events. We could also write a separate state class for keeping the current state of an aggregate, which might be better suited for more complex aggregates with many child entities and value objects.
Testing the implementation is straightforward and does not require any infrastructure, as shown in the following code snippet:
class BookTest {
@Test
fun borrowBook() {
val bookId = BookId(UUID.randomUUID())
val book = BookFactory.registerNewBook(bookId)
val readerId = ReaderId(UUID.randomUUID())
val today = LocalDate.now()
val endDate = today.plusWeeks(6)
val policy = BorrowBookPolicy()
book.borrowBook(readerId, today, policy)
val event = book.getChanges().last()
Assertions.assertTrue(event is BookBorrowed)
val bookBorrowed = event as BookBorrowed
Assertions.assertEquals(bookId, bookBorrowed.bookId)
Assertions.assertEquals(readerId, bookBorrowed.readerId)
Assertions.assertEquals(today, bookBorrowed.loanDate)
Assertions.assertEquals(endDate, bookBorrowed.loanEndDate)
}
}
For the full implementation of the book aggregate, see my GitHub repository.
The Use Cases Layer
The next layer we will look into is the use cases layer, which contains application-specific software. This layer controls the flow of aggregates, including storing and retrieving aggregates to and from the event store, respectively. I prefer to organize code in this layer by feature. Each feature or use case is contained within a corresponding package, as illustrated in the following figure.
For each use case, there is a contract that can be invoked by the next outer layer. Organizing code this way respects the Single Responsibility Principle (SRP), so each class or interface has only one reason to change, which is a change of the according use case itself.
For example, the contract of the borrow book use case looks as follows:
sealed interface BorrowBook {
fun execute(command: BorrowBookCommand): Result<Unit>
}
data class BorrowBookCommand(
val bookId: BookId,
val readerId: ReaderId,
val loanAt: LocalDate
)
Similar to our domain model, we also return a result object indicating the outcome of the use case execution. There is no need for components which invoke that use case to know details on the implementation. The use case’s implementation is shown in the following code snippet:
class BorrowBookExecutor(
private val repository: BorrowBookRepository,
private val policy: BorrowBookPolicy
) : BorrowBook {
override fun execute(command: BorrowBookCommand): Result<Unit> {
// load book aggregate from repository
val book = repository.get(command.bookId)
// try to borrow book
val result = book.borrowBook(command.readerId, command.loanAt, policy)
return result.fold(
{
// on success
repository.save(it)
Result.success(Unit)
},
{
// on failure
Result.failure(it)
}
)
}
}
First, the according book aggregate is loaded from the repository, which is passed as a constructor argument. Second, the borrowBook
method of that book is called passing the data of the specified command. When the execution of the command was successful, we store changes of that aggregate in the event store. In case of a validation error, we return a failed result object and pass the business exception. Alternatively, we could map the business exception to a more “generic” or application-specific exception containing an error code.
I prefer to build a separate repository interface for each use case in order to reduce coupling points. Furthermore, those repository interfaces are not very complex, as shown in the following code snippet. The implementation can still use generic base classes in the infrastructure layer to avoid redundant code if desired.
interface BorrowBookRepository {
fun get(bookId: BookId): Book
fun save(book: Book)
}
Infrastructure and additional notes
Since the focus of this blog is on the domain and application layer, we won’t go into detail about the infrastructure layer. All infrastructure concerns are outsourced to the infrastructure layer. This is the layer where we would implement the logic for storing events in an event store such as EventStoreDB. This is also the layer where we would invoke the use cases layer from different entry points, for example, an HTTP endpoint, a GUI, or a console, and present results to the user.
Where do you do null checks and the like?
As the use cases layer as well as the domain layer uses domain-specific types that already ensure “valid” data, validations like null checks, empty strings, and the like are up to the infrastructure layer. The infrastructure layer converts data from the form most convenient for the infrastructure layer (e.g. a JSON data transfer object) to the form most convenient for the use cases (and domain) layer. In my opinion, it makes sense to avoid polluting the domain with those “annoying” validations and allow focusing on core domain logic instead.
Is the presented approach appropriate for every use case?
Obviously not. Combining Event Sourcing, DDD, and Clean Architecture is neither necessary nor reasonable for all use cases. Simpler subdomains with less complex logic might profit from a more straightforward approach, such as the Transaction Script pattern.
Outlook
Thanks for reading this post. I hope it gave you some insights on how to build an event-sourced domain model.
Although very popular, the tactical patterns from DDD (aggregates, value objects, entities, …) are just one way to build an event-sourced domain model. In the next post, I would like to present an alternative approach that I find worth considering when building an event-sourced domain model. Stay tuned and see you in the next one.
Photo by Hasan Almasi on Unsplash