Practical DDD in Golang: Entity

- 10 minutes read - 2068 words

The story about DDD in Go continues by introducing one of the most common building blocks — the Entity.

In the previous article, I attempted to provide insights into the Value Object design pattern and how we should apply it in Go. In this article, the narrative continues with the introduction of a design pattern called Entity. Many developers have heard about Entity countless times, even if they’ve never used the DDD approach. Examples can be found in PHP frameworks and Java. However, its role in DDD differs from its use elsewhere. Discovering its purpose in DDD marked a significant turning point for me. It seemed a bit unconventional, especially for someone with a background in PHP MVC frameworks, but today, the DDD approach appears more logical.

It is not part of ORM

As demonstrated in the examples for PHP and Java frameworks, the Entity often assumes the roles of various building blocks, ranging from Row Data Gateway to Active Record. Due to this, the Entity pattern is frequently misused. Its intended purpose is not to mirror the database schema but to encapsulate essential business logic. When I work on an application, my Entities do not necessarily replicate the database structure.

In terms of implementation, my first step is always to establish the domain layer. Here, I aim to consolidate the entire business logic, organized within Entities, Value Objects, and Services. Once I’ve completed and unit-tested the business logic, I proceed to create an infrastructural layer, incorporating technical details like database connections. As illustrated in the example below, we separate the Entity from its representation in the database. Objects that mirror database schemas are distinct, often resembling Data Transfer Objects or Data Access Objects.

Entity inside the Domain Layer

type BankAccount struct {
	ID       uint
	IsLocked bool
	Wallet   Wallet
	Person   Person
}

Repository interface inside the Domain Layer

// Repository interface inside domain layer
type BankAccountRepository interface {
	Get(ctx context.Context, ID uint) (*BankAccount, error)
}

Data Access Object inside the Infrastructure Layer

type BankAccountGorm struct {
	ID         uint       	`gorm:"primaryKey;column:id"`
	IsLocked   bool       	`gorm:"column:is_locked"`
	Amount 	   int        	`gorm:"column:amount"`
	CurrencyID uint 	    `gorm:"column:currency_id"`
	Currency   CurrencyGorm `gorm:"foreignKey:CurrencyID"`
	PersonID   uint       	`gorm:"column:person_id"`
	Person     PersonGorm 	`gorm:"foreignKey:PersonID"`
}

Concrete Repository inside the Infrastructure Layer

type BankAccountRepository struct {
	//
	// some fields
	//
}

func (r *BankAccountRepository) Get(ctx context.Context, ID uint) (*domain.BankAccount, error) {
	var dto BankAccountGorm
	//
	// some code
	//
	return &BankAccount{
		ID:       dto.ID,
		IsLocked: dto.IsLocked,
		Wallet:   domain.Wallet{
			Amount:   dto.Amount,
			Currency: dto.Currency.ToEntity(),
		},
		Person:   dto.Person.ToEntity(),
	}, nil
}

The example shown above is just one of the many variations we can implement. While the structure of both the Entity and DTO can vary depending on the specific business case (such as having multiple Wallets per BankAccount), the core concept remains consistent.

We always maintain the Repository interface in the domain layer. Within this layer (which is typically the lowest one in the layered architecture I use), certain Domain Services may depend on Repositories, so they should be aware of their existence. Repositories provide a contract that ensures we work with Entity objects from our domain layer, at least when dealing with them externally. Inside the Repository, we can handle things as needed, as long as we deliver accurate results.

With this structure, I’ve consistently managed to separate my business logic from the underlying storage. When it comes to making changes to the database, only the mapping methods, which transform DTOs to Entities and vice versa, need to be modified.

Additional examples of Entities

type Currency struct {
	ID       uint
	Code     string
	Name     string
	HtmlCode string
}

type Person struct {
	ID          uint
	FirstName   string
	LastName    string
	DateOfBirth time.Time
}

type BankAccount struct {
	ID       uint
	IsLocked bool
	Wallet   Wallet
	Person   Person
}

In some instances, Entities might encompass intricate business logic, drawing data from various sources such as relational databases, NoSQL databases, and external APIs. Particularly in these scenarios, the concept of segregating the business layer from technical details proves to be extremely beneficial.

