DEV Community

Cover image for Elegant Domain-Driven Design objects in Go
Manuel Doncel Martos
Manuel Doncel Martos

Posted on

Elegant Domain-Driven Design objects in Go

❓How do you define in Go your domain objects?

Go isn't your typical object-oriented language. When you try to implement Domain-Driven Design (DDD) concepts like Entities and Value Objects it can feel that you are fighting against the language.

In this article, I'll share practical patterns I've used to implement DDD concepts in Go. We'll focus on making your domain code expressive, type-safe, and maintainable.

⚠️This is an opinionated article that hopefully helps you to implement better and more elegant structs.
Aggregates are left out on purpose.

Our Domain: Coffee Shop Loyalty Program

Let's come up with an easy example so we can focus on how to properly define DDD structs.

Problem Space

"How do we encourage customers to return to our coffee shop by rewarding repeat purchases?"

Core Problems:

  • Customers buy coffee.
  • We want to give them points for each purchase.
  • Points expire after 30 days.
  • We can show a leaderboard and give rewards like free coffee.

Solution Space

After several collaborations/iterations with the domain experts, we've identified:

  • An entity Customer, that contains customer's info and their coffee orders.
  • A value object CoffeeOrder, that contains information about the order.

Let's start implementing the CoffeeOrder first.

Initial Implementation Attempt

Value Objects

Value objects are immutable objects representing descriptive aspects of a domain, defined solely by its attributes (values) rather than a unique identity.

type CoffeeOrder struct {
  // Size of the ordered coffee.
  Size string
  // OrderBy user id that ordered the coffee.
  OrderBy int
  // OrderTime time when the ordered happened.
  OrderTime time.Time
}
Enter fullscreen mode Exit fullscreen mode

As we can see, there is not anything like ID, or CoffeOrderID.

Entities

Entities are objects defined by their unique identity, not just its attributes, possessing a lifecycle and continuity through changes in its state.

type Customer struct {
  // ID of the customer.
  ID int
  // Name of the customer.
  Name string
  // CoffeeOrders the coffee orders of this customer.
  CoffeeOrders []CoffeeOrder
}
Enter fullscreen mode Exit fullscreen mode

Then, we can implement the business logic by asking the Customers for their points at a particular moment in time, with something like:

// PointsAt returns the points of a customer at a moment in time.
func (c *Customer) PointsAt(ref time.Time) int{
  points := 0
  for _, co := range c.CoffeeOrders {
    points += co.Points(ref)
  }

  return points
}
Enter fullscreen mode Exit fullscreen mode

Despite this can look ok, there are many things that can be improved.

Leveling Up: Making Our Domain More Robust

Immutability

An entity needs to mutate, but a value objet needs to be immutable. The current implementation does not guarantee immutability for the CoffeeOrder struct.
In fact, this is a concept that it's not natural in Go, and here is an example in which we need to fight against the language.

❓How To Achieve Immutabiliy

One of the most obvious things to do is to use non-pointer receivers in the struct's methods, but fields can't be exported either, because if not, they can be modified like so:

co.OrderBy = 5
Enter fullscreen mode Exit fullscreen mode

Because field can't be exported, then we need to add a constructor.

🎓 There are no constructor in Go, but people call constructors to functions that starts with New, or Must and returns either the struct, or the struct and an error.

So we can have something like:

type CoffeeOrder struct {
  // size of the ordered coffee.
  size string
  // orderBy user id that ordered the coffee.
  orderBy int
  // orderTime time when the ordered happened.
  orderTime time.Time
}

func NewCoffeeOrder(size string, orderBy int, orderTime time.Time) CoffeeOrder {
  return CoffeeOrder{
    size:      size,
    orderBy:   orderBy,
    orderTime: orderTime,
  }
}
Enter fullscreen mode Exit fullscreen mode

In this case this is enough, but we need to make sure that each field is also immutable. As an example, a value object that uses a slice or a map as an input parameter, needs to make sure that that can't be modified externally (defensive copy).

❓ Is It Now Immutable and Completely Ready?

The answer is not, since the struct is exported, we could skip the instantiation of the struct using the constructor and directly do:

