Domain-driven Design in Go: Aggregates

Dennis Vis
Dennis Vis
March 23 2025
12 min read

Using Value Objects and Entities goes a long way towards improving code readability and structure. But for effective system design in Domain-driven Design, you’ll need to understand a higher level building block: aggregates.

In this post, I’ll explore what aggregates entail, how they relate to system design and how to create them in Go.

This post is part of the series "Domain-driven Design in Go".

In this series, I explain how I apply Domain-driven Design (DDD) in Go projects. This is not meant to be a definitive guide on how this should be done. Instead, I want to show you how it can be done. These are the methods I have come to apply frequently, after trying out many different ways, and they work quite well for me. But I’m still fine-tuning these methods frequently. Hopefully you’ll be able to extract some inspiration and teachings from them.

Introducing aggregates

In code, aggregates equal entities. Like entities, aggregates compose other entities and value objects. They also have their own identifier and lifecycle. For this reason, make sure you understand entities before reading on.

The concept of aggregates encompasses more than how to structure code, though. They define specific boundaries within applications. These boundaries need careful consideration and enforcement. Defining aggregates has implications for code structure, but even more so for the design of the system as a whole.

Throughout this post, I’ll use a simplified ‘Order’ in an e-commerce system as an example aggregate. The diagram below shows a schematic representation of the example aggregate. Some of the terms in there might not make sense right now, but they should by the end of this post.

---
config:
    class:
        hideEmptyMembersBox: true
---
classDiagram
    direction LR
    class C["Customer"]
    namespace Product {
        class P["Product"]
    }
    <<AggregateRoot>> P
    namespace Order {
        class OI["OrderItem"]
        class SA["ShippingAddress"]
        class O["Order"]
    }
    <<AggregateRoot>> O

    C "1" <..|> "0..*" O
    O "1" *--o "0..*" OI
    P "1" <--o "0..*" OI
    O "1" *--o "0..1" SA

Aggregates as guardians of data consistency

To understand aggregates one needs to understand that their main purpose involves maintaining data consistency. This happens in two ways:

  1. By ensuring that the data within the aggregate gets treated as a single unit
  2. By applying validation and other business logic to the data within the aggregate

The first point ensures that the data relating to the aggregate’s element never goes out of sync. I’ll illustrate why this matters using the ‘Order’ example.

An order in an e-commerce system typically contains:

  • Order items, relating to products, quantities, prices
  • Pricing information, like subtotal, taxes, discounts, total
  • Status information, like pending, paid, shipped
  • Customer information, like shipping address

Imagine treating these elements as separate units. Imagine persisting order items separately from the order. That would create a possible situation where an update to an order item’s quantity happens, but the order total stays the same. This could lead to issues, such as customers believing they owe less than the actual amount. Persisting the order in a single transaction ensures that the order and its items always stay consistent.

The second point about validation and business logic weighs equally heavy. This kind of logic often gets referred to as the invariants. Invariants serve as rules that the data within the aggregate must meet to ensure validity.

Coming back to the ‘Order’ example, it could have invariants that ensure:

  • The order total always equates the sum of the order items’ prices
  • The order status always reflects the payment status
  • The order item quantity never exceeds the inventory quantity

Invariants belong in the aggregate because only it has all the information needed to apply them. Only the Order aggregate knows about all the order items it contains. As a result it knows, for instance, what the total price of the order amounts to.

Now you know why ’expressing domain logic’, as described in the post about entities, actually relates to aggregates. Domain logic, like business rules, belong to aggregates. All actions done on the data within an aggregate, need to go though that aggregate.

Next, you’ll learn where to place this logic: the aggregate root.

Aggregate root

At the root of an aggregate resides a special entity: the aggregate root. All the entities and value objects that make up the aggregate descend directly or indirectly from the aggregate root.

The fact that aggregate and aggregate root form distinct concepts confuses some developers. It certainly confused myself. To help make this more clear, keep the following in mind:

  • ‘Aggregate’ relates to the overarching concept of a collection of entities and value objects, that form a logical whole
  • ‘Aggregate root’ relates to the main entity in the aggregate, composing all entities and value objects and exposing the aggregate’s public interface

In essence, ‘aggregate’ relates to the conceptual construct. ‘Aggregate root’ relates to both the entity at the root of the aggregate and the representation of an aggregate in code.

