Practical DDD in Golang: Aggregate
- 11 minutes read - 2257 wordsOne DDD pattern to rule them all.

I have spent years understanding and practicing the DDD approach. Most of the principles were easy to understand and implement in the code. However, there was one that particularly caught my attention. I must say that the Aggregate pattern is the most critical one in DDD, and perhaps the entire Tactical Domain-Driven Design doesn’t make sense without it. It serves to bind business logic together. While reading, you might think that the Aggregate resembles a cluster of patterns, but that is a misconception. The Aggregate is the central point of the domain layer. Without it, there is no reason to use DDD.
Join the Ompluscator's Blog Community
Hey there! Want to be part of our awesome community? Just drop your email below, and we'll send you the good stuff – cool articles, fun updates, and more. No spam, we promise!
Thank you for subscribing!
Business Invariants
In the real business world, some rules are flexible. For example, when you take a loan from a bank, you need to pay some interest over time. The overall amount of interest is adjustable and depends on your invested capital and the period you will spend to pay the debt. In some cases, the bank may grant you a grace period, offer you a better overall credit deal due to your loyalty in the past, provide you with a once-in-a-lifetime offer, or require you to place a mortgage on a house.
All of these flexible rules from the business world are implemented in DDD using the Policy pattern. They depend on many specific cases and, as a result, require more complex code structures. In the real business world, there are also some immutable rules. Regardless of what we try, we cannot change these rules or their application in our business. These rules are known as Business Invariants. For example, nobody should be allowed to delete a customer account in a bank if any of the bank accounts associated with the customer has money or is in debt. In many banks, one customer may have multiple bank accounts with the same currency. However, in some of them, the customer is not allowed to have any foreign currency accounts or multiple accounts with the same currency. When such business rules exist, they become Business Invariants. They are present from the moment we create the object until the moment we delete it. Breaking them means breaking the whole purpose of the application.
Currency Entity
type Currency struct {
id uuid.UUID
//
// some fields
//
}
func (c Currency) Equal(other Currency) bool {
return c.id == other.id
}
BankAccount Entity
type BankAccount struct {
id uuid.UUID
iban string
amount int
currency Currency
}
func NewBankAccount(currency Currency) BankAccount {
return BankAccount{
//
// define fields
//
}
}
func (ba BankAccount) HasMoney() bool {
return ba.amount > 0
}
func (ba BankAccount) InDebt() bool {
return ba.amount > 0
}
func (ba BankAccount) IsForCurrency(currency Currency) bool {
return ba.currency.Equal(currency)
}
BankAccounts Value Object
type BankAccounts []BankAccount
func (bas BankAccounts) HasMoney() bool {
for _, ba := range bas {
if ba.HasMoney() {
return true
}
}
return false
}
func (bas BankAccounts) InDebt() bool {
for _, ba := range bas {
if ba.InDebt() {
return true
}
}
return false
}
func (bas BankAccounts) HasCurrency(currency Currency) bool {
for _, ba := range bas {
if ba.IsForCurrency(currency) {
return true
}
}
return false
}
CustomerAccount Entity and Aggregate
type CustomerAccount struct {
id uuid.UUID
isDeleted bool
accounts BankAccounts
//
// some fields
//
}
func (ca *CustomerAccount) MarkAsDeleted() error {
if ca.accounts.HasMoney() {
return errors.New("there are still money on bank account")
}
if ca.accounts.InDebt() {
return errors.New("bank account is in debt")
}
ca.isDeleted = true
return nil
}
func (ca *CustomerAccount) CreateAccountForCurrency(currency Currency) error {
if ca.accounts.HasCurrency(currency) {
return errors.New("there is already bank account for that currency")
}
ca.accounts = append(o.accounts, NewBankAccount(currency))
return nil
}
In the example above, we can see a Go code construct with CustomerAccount
as an Entity and Aggregate. Additionally,
there are BankAccount
and Currency
as Entities. Individually, all three entities have their own business rules.
Some rules are flexible, while others are invariants. However, when they interact with each other, certain invariants
affect all of them. This is the area where we place our Aggregate.
We have a logic for BankAccount
creation that depends on all BankAccounts
of a particular CustomerAccount
.
In this case, one Customer
cannot have multiple BankAccounts
with the same Currency
. Furthermore, we cannot
delete a CustomerAccount
if all BankAccounts
connected to it are not in a clean state, meaning they should not
have any money in them.
The diagram above displays a group of three entities we’ve previously discussed. They are all interconnected by Business Invariants that guarantee the Aggregate is consistently in a dependable state. If any other Entity or Value Object is governed by the same Business Invariants, those new objects also become components of the same Aggregate. However, if within the same Aggregate, we lack a single Invariant that links one object to the rest, then that object does not belong to that Aggregate.
Boundary
Many times I have used DDD, there was a question about how to define the Aggregate boundary. By adding every new Entity or Value Object into the game, that question always rises. Till now, it is clear that Aggregate is not just some collection of objects. It is a domain concept. Its members define a logical cluster. Without grouping them, we can not guarantee that they are in a valid state.
Person Entity
type Person struct {
id uuid.UUID
//
// some fields
//
birthday time.Time
}
func (p *Person) IsLegal() bool {
return p.birthday.AddDate(18, 0, 0).Before(time.Now())
}
Company Entity
type Company struct {
id uuid.UUID
//
// some fields
//
isLiquid bool
}
func (c *Company) IsLegal() bool {
return c.isLiquid
}
Customer Entity and Aggregate
type Customer struct {
id uuid.UUID
person *Person
company *Company
//
// some fields
//
}
func (c *Customer) IsLegal() bool {
if c.person != nil {
return c.person.IsLegal()
} else {
return c.company.IsLegal()
}
}
In the code snippet above, you can see the Customer
Aggregate. In many applications, you will typically have an Entity
called Customer
, and often, that Entity will also serve as the Aggregate. Here, we have some Business Invariants
that determine the validity of a specific Customer
, depending on whether it is a Person
or a Company
. While
there could be more Business Invariants, for now, one suffices. Since we are developing a banking application, the
question arises: Do CustomerAccount
and Customer
belong to the same Aggregate? There is a connection between
them, and certain business rules link them, but are these rules considered Invariants?
One Customer
can have multiple CustomerAccounts
(or none at all). We have observed that there are certain
Business Invariants associated with objects related to Customer
and other Invariants related to CustomerAccount
.
To adhere to the precise definition of Invariants, if we cannot identify any that connect Customer
and
CustomerAccount
together, then it is advisable to separate them into distinct Aggregates. This same consideration
applies to any other cluster of objects we introduce: Do they share any Invariants with the existing Aggregates?
It’s always a good practice to keep Aggregates as small as possible. Aggregate members are typically persisted together in storage, such as a database, and adding too many tables within a single transaction can be problematic. In this context, it’s evident that we should define a Repository at the level of the Aggregate and persist all its members exclusively through that Repository, as demonstrated in the example below.
CustomerRepository
type CustomerRepository interface {
Search(ctx context.Context, specification CustomerSpecification) ([]Customer, error)
Create(ctx context.Context, customer Customer) (*Customer, error)
UpdatePerson(ctx context.Context, customer Customer) (*Customer, error)
UpdateCompany(ctx context.Context, customer Customer) (*Customer, error)
//
// and many other methods
//
}
We can define Person
and Company
as Entities (or Value Objects). However, even if they have their own Identity,
we should update them through the Customer
by using the CustomerRepository
. Working directly with Person
or
Company
or persisting them without Customer
and other related objects can break Business Invariants. It’s
important to ensure that transactions apply to all of them together or, if necessary, can be rolled back as
a whole. Deletion of an Aggregate must also occur as a cohesive unit. In other words, when we delete the
Customer
Entity, we should also delete the Person
and Company
Entities, as they don’t have a reason to
exist separately.
As you can see, the size of an Aggregate should neither be too small nor too large. It should be precisely bounded by Business Invariants. Everything within that boundary must be used together, and anything outside that boundary belongs to other Aggregates.
Relationships
As you could see previously in the article, there are relationships between Aggregates. These relationships should always be represented in the code, but they should be kept as simple as possible. To avoid complex connections, it’s best to avoid referencing Aggregates directly and instead use Identities for relationships. You can see an example of this in the code snippet below.
A Wrong Approach with Referencing
type CustomerAccount struct {
id uuid.UUID
//
// some fields
//
customer Customer // the wrong way with referencing
//
// some fields
//
}
The Right Approach with Identity
type CustomerAccount struct {
id uuid.UUID
//
// some fields
//
customerID uuid.UUID // the right way with identity
//
// some fields
//
}
The other problem may be with the direction of relationships. The best scenario is when we have a unidirectional connection between them and we avoid any bidirectional relationships. Deciding on the direction of these relationships is not always easy, and it depends on the specific use cases within our Bounded Context.
For example, if we are developing software for an ATM where a user interacts with a CustomerAccount
using a
debit card, we might sometimes need to access the Customer
by having its identity in the CustomerAccount
.
In another scenario, our Bounded Context might be an application that manages all CustomerAccounts
for one
Customer
, where users can authorize and manipulate all BankAccounts
. In this case, the Customer
should
contain a list of Identities associated with CustomerAccounts
.
Aggregate Root
All the Aggregates discussed in this article share the same names as some of the Entities, such as the Customer
Entity and the Customer
Aggregate. These unique Entities are known as Aggregate Roots and are the primary
objects within the Aggregates. An Aggregate Root serves as a gateway for accessing all other Entities,
Value Objects, and Collections contained within the Aggregate. It is considered the main entry point for
interacting with the Aggregate.
It is essential to follow the rule that members of an Aggregate should not be changed directly but through the Aggregate Root. The Aggregate Root should expose methods that represent its rich behaviors, define ways to access attributes or objects within it, and provide methods for manipulating that data. Even when an Aggregate Root returns an object, it should return only a copy of it to maintain encapsulation and control over the Aggregate’s internal state.
Rich Behaviors
func (ca *CustomerAccount) GetIBANForCurrency(currency Currency) (string, error) {
for _, account := range ca.accounts {
if account.IsForCurrency(currency) {
return account.iban, nil
}
}
return "", errors.New("this account does not support this currency")
}
func (ca *CustomerAccount) MarkAsDeleted() error {
if ca.accounts.HasMoney() {
return errors.New("there are still money on bank account")
}
if ca.accounts.InDebt() {
return errors.New("bank account is in debt")
}
ca.isDeleted = true
return nil
}
func (ca *CustomerAccount) CreateAccountForCurrency(currency Currency) error {
if ca.accounts.HasCurrency(currency) {
return errors.New("there is already bank account for that currency")
}
ca.accounts = append(ca.accounts, NewBankAccount(currency))
return nil
}
func (ca *CustomerAccount) AddMoney(amount int, currency Currency) error {
if ca.isDeleted {
return errors.New("account is deleted")
}
if ca.isLocked {
return errors.New("account is locked")
}
return ca.accounts.AddMoney(amount, currency)
}
Within an Aggregate, there are typically multiple Entities and Value Objects, each with its own Identity. These Identities can be classified into two types: Global Identity and Local Identity.
-
Global Identity: The Aggregate Root within the Aggregate has a Global Identity. This Identity is unique globally, meaning that there is no other Entity in the entire application with the same Identity. It is permissible to reference the Global Identity of the Aggregate Root from outside the Aggregate, allowing external parts of the application to interact with the Aggregate.
-
Local Identity: All other Entities and Value Objects within the Aggregate have local Identities. These Identities are unique only within the context of the Aggregate itself. They may be reused for Entities and Value Objects outside the Aggregate. Local Identities are known and managed solely by the Aggregate, and they should not be referenced or exposed outside the boundaries of the Aggregate.
By distinguishing between Global and Local Identities, we can maintain consistency and avoid conflicts within the Aggregate while ensuring that the Aggregate Root remains uniquely identifiable throughout the application.
Global and Local Identity
type Person struct {
id uuid.UUID // local identity
//
// some fields
//
}
type Company struct {
id uuid.UUID // local identity
//
// some fields
//
}
type Customer struct {
id uuid.UUID // global identity
person *Person
company *Company
//
// some fields
//
}
Conclusion
An Aggregate is a concept in the domain that follows certain rules called Business Invariants. These rules must always be true, no matter the state of the application. They define the limits or boundaries of an Aggregate. When it comes to storing or removing data, all parts of an Aggregate must be handled together. Aggregate Roots act as entry points to the other elements within the Aggregate. To access these elements, you must go through the Aggregate Roots; you can’t reach them directly.
Join the Ompluscator's Blog Community
Hey there! Want to be part of our awesome community? Just drop your email below, and we'll send you the good stuff – cool articles, fun updates, and more. No spam, we promise!
Thank you for subscribing!