co := CoffeeOrder{}
Enter fullscreen mode Exit fullscreen mode

creating an zero-value struct that is clearly invalid.
To avoid that you could create an interface like:

type (
  CoffeeOrder interface {
    Size() CoffeeSize
    OrderBy() CustomerID
    OrderTime() time.Time
    Points(ref time.Time) CoffeePoints
    x() // method to make sure interface implementation is only defined in this package.
  }

  type coffeeOrder struct {
    // size of the ordered coffee.
    size CoffeeSize
    // orderBy user id that ordered the coffee.
    orderBy CustomerID
    // orderTime time when the ordered happened.
    orderTime time.Time
  }
)
Enter fullscreen mode Exit fullscreen mode

Then the constructor would instantiate coffeeOrder but returns CoffeeOrder.

Key concepts:

  • Interface hides implementation details.
  • Constructor validates business rules.
  • No exported fields prevent direct mutation.
  • Value receivers (CoffeeOrder, not *coffeeOrder) prevent modification.

Using Domain-Specific Types, Not Primitives

Go has the option to create custom types with Type Definition, this allow us to leverage the behaviour of primitive types to give more meaning to our fields.

As an example, we could define a type like:

type CustomerID int
Enter fullscreen mode Exit fullscreen mode

And use it in both our structs, giving more meaning to the field. This custom type allow us to define functions that accept a CustomerID not just a regular int.

And the same goes with the coffee size, allowing us to add new methods to this new type like validating the value.

type CoffeeSize string

// IsValid returns whether the coffee size has a valid value or not.
func (cs CoffeeSize) IsValid() bool {
  switch cs {
    case "small", "medium", "large":
      return true
    default:
      return false
  }
}
Enter fullscreen mode Exit fullscreen mode

💡In general, I recommend to define new types over primitive types, since they give more domain meaning to your struct definitions and function/methods. And allows you to create custom methods that enhance their functionality in a localized way.

Creating Rich Domain Errors

Now, we can change our CoffeeOrder constructor like this:

func NewCoffeeOrder(size CoffeeSize, orderBy CustomerID, orderTime time.Time) (CoffeeOrder, error) {
  if !size.IsValid() {
    return CoffeeOrder{}, errors.New("invalid Coffee Size")
  }
  return CoffeeOrder{
    size:      size,
    orderBy:   orderBy,
    orderTime: orderTime,
  }, nil
}
Enter fullscreen mode Exit fullscreen mode

We are checking whether the value given by the user is a correct coffee size or not, and if not we return an error.

In this case we are creating an error directly, but the error itself does not contain any meaning, just a string with the error message.

I also recommend to create custom errors that give enough context to the caller to understand what happened.
We could create something like:

var _ error = new(WrongCoffeeSizeError)

type WrongCoffeeSizeError struct {
  Size string
}

func (e WrongCoffeeSizeError) Error() string {
  return fmt.Sprintf("invalid coffee size: %s", e.Size)
}
Enter fullscreen mode Exit fullscreen mode

And then, return it like:

if !size.IsValid() {
  return CoffeeOrder{}, WrongCoffeeSizeError{Size: string(size)}
}
Enter fullscreen mode Exit fullscreen mode

Service Layer

Services are used in DDD to orchestrate business operations that are above the domain. In this case, we could create a service to get all the CoffeeOrders in the last 30 days, then get all the Customers of those orders and sort them by points to get the leaderboard.

Practical Tips

Linting

Some of these practices can be enforced with a linter. There are many linters available in Go, but not one specialized for DDD, so I decided to create one that I use in my projects to make sure that I check patterns like:

  • Immutability for Value objects.
  • Custom types over primitives for Entities.
  • Pointer and non-pointer receivers for Entities and Value objects.
  • No errors.New used in returning a method call.

It's still under heavy development, but hopefully it can help you to create more elegant objects.
Here is the link to the repository godddlint in case you are interested.

Summary

In this article we covered several DDD concepts but also other concepts like:

  • Immutability.
  • Create domain-specific types for compile-time safety.
  • Defining new domain errors.

What patterns have you found effective for DDD in Go? Share your experiences in the comments!

Top comments (0)