Practical DDD in Golang: Aggregate

- 11 minutes read - 2257 words

One 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.

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.

Business Invariants

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?

Aggregate Boundary

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?

ll 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.

  1. 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.

  2. 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.

Useful Resources

comments powered by Disqus