Domain Driven Design in Go: Value Objects

Dennis Vis
mei 4 2023
10 min read
In an ideal world non-technical colleagues would be able to read our code and quickly recognise the concepts, rules and processes that make up the business. We might never reach such an ideal but I’d argue we should strive for it nonetheless. Value Objects offer one way of getting us closer to that ideal situation.

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.

Value Objects are an important building block of Domain Driven Design. They allow for representing certain business concepts using some data and the behaviour related to this data. They do not have an identity, unlike entities, and are considered equal when their data is equal. Lastly, they should be immutable, meaning their data should not change once instantiated.

In this post, I will show you how to create Value Objects in Go that adhere to these properties and how using them can improve your code.

Type definition

The first thing to do when creating a Value Object in Go is to create a type definition. We don’t want to use any of the base types Go provides, as these do not express the domain very well and cannot be given additional methods. However, we can derive Value Object types from base types. In fact, this is a very common form of Value Object type.

For our example we create the type email.Address, which is derived from string. This makes sense for our initial implementation, which will mostly be a fancy wrapper around a string.

package email

// Address identifies an email box to which messages are delivered.
type Address string

This alone already has some advantages as it’s more expressive. For any variable or field that uses this type it immediately becomes clear to the reader that we’re dealing with an email address. But we can do much more…

Expressing domain logic

When we define a type in Go we can add methods to it. This is ideal for expressing any type of business logic we want to provide for the data that the Value Object contains.

Extending on our email address example we can add a HasDomain method to the Address type.

package email

import "strings"

// Address identifies an email box to which messages are delivered.
type Address string

// HasDomain returns true if the [Address] is part of the given
// email domain.
func (a Address) HasDomain(domain string) bool {
    split := strings.Split(string(a), "@")
    return len(split) == 2 && split[1] == domain
}

And this is how we would use it:

if someEmailAddress.HasDomain("example.com") {
    fmt.Printf(
        "email address %q is in the target domain\n",
        string(myEmail),
    )
}

Even though we’re still working with what is essentially just a string, we managed to add behaviour to it using a method. Not only is this a great way of collecting email address related logic in a single place, it also makes for code that reads naturally.

Using structs

If the HasDomain method is called a lot, and the constant string splitting turns out to be detrimental to performance, for instance, we could choose to make an optimisation. These kinds of optimisations should generally be based on benchmarks, for which Go has built-in support. I didn’t benchmark this particular case but for the sake of this example let’s say I did and saw a clear opportunity for optimisation.

To avoid the constant splitting, we could store the domain part somehow. We’d want this data to be a part of the type, as to not have to store parts of an address in different locations, that would become a mess quickly. We can’t use a type derived from string for this. What we need is a struct instead.

Structs are a great way of expressing more complex Value Objects. Many Value Objects will consist of different fields or even other Value Objects. Since structs are simply holders of values, they can easily adhere to the Value Object requirements when used in the proper way.

Here’s an example of how the email.Address type could be changed into a struct type.

package email

// Address identifies an email box to which messages are delivered.
type Address struct {
    domain    string
    localPart string
}

// HasDomain returns true if the [Address] is part of the given
// email domain.
func (a Address) HasDomain(domain string) bool {
    return a.domain == domain
}

Notice how the fields localPart and domain start with a lowercase, meaning they are not exposed outside of the email package. This means setting them and reading them needs to happen within the email package. The email package, thus, becomes responsible for them and encapsulates their logic. Clients of the email package don’t need to known about this implementation detail, they can still call the methods the exact same way as before.

Constructor functions

Because we’ve made the fields of the email.Address type private to the email package, there is now no way for clients of the email package to initialise them. When clients use literal struct initialisation (e.g. email.Address{}) the fields will always be empty strings. In this case, the zero value of our type isn’t very useful either. We need to provide clients with a way to create fully initialised email.Address instances.

In these situations you should use a constructor function. These typically consist of the word New followed by the name of the type being constructed and contain the logic for initialising a new instance of that type. For example:

// NewAddress creates a new instance of [Address] with the given
// domain and local part.
func NewAddress(domain, localPart string) Address {
    a := Address{
        domain:    domain,
        localPart: localPart,
    }

    return a
}

In the case of an email address it’s not likely that clients will find it convenient to always have to provide the domain and local part separately. It’s much more likely that they’ll want to provide the email address in string form. This calls for another constructor function. This function needs to parse the given string as an email address. That’s both a bit different than regular initialisation and could also fail. It’s a good idea to reflect both facts in the function signature:

package email

import (
    "fmt"
    "net/mail"
    "strings"
)