As mentioned, the aggregate root exposes the aggregate’s public interface. This means that when you create an aggregate, you need to make sure that all behavior related to the elements it composes, go through the aggregate root. You can call methods on other entities within the aggregate, but only if done from within methods of the aggregate root.

Go provides a way to enforce this. At least partially, on package level. By starting the names of the methods on the non-aggregate-root entities with a lowercase letter, you can make sure that clients outside the package can’t reach them. The aggregate root should generally live in the same package and, as a result, does have access to these ‘un-exported’ methods.

For example:

package order

type OrderItem struct {
    productID string
    quantity  int
    // ...
}

func (o *OrderItem) updateQuantity(quantity int) error {
    // ...
}

type Order struct {
    id    string
    items []OrderItem
}

func (o *Order) computeTotal() error {
    // ...
}

func (o *Order) UpdateQuantity(productID string, quantity int) error {
    found := false
    for i := range o.orderItems {
        if o.orderItems[i].productID != productID {
            continue
        }

        found = true
        err := o.orderItems[i].updateQuantity(quantity)
        if err != nil {
            // Handle error
        }

        break
    }
    if !found {
        return fmt.Errorf("product with ID %s not found in order", productID)
    }

    err := o.computeTotal()
    if err != nil {
        // Handle error
    }

    return nil
}

Here, both the OrderItem entity and the Order aggregate root define an updateQuantity method but clients outside the package can only call the UpdateQuantity method of the aggregate root, as its written with an uppercase letter. This creates a nice separation of concerns, where the OrderItem entity contains the update logic for individual order items, while the aggregate root deals with the business logic related to a collection of order items. This moves some of the logic out of the aggregate root but the aggregate root still provides the only available public interface for an Order.

Keep in mind that other aggregates you place in the same package also have access to the ‘un-exported’ methods, resulting in non-absolute isolation. One could argue that aggregates need their own package. When just starting out on your DDD journey, this could form a good strategy to begin with. But avoid creating a situation that limits future flexibility. Allow your code to evolve with changing requirements. When the aggregate-per-package strategy doesn’t seem to fit anymore, re-assess it and decide if another approach fits better.

Representing an aggregate root as an entity in code makes it indistinguishable from any other entity. To make sure readers of the code understand that a specific entity actually represents an aggregate root, adding a code comment to explain this makes sense. Like in the following snippet:

// Order is the AggregateRoot of the Order aggregate.
type Order struct {
    // ...
}

Deciding on a static marker for aggregate roots (AggregateRoot in this example) helps with code base navigation, as you can use this term in search queries.

Cross referencing

Aggregates don’t exist in isolation but constitute parts of larger systems, composing more aggregates. This means that aggregates require the capability to reference other aggregates. Keep the following considerations in mind when creating these cross-references.

First of all, the method of referencing needs addressing. It might seem tempting to use an object reference for this but that leads to undesirable implications, like having to load two aggregates from storage, where one would suffice. Instead, when an aggregate needs a reference to another aggregate, use the ID of the other aggregate to achieve this. Then use that ID for fetching the other aggregate when the need for that arises.

For example:

// Product is the AggregateRoot of the Product aggregate.
type Product struct {
    id string
    // ...
}

type OrderItem struct {
    productID string
    quantity  int
    // ...
}

// Order is the AggregateRoot of the Order aggregate.
type Order struct {
    id    string
    items []OrderItem
    // ...
}

func (o *Order) OrderItems() []OrderItem {
    return o.items
}

type InvoiceService struct {
    orderRepo   OrderRepository
    productRepo ProductRepository
}

func (svc *InvoiceService) GetInvoiceDetails(orderID string) (InvoiceDetails, error) {
    order, err := svc.orderRepo.GetByID(orderID)
    if err != nil {
        // Handle error
    }

    orderItems := order.OrderItems()

    productIDs := make([]string, len(orderItems))
    for i, oi := range orderItems {
        productIDs[i] = oi.ProductID
    }

    products, err := svc.productRepo.ListByIDs(productIDs)
    if err != nil {
        // Handle error
    }

    return InvoiceDetails{
        Order:    order,
        Products: products,
    }
}

This example shows that using an ID to reference a Product in an OrderLine allows postponing product retrieval until a need for its data arises. In the example, the need for retrieving the product arises when creating invoice details. Only then does the product data need retrieval from storage. You’ll notice the presence of a service and repositories. Both subjects of future posts but I’ll briefly explain each of them further in this article.