Identity

The primary distinction from Value Objects is the concept of Identity. Entities possess Identities, which is their sole property that can establish their uniqueness. Even if two Entities differ slightly in one or more of their fields, they are considered the same Entity if they share the same Identity. Therefore, when we assess their equality, we solely examine their Identities.

Checking Equality in Entity

type Currency struct {
	ID       uint
	Code     string
	Name     string
	HtmlCode string
}

func (c Currency) IsEqual(other Currency) bool {
	return other.ID == c.ID
}

There are three types of Identities:

  1. Application-generated Identities: In this case, we create new Identities for entities before they are stored in the database. UUIDs are commonly used for this purpose.

  2. Natural Identities: These involve using existing biological or unique identifiers when working with real-world entities, such as Social Security Numbers.

  3. Database-generated Identities: This is the most common approach, even when the option to implement the previous two solutions is available. In this approach, Identities are generated by the database.

Application-generated Identities

type Currency struct {
	ID       uuid.UUID
	Code     string
	Name     string
	HtmlCode string
}

func NewCurrency() Currency {
	return Currency{
		ID: uuid.New(), // generate new UUID
	}
}

Natural Identities

type Person struct {
	SSN         string // social security number
	FirstName   string
	LastName    string
	DateOfBirth time.Time
}

Database-generated Identities

type BankAccount struct {
	ID       uint
	IsLocked bool
	Wallet   Wallet
	Person   Person
}

type BankAccountGorm struct {
	ID         uint `gorm:"primaryKey;autoIncrement:true"`
	IsLocked   bool
	Amount 	   int        	
	CurrencyID uint
	PersonID   uint
}

I prefer to use only numerical values for indexing and querying. In many cases, when working with application-generated keys or natural keys, we encounter text-based data and need to find a way to accurately map these texts to numerical values in a database.

Since Identity is the primary distinction between Entity and Value Object, you might guess that this line of separation can be easily blurred. Indeed, depending on the Bounded Context, an object can easily transition from being an Entity to a Value Object.

Transaction Service

type Currency struct {
	ID       uint
	Code     string
	Name     string
	HtmlCode string
}

Web Service

type Currency struct {
	Name     string
	HtmlCode string
}

Just as seen in the example above, Currency can function as a central Entity within a specific Bounded Context, such as a Transaction Service or Exchange Service. However, in situations where we require it for UI formatting, Currency can be employed as a straightforward Value Object.

Validation

In contrast to a Value Object, an Entity can alter its state over time. This implies that we need to perform ongoing validation checks whenever we intend to modify an Entity.

Validation with each change

type BankAccount struct {
	ID       uint
	IsLocked bool
	Wallet   Wallet
	//
	// some fields
	//
}

func (ba *BankAccount) Add(other Wallet) error {
	if ba.IsLocked {
		return errors.New("account is locked")
	}

	//
	// do something
	//
}

Yes, I understand. In the example above, we can directly access Wallet and modify it without using the Add method. Personally, I’m not a big fan of Getters and Setters in Go. I find it hard to maintain when there are many functions that either return or set values. In such cases, I trust the engineers’ judgment to understand how they should change the state of the Entity if methods are already available. However, I leave this decision to each developer to make on their own. Using getters and setters with private fields is also a viable solution.

Pushing behaviors

The primary goal of DDD is to closely mirror the business processes. Therefore, it shouldn’t come as a surprise when we encounter numerous methods within our domain layer. These methods can belong to various objects. Since Entities hold the most intricate state compared to all other code components, they may also feature the most functions to represent their extensive behaviors.

In some instances, we might observe that a couple of fields within an Entity consistently interact with each other. If we use one of them to enforce a particular business rule, it’s likely that we’ll also need the other one. In such cases, we can always group these fields into a single unit, a Value Object, and delegate its management to the Entity. However, we must approach this carefully to ensure a clear separation of concerns between the Entity and Value Objects.

One Wrong Approach

type Wallet struct {
	Amount   int
	Currency Currency
}

type BankAccount struct {
	ID       uint
	IsLocked bool
	Wallet   Wallet
	//
	// some fields
	//
}

func (ba *BankAccount) Deduct(other Wallet) error {
	if ba.IsLocked {
		return errors.New("account is locked")
	}
	if !other.Currency.IsEqual(ba.Wallet.Currency) {
		return errors.New("currencies must be the same")
	}
	if other.Amount > ba.Wallet.Amount {
		return errors.New("insufficient funds")
	}

	ba.Wallet = Wallet{
		Amount:   ba.Wallet.Amount - other.Amount,
		Currency: ba.Wallet.Currency,
	}

	return nil
}

In the example above, we can see that the BankAccount Entity takes on more responsibility from the Wallet Value Object. It’s clear when we check if the BankAccount is locked or not. However, verifying the equality of Currency and ensuring there’s enough amount in the Wallet raises a code smell. In such situations, I relocate the entire deduction logic to the Value Object, except for the crucial task of verifying if the BankAccount is locked. This way, the Wallet gets its share of code to validate and deduct the amount.

The Right Approach

type Wallet struct {
	Amount   int
	Currency Currency
}

func (w Wallet) Deduct(other Wallet) (*Wallet, error) {
	if !other.Currency.IsEqual(w.Currency) {
		return nil, errors.New("currencies must be the same")
	}
	if other.Amount > w.Amount {
		return nil, errors.New("insufficient funds")
	}

	return &Wallet{
		Amount:   w.Amount - other.Amount,
		Currency: w.Currency,
	}, nil
}

type BankAccount struct {
	ID       uint
	IsLocked bool
	Wallet   Wallet
	//
	// some fields
	//
}

func (ba *BankAccount) Deduct(other Wallet) error {
	if ba.IsLocked {
		return errors.New("account is locked")
	}

	result, err := ba.Wallet.Deduct(other)
	if err != nil {
		return err
	}

	ba.Wallet = *result

	return nil
}

This way, the Wallet Value Object can be associated with any other Entity or Value Object and still facilitate deductions based on its internal state. Conversely, the BankAccount can offer an additional method for deducting amounts from locked accounts without duplicating the same logic. An Entity has the flexibility to delegate its behaviors to other building blocks, such as Domain Services.

I transfer these methods to Services in two scenarios. The first situation arises when the behavior is too intricate, possibly involving interactions with Specifications, Policies, other Entities, or Value Objects. It might also rely on results obtained from Repositories or other Services. The second case involves behaviors that aren’t overly complex but lack a clear place to reside. They could potentially belong to one Entity, another Entity, or even a Value Object.

Another Wrong Approach

type Currency struct {
	ID uint
	//
	// some fields
	//
}

type ExchangeRatesService struct {
	repository ExchangeRatesRepository
}

func (s *ExchangeRatesService) Exchange(to Currency, other Wallet) (Wallet, error) {
	//
	// do something
	//
}

When the business logic becomes too complex, my practice is to transfer it to a distinct Domain Service, as shown with the ExchangeRatesService in the example above. This approach has consistently allowed me to enhance my domain layer by introducing new Domain Policies.

At times, it seems like the right course of action to delegate behavior to other building blocks. However, it’s crucial to exercise caution when doing so. Transferring too many behaviors from Entities to Domain Services can give rise to another code smell known as the Anemic Domain Model.

Another Right Approach

type TransactionService struct {
	//
	// some fields
	//
}

func (s *TransactionService) Add(account *BankAccount, second Wallet) error {
	//
	// do something
	//
}

The example above illustrates the TransactionService Domain Service, which assumes responsibility from the BankAccount Entity. When there’s no need to validate complex business invariants, this behavior doesn’t necessarily belong in a Domain Service. Determining the appropriate location for a specific behavior is akin to an exercise that may appear challenging at first but becomes more intuitive with practice. Even today, I occasionally face difficulties in pinpointing the ideal location, but more often than not, I can structure the code as it should be.

Conclusion

While we commonly utilize them in various frameworks, it’s not always the best practice. Their role should be to represent our states and behaviors rather than merely mirroring the database schema. Entities provide us with valuable means to describe stateful real-world objects. In many instances, they serve as the core components of our applications, if not essential for our business logic to function properly.

Useful Resources

comments powered by Disqus