// ParseAddress attempts to extract an [Address] from a given string.
func ParseAddress(address string) (Address, error) {
    m, err := mail.ParseAddress(address)
    if err != nil {
        return Address{}, fmt.Errorf("email: %w", err)
    }

    split := strings.Split(m.Address, "@")

    a := Address{
        domain:    split[1],
        localPart: split[0],
    }

    return a, nil
}

Now clients have two ways of creating an instance of email.Address. Both of which make sure the domain and localPart fields are initialised without making the client aware these even exist.

Passing by value

As you might have noticed in the code examples; the methods of- and constructors for the Address type use address values, not pointers. This is intentional. In Go variables can be passed by reference (a pointer), or by value (a copy). In the latter case the receiver of the variable cannot mutate the original value, only its local copy. This, in effect, makes the variable immutable. For Value Objects, that’s exactly what we want!

Using value-semantics consistently ensures any interested party can consult the data within the Value Object but cannot mutate the original value.

Adding common helper methods

Aside from the methods that contain domain logic, there are a few additional methods that are often a good idea to add.

When deriving a type from a base type it’s often convenient to provide a method that returns the Value Object as that type. For instance; when deriving a type from int32, it’s convenient to create a method on it called Int32 which returns the data as an int32. For example:

// Count holds a counter of something.
type Count int32

// Int32 returns the value of [Count] as int32.
func (c Count) Int32() int32 {
    return int32(c)
}

Although this is a minor change and you could easily opt for using the cast directly, it’s often convenient when mapping from a complex Value Object, or entity, to another projection of the data. For instance; when mapping from the domain layer to the infrastructure layer, before persisting the data.

Another very common method is the String method. Implementing this method means the type has implemented the ‘Stringer’ interface (through Go’s implicit interface implementation). Many packages look for implementations of this interface to create a string representation of a given variable. For instance; the fmt package uses it to replace %s entries in a format string. Adding a String method to a Value Object is a good practice to make sure the string representation is correct and informative. For example:

package email

// Address identifies an email box to which messages are delivered.
type Address struct {
    domain    string
    localPart string
}

// String returns a string representation of an [Address].
func (a Address) String() string {
    return a.localPart + "@" + a.domain
}

Adding validation

Nearly all business logic contains some form of validation. We often need to check if data is in a state that is allowed and/or expected. Adding validation logic to Value Objects is a great way of keeping this logic close to the data and allowing for more natural reading code.

To my knowledge, there does not exist a similarly broadly supported interface for validation as there is for Stringer. The closest thing I’ve found is the Validate() error method which the Golang ProtoBuf Validator Compiler produces. This is supported in multiple projects, although they’re all related to protocol buffers. That being said, I do think this signature will properly serve most validation use cases. Therefore, I advise to follow it unless your specific use case really doesn’t fit.

Getting back to our email example; we might want to either remove the NewAddress constructor, add validation to its implementation or add a validate method to the email.Address type. This is because, as it stands, no checks are done to make sure the domain and local part contain valid values. The answer to which option is the best one is, as always, ‘it depends’. For the sake of our example, let’s say adding the validate method makes the most sense for our use case. Its implementation could look like the following:

package email

import (
    "fmt"
    "net/mail"
)

// Address identifies an email box to which messages are delivered.
type Address struct {
    domain    string
    localPart string
}

// String returns a string representation of an [Address].
func (a Address) String() string {
    return a.localPart + "@" + a.domain
}

// Validate verifies the validity of the [Address].
// The returned error is nil if the [Address] is valid or contains
// the validation error if it's not.
func (a Address) Validate() error {
    _, err := mail.ParseAddress(a.String())
    if err != nil {
        return fmt.Errorf("email: %w", err)
    }

    return nil
}

Then it would be used like so:

err := someEmailAddress.Validate()
if err != nil {
    return fmt.Errorf("invalid email address: %w", err)
}

Asserting equality

As mentioned at the beginning of this post; Value Objects are considered equal when their values are equal. This is also precisely the case for values in Go. Comparing any of the base types, types derived from base types or struct types using the == operator will return true if the values are equal.

Try it out for yourself at the Go Playground.

Conclusion

As you’ve seen; Go has excellent support for Value Objects:

  • Type definitions allow us to be more expressive and add additional domain logic to each type
  • Passing around copies of Value Objects means they’ll be immutable
  • Common helper methods can be added which make mapping to and from the domain layer straightforward
  • Leveraging packages facilitates domain logic encapsulation
  • Equality checks work as expected

When working in the domain layer make generous use of Value Objects. But feel free to apply them anywhere else too! When implemented properly they can have a profound, positive effect on code readability and structure.