In the previous chapter, we learned about some of the core concepts of domain-driven design. In this chapter, we will build upon that foundational knowledge to learn more patterns and concepts, which will help you on your journey to mastering DDD. We will start by looking at entities and value objects. This is where we will write most of the business logic for our domain-driven application.
We will finish by looking at aggregates, which are useful when we need to cluster domain objects together and treat them as a single item.
In this chapter, we will cover the following topics:
By the end of this chapter, you will be able to identify some common pitfalls while designing entities and how we can avoid them. You will also be able to tell what value objects and aggregate patterns are and to use them.
Let’s start by looking at entities.
In this chapter, we will write a small amount of Golang code. To be able to run it, you will need the following:
In domain-driven design, entities are defined by their identity. Their attributes do not define them, and it is expected that although their attributes may change over time, their identity will not. While the entity may change so much that it is indistinguishable from where it started, it retains the same identity, and we treat it as the same object. Let’s look at an example. On ebay.com, you can sign up as a user. If you choose to sell something, you become a seller. You can also choose to bid on items. A naïve model of this might look as follows:
Figure 3.1 – A simple domain model for an auction site
Some actions that could take place in our system are as follows:
These actions do not change the identity of our entity. We are still referencing the same ID, but some attributes may have changed.
An implementation of the auction entity might look as follows:
package chapter3 import ( "time" "github.com/Rhymond/go-money" ) // Auction is an entity to represent our auction construct. type Auction struct { ID int // We use a specific money library as floats are not good ways to represent money. startingPrice money.Money sellerID int createdAt time.Time auctionStart time.Time auctionEnd time.Time }
As an exercise, see if you can write entities for bids and users.
In the preceding code sample, we have an int called ID – this is our entity ID. Entity IDs do not necessarily have to be generated by the system. They may form part of the entity’s attributes. For example, in most countries, people are issued a unique tax identification number that never changes. Therefore, it might be a good unique identifier for you to use if it’s relevant to your domain (for example, if you are building a HR system).
One interesting case is a user’s email address. At a glance, email addresses might seem like good entity identifiers as we will require them to be unique. However, in most systems, users can change the email address they will receive notifications on. Therefore, email addresses would be much better suited to be an attribute of the entity.
Generating good unique identifiers for our entities is surprisingly hard. In the previous example, we used an int for our ID. For some good use cases, this will be fine as it is simple. However, in a system of substantial scale, we will quickly run into an issue.
Let’s say we write the following Go code:
fmt.Println(math.MaxInt)
We will get the following output:
9223372036854775807
However, let’s try and add 1 to this:
fmt.Println(math.MaxInt + 1)
We will get the following error:
cannot use math.MaxInt + 1 (untyped int constant 9223372036854775808) as int value in argument to fmt.Println (overflows)
We just ran out of integers! If we had used this as an identifier, we would now be experiencing a customer-facing production issue and would need to scramble to rearchitect our system under pressure effectively.
It is essential to try and future-proof your system as much as possible, especially when it comes to things such as entity identifiers. If you struggle to develop a good strategy for generating IDs, using universally unique identifiers (UUIDs) is a good place to start. UUIDs are 128-bit labels, which, when generated according to the specification, are effectively unique.
While UUIDs are not a part of the Go standard library, Google provides an excellent library for them. The following is an example of how to use UUIDs in Go:
package chapter3 import "github.com/google/uuid" type SomeEntity struct { id uuid.UUID } func NewSomeEntity() *SomeEntity { id := uuid.New() return &SomeEntity{id: id} }
If you are using a persistent store such as PostgreSQL, you can lean on it to create a UUID for you.
Due to the focus of entities being on their identity, it is very easy to fall into the trap of letting the database design dictate what your domain model will look like. This can lead to what is known as an anemic domain model.
Anemic models have little or no domain behavior as part of their design. This means that you are not getting the full benefit of DDD. In my experience, entities are where anemia shows up most often. It is quite easy to diagnose anemic models and course-correct them if they’re identified early enough. If your model has mostly public getter and setter functions, no business logic, or depends on various clients to implement the business logic, you probably have an anemic model.
Here is what an anemic entity for our auction might look like:
package chapter3 import ( "time" "github.com/Rhymond/go-money" ) type AnemicAuction struct { id int startingPrice money.Money sellerID int createdAt time.Time auctionStart time.Time auctionEnd time.Time } func (a *AnemicAuction) GetID() int { return a.id } func (a *AnemicAuction) StartingPrice() money.Money { return a.startingPrice } func (a *AnemicAuction) SetStartingPrice(startingPrice money.Money) { a.startingPrice = startingPrice } func (a *AnemicAuction) GetSellerID() int { return a.sellerID } func (a *AnemicAuction) SetSellerID(sellerID int) { a.sellerID = sellerID } func (a *AnemicAuction) GetCreatedAt() time.Time { return a.createdAt } func (a *AnemicAuction) SetCreatedAt(createdAt time.Time) { a.createdAt = createdAt } func (a *AnemicAuction) GetAuctionStart() time.Time { return a.auctionStart } func (a *AnemicAuction) SetAuctionStart(auctionStart time.Time) { a.auctionStart = auctionStart } func (a *AnemicAuction) GetAuctionEnd() time.Time { return a.auctionEnd } func (a *AnemicAuction) SetAuctionEnd(auctionEnd time.Time) { a.auctionEnd = auctionEnd }
Please note that there is nothing necessarily wrong with this, and you will see a lot of Go code that looks like this. But you simply are not getting the full benefit of the domain-driven design if you do this. As you can see, any other construct that uses our AnemicAuction will potentially make assumptions about what some of our attributes are for. Furthermore, they will implement business logic themselves and may do this in different ways that our domain experts did not intend.
Let’s refactor the code to the following:
package chapter3 import ( "errors" "time" "github.com/Rhymond/go-money" ) type AuctionRefactored struct { id int startingPrice money.Money sellerID int createdAt time.Time auctionStart time.Time auctionEnd time.Time } func (a *AuctionRefactored) GetAuctionElapsedDuration() time.Duration { return a.auctionStart.Sub(a.auctionEnd) } func (a *AuctionRefactored) GetAuctionEndTimeInUTC() time.Time { return a.auctionEnd } func (a *AuctionRefactored) SetAuctionEnd(auctionEnd time.Time) error { if err := a.validateTimeZone(auctionEnd); err != nil { return err } a.auctionEnd = auctionEnd return nil } func (a *AuctionRefactored) GetAuctionStartTimeInUTC() time.Time { return a.auctionStart } func (a *AuctionRefactored) SetAuctionStartTimeInUTC(auctionStart time.Time) error { if err := a.validateTimeZone(auctionStart); err != nil { return err } // in reality, we would likely persist this to a database a.auctionStart = auctionStart return nil } func (a *AuctionRefactored) GetId() int { return a.id } func (a *AuctionRefactored) validateTimeZone(t time.Time) error { tz, _ := t.Zone() if tz != time.UTC.String() { return errors.New("time zone must be UTC") } return nil }
Even in our simple example, we can see the benefit of our entity having some business logic. We have guaranteed that time zones are consistent, made it clear to the caller that we only deal with UTC, and enforced this with errors. We have also given them a consistent definition of the elapsed duration of our auction, rather than depending on the consumer to define it themselves, which could potentially lead to drift.
So, why can’t we use the same model for our database? As systems grow in complexity, you might find the need to store metadata about the auction, such as how many users viewed it, how effective ads were at pointing to this auction, or a tracing ID so that we can track a user’s journey through the system. All this information is useful, but it does not belong in the domain model.
Object-relational mappings (ORMs) are a popular approach to managing database persistence. They are not a DDD concept, but they are popular enough that I thought it was worth a brief mention.
For Golang, GORM (https://gorm.io) is a popular library for this. I am not a fan of ORMs – they lead to a layer of unnecessary abstraction and poor database query design, which is often one of the biggest reasons applications have performance issues. By using an ORM, you are delegating control of query creation and planning.
If you want to use an ORM, ensure it does not control how you write your entities in your DDD context; otherwise, you may end up with an anemic model. We also want to keep the coupling between our entity and ORM to a minimum. Therefore, I recommend you use an adaptor layer to decouple your ORM and DDD entity layer. We covered adaptor layers in more detail in the previous chapter.
Now that we understand entities, let’s look at value objects.
Value objects are, in some ways, the opposite of entities. With value objects, we want to assert that two objects are the same given their values. Value objects do not have identities and are often used in conjunction with entities and aggregates to enable us to build a rich model of our domain. We typically use them to measure, quantify, or describe something about our domain.
Before we go any further, let’s write some Golang code to help us understand value objects a bit further.
Firstly, we will define a Point in the following code block:
package chapter3 type Point struct { x int y int } func NewPoint(x, y int) *Point { return &Point{ x: x, y: y, } }
We will also write the following test, which checks if two points with the same coordinates are equal:
package chapter3_test import ( "testing" "ddd-golang/chapter3" ) func Test_Point(t *testing.T) { a := chapter3.NewPoint(1, 1) b := chapter3.NewPoint(1, 1) if a != b { t.Fatal("a and b were not equal") } }
To a human, these two points are, of course, equal. You visit point A or point B on a map; you will end up in the same place – that is, at coordinates 1,1. However, this test fails:
=== RUN Test_Point value_objects_test.go:13: a and b were not equal --- FAIL: Test_Point (0.00s)
So, why does it fail? In Golang, when we use the & symbol, we create a pointer to a memory address where points A and B are stored. When we do an equality check, they are not equal, as A and B are stored in different memory locations.
Now, let’s change our point definition to the following:
type Point struct { x int y int } func NewPoint(x, y int) Point { return Point{ x: x, y: y, } }
The test now passes (notice how we are no longer returning a pointer?). This is because the two points are now being compared on their values when we do an equality check. They are value objects; we can treat them equally if their values are equal.
Notice how, in the point class, x and y are lowercase? This is to stop them from being exported and mutated. It is recommended that value objects remain immutable to prevent any unexpected behavior.
Value objects should be replaceable. Imagine we are writing a game and using a point to represent the player’s current location. We might write some code to move our player, as follows:
package chapter3 type Point struct { x int y int } func NewPoint(x, y int) Point { return Point{ x: x, y: y, } } const ( directionUnknown = iota directionNorth directionSouth directionEast directionWest ) func TrackPlayer() { currLocation := NewPoint(3, 4) currLocation = move(currLocation, directionNorth) } func move(currLocation Point, direction int) Point { switch direction { case directionNorth: return NewPoint(currLocation.x, currLocation.y+1) case directionSouth: return NewPoint(currLocation.x, currLocation.y-1) case directionEast: return NewPoint(currLocation.x+1, currLocation.y) case directionWest: return NewPoint(currLocation.x-1, currLocation.x) default: //do a barrel roll } return currLocation }
The point here is a description of our player’s location. We can take advantage of the replaceability of the value object to update the point representing a player’s position to be a completely new value every time we move. In this specific instance, you’ll also notice that the move function is side effect free. This is something we should strive toward as part of immutability.
By following the principles of immutability and side-effect-free functions, we have made our value objects easier to reason about and to write unit tests for. We can write very simple tests with multiple different inputs with predictable outputs. This will help us with the long-term maintenance of the system.
We should aim to use value objects as much as possible when modeling our domain. This is because they are the safest constructs we can use when implemented correctly. We do not have to worry about consumers incorrectly modifying our instance in a way we did not intend.
If you care only about the values of an object, then it should preferably be a value object. Some other questions to ask yourself to ensure a value object is the right choice for you are:
If the answers to all these questions are yes, a value object is probably right for your use case.
At this point, it probably feels as if I am advising everything should be a value object. The truth is that this is not a bad way to think about it. Try and make everything a value object to start with until it does not fit your use case. At that point, it can be upgraded to an entity.
Now that we have a strong understanding of entities and value objects, we can build upon our knowledge and learn how to combine them with the aggregate pattern.
Aggregates are probably one of the hardest patterns of domain-driven design and are, therefore, often implemented incorrectly. This isn’t necessarily bad if it helps you organize your code, but in the worst case, it may hinder your development speed and cause inconsistencies.
In domain-driven design, the aggregate pattern refers to a group of domain objects that can be treated as one for some behaviors. Some examples of aggregate patterns are:
Figure 3.2 – An aggregate of a wallet holding a debit card, a credit card, and cryptocurrencies
Often, aggregates are confused with data structures used for collections of data, such as arrays, maps, and slices. These are not the same thing. While an aggregate may use these collections, an aggregate is a DDD concept and, therefore, will usually contain multiple collections, fields, functions, and methods. Instead, the job of an aggregate pattern is to act as a transaction boundary for the domain objects within. Loading, saving, editing, and deleting should happen to all objects within the aggregate or not at all. Let’s look at our examples again:
Let’s look at how we might implement the wallet aggregate we mentioned here:
type WalletItem interface { GetBalance() (money.Money, error) } type Wallet struct { id uuid.UUID ownerID uuid.UUID walletItems []WalletItem } func (w Wallet) GetWalletBalance() (*money.Money, error) { var bal *money.Money for _, v := range w.walletItems { itemBal, err := v.GetBalance() if err != nil { return nil, errors.New("failed to get balance") } bal, err = bal.Add(&itemBal) if err != nil { return nil, errors.New("failed to increment balance") } } return bal, nil }
Here are some interesting things to call out about this code block – id is our aggregate root and is our wallet’s identity. OwnerID is the identity of the entity that owns the wallet. We do not always need to know all the details of an owner, but it gives us the ability to fetch them when necessary. walletItems is a collection of WalletItem. WalletItem is an entity we defined elsewhere, so for now, we just define an interface.
One of the hardest tasks of domain-driven design is trying to discover which type of construct to use and when. Before trying to cluster our domain models into aggregates, we need to find our bounded context’s invariants. An invariant is simply a rule in our domain that must always be true. For example, we may say that in our system, for an order to be created, we must have the item in stock. This is a business invariant. If we do not have an item in stock, we cannot promise it to customers.
For aggregates, we are looking for transactional consistency, not eventual consistency; we want any changes to our aggregate to be immediate and atomic. Therefore, we can think of an aggregate as a transactional consistency boundary. Whenever we make changes within our domain, we should ideally only modify one aggregate per transaction. If it is more, then your model is probably not quite correct, and you should revisit it.
Generally, we should aim for small aggregates. Keeping aggregates small will help make our system more scalable, improve performance, and give transactions more chance of success. Let’s look at the order system again and imagine a multi-user scenario (that is, multiple customers are trying to order the same item from a website at once). We could model our order aggregate as follows:
type item struct { name string } type Order struct { items []item taxAmount money.Money discount money.Money paymentCardID uuid.UUID customerID uuid.UUID marketingOptIn bool }
This order struct seems reasonable and in line with what we see in many order flows online today. However, including marketing opt-in in this aggregate is a bad design for a couple of reasons:
type Order struct {
items []item
taxAmount money.Money
discount money.Money
paymentCardID uuid.UUID
customerID uuid.UUID
}
Note
This does not mean we cannot include a marketing opt-in checkbox in our UI; it should just be decoupled from our aggregate and the transactional guarantee we want to achieve.
Especially at the business scale, there will be situations where our bounded context changes and other sub-systems would like to be notified. Beyond our bounded context, we should expect (and aim for) eventual consistency. This means we expect the other systems to receive and process our event in a reasonable amount of time, but we do not expect it to be atomically up-to-date as we would expect our bounded contexts to be. This leads to more decoupled systems with stronger resilience and scalability possibilities. Check with the domain experts to see if eventual consistency is an acceptable trade-off to make room for these benefits. We will cover more about publishing domain events and microservices in Part 2 of this book.
In this chapter, we learned about entities, value objects, and aggregates. We saw why they can be challenging to reason about and why they are probably the most important building blocks of domain-driven design.
By now, we understand the difference between value objects and entities and why value objects are much safer to use generally. Furthermore, we have learned how to use aggregates to ensure transaction boundaries, which is important in any system!
In the next chapter, Chapter 4, Factories, Repositories, and Services, we will cover the final core concepts of domain-driven design before we build some more complex applications together in Part 2!
Take a look at the following resources to learn more about the topics that were covered in this chapter:
3.149.243.106