Besides the method of referencing, data consistency - the most important responsibility of aggregates, as you’ve learned - needs consideration. A single aggregate keeps its data immediately consistent. This means that persistence of changes takes place in a single transaction, right after the request for those changes comes in.

Conversely, changes communicated between aggregates exhibit eventual consistency. Meaning persistence of these changes happens somewhere in the future. This arises from the fact that these changes span a multitude of transactions, one for each aggregate. Which, in turn, means a small amount of time exists between when each aggregate persists their part of the changes. No matter how small that time frame, it demands consideration within the system’s design.

Aggregates themselves function as tools for data consistency. But when dealing with data spanning a multitude of aggregates, the need for further tooling arises. I’ll briefly explain a few of the options available to you.

Services

Domain services, often confused with microservices, form another concept within Domain-driven Design. They specifically allow for dealing with logic that spans more than one aggregate. In other words: a service encompasses logic that doesn’t belong to a single aggregate.

The exact workings of services forms the subject of another, future post. For now, knowing the following about services should suffice:

  • Services provide a location for logic that spans more than one aggregate, thereby orchestrating communication between aggregates
  • Services don’t offer clear cut answers about what to do when communication between aggregates fails

To the last point: the responsibility for what to do in situations where logic that spans more than one aggregate fails somehow, lies with the implementer. For instance, one might accept partial success, or, conversely, decide to perform a full rollback. Services offer a place to house this kind of logic but they don’t offer any clear cut solutions for this.

Events

Domain events form yet another Domain-driven Design concept. Where services often run within the same process as the aggregates they manage, events, in contrast, often cross process boundaries. This makes them powerful but can also increase complexity.

Events also form the topic of a future blog post, so I’ll again keep it brief. Knowing the following about events suffices for now:

  • A single process creates an event but many processes can consume such an event
  • Events allow for indirect communication between aggregates
  • Events can enable cross-aggregate logic

Events enable event-driven systems. This often fits well within microservices architectures. As a result, events feature predominantly in modern Domain-driven Design. But event-driven logic often involves complexity. If a service suffices, use that initially. But when the logic spans a multitude of boundaries, like bounded contexts, events can help to achieve this.

Jobs

Jobs form a more generic concept, present in many software systems and not directly related to Domain-driven Design. Jobs consist of pieces of independent logic that execute on demand or for specific criteria, for instance when the next slot of a schedule activates.

Jobs can achieve eventual consistency between aggregates that reference each other. Imagine, for instance, a job that runs periodically and checks whether any payment aggregates have changed their state. If so, it updates the orders that they reference.

Since an event-driven system can achieve the same things jobs can, and since publishing events for significant happenings in the domain provides many other benefits as well, using events instead of jobs often makes more sense. The main advantage of jobs comes in the form of their simplicity. But this simplicity only lasts until dependencies between jobs start to arise. Once jobs become unwieldy due to complex dependencies, replace them with an event-driven approach or incorporate them into a workflow engine.

Implications on system design

By now, the implications of choosing the boundaries of aggregates have become clear. Not only do they affect the entities and value objects they compose, they also affect the communication methods between each other and, by extension, the inner workings of a system and the interplay of its data.

Much like defining bounded contexts or microservices, defining aggregates represents a software design decision that has a profound impact on the system as a whole.

Systems can evolve over time so the decisions you make today won’t have to haunt you forever. But take note of the implications, trade-offs and possible adjustments that you can apply in the future. This enables you to properly evolve the system as needed, when it turns out the initial design no longer fits.

A word on repositories

In this article I’ve touched upon the persistence of aggregates a few times. Aggregates would not function if they would not persist the data they hold. In Domain-driven Design, persisting of aggregates happens through the concept of a repository.

This leads to the understanding that to work with aggregates, you’ll need to persist them. To persist them, you’ll need to use a repository. In a future article I’ll go in depth on repositories and how to create them in Go.

Conclusion

In this post you’ve learned that:

  • The creation of aggregates in Go mirrors that of entities
  • Aggregates guard data consistency
  • The aggregate root contains the entire aggregate data structure and the invariants in its methods
  • Aggregates can reference each other and this needs special consideration
  • Deciding on aggregate boundaries has implications for system design

Understanding aggregates, how to define them, and how to use them, forms a vital component of Domain-driven system design.

comments powered by Disqus