Domain Driven Design in Go: Value Objects
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.