Chapter 6 of Domain-Driven Design by Eric Evans is about handling objects that have complex interdependencies and a life cycle where the object goes through different states. These objects can be harder to maintain while keeping the model clean and preserving its integrity.
The author introduces 3 patterns: Aggregates, Factories and Repositories. This article will discuss Aggregates and I will explain it with an example using Golang.
What's an Aggregate?
An Aggregate is a cluster of associated objects that we treat as a unit for the purpose of data changes. The aggregate enforces the integrity of the model by using Invariants
Invariants are consistency rules that must be maintained whenever data changes occur between members of the aggregate
Why use an Aggregate?
Business domains usually have many interconnected objects which introduces difficulties in setting boundaries between objects and preserving the objects integrity at the same time
Structure of Aggregates
An Aggregate is an object that has a reference to multiple other objects where one of these objects is the root of the aggregate
The root is the only object inside the aggregate that objects outside the aggregate can hold a reference to
Objects within the aggregate can reference each others
Objects other than the root have local identity inside the aggregate but they have no identity outside the aggregate. This is local to the context of the aggregate and does not mean that an object that has an identity in the system cannot belong to the aggregate even if it's not the root object.
Example
Assume we have a banking system where we have Entities: Customer, Account, Transaction
A customer wants to transfer money from one of their account to another customer's account
In this scenario we need to check for a lot of things:
- The customer is actually the owner of the account
- The account has enough funds to make the transfer
- The 2 accounts are active
- The 2 accounts are not the same
We can use an aggregate to enforce those variants
The aggregate's root is the transaction because that's what we care about in this operation. The id of the aggregate will be the transaction Id since it is the root.
Let's call the aggregate "TransferAggregate"
Its structure would be:
type TransferAggregate struct{
transaction Transaction,
sender Customer,
senderAccount Account,
receivingAccount Account
}
Notice that the Entities have an identity outside of the aggregate. However, from the aggregate's point of view, we only need the data inside this object and we don't care about their identity in this operation. As for the Accounts, each has an identity inside the aggregate because we need to differentiate between the sender and the receiver.
Notice that the fields are all private to prevent changes to the aggregate without using the methods.
When The user creates the trasfer request we create the aggregate and check for the invariants
func (t *TransferAggregate ) isValid() error {
// check if customer has the required amount
if t.transaction.GetAmount() >= t.senderAccount.GetFunds() {
return errors.New("insufficient funds")
}
// Check if customer is the owner of the sender account
if t.sender.Id != t.senderAccount.GetOwnerId() {
return errors.New("unauthorized")
}
// Check if both accounts are active
if t.senderAccount.IsActive() == false && t.receivingAccount.IsActive() == false {
return errors.New("one or both accounts are not active")
}
// Check if both accounts are active
if t.senderAccount.Id == t.receivingAccount.Id {
return errors.New("can not transfer from the same account")
}
return nil
}
We created a function that validates all the invariants at once. it returns nil if the invariants are satisfied and an error if any of the invariants is not satisfied.
Now let's implement the function that creates the aggregate
func Create (transaction Transaction, customer Customer, sender Account, receiver Account) (TransferAggregate, error) {
var transferAggregate = TransferAggregate{
transaction: transaction,
sender: customer,
senderAccount: sender,
receivingAccount: receiver
}
err := transferAggregate.isValid()
if err == nil {
t.senderAccount.SubtractFunds(t.transaction.GetAmount())
t.receivingAccount.AddFunds(t.transaction.GetAmount())
return transferAggregate, nil
}
return TransferAggregate{}, err
}
The function takes 4 arguments which are the 4 entities that construct the aggregate. It first creates the aggregate then invokes the isValid
method. If it returns nil, we create the aggregate and return it otherwise we return an error.
One important thing to know is that we may or may not persist the aggregate in the database depending on the use case. But we do need to persist the changes we make to the objects in the database. In this case we should save the transaction, senderAccount and recieverAccount to the database. This can be done through the function that is calling Create. This function would :
- Begin database transaction
- Get all the entities from the database
- Call the Create Method
- if no errors happen, Commit the changes to the database
- End database transaction
I will not implement this function here because it is out of scope of this article.
Note that all the actions from reading the entities to persisting them to the database must be inside a database transaction to ensure that the data has not changed since they were read.
It's important to understand that this is just an example. In real financial systems a lot more needs to be handled but I didn't handle that for the sake of simplicity.
Conclusion
Aggregates are a concept and they don't really have a specific way of implementation. I implemented this example only to clarify the concepts, however, it can be implemented in many different ways depending on the needs of the project. So understand the concept itself and implement aggregates the way that fits your use case.