DEV Community

Cover image for Value Objects in Go: Making Invalid Domain State Unrepresentable
Gabriel Anhaia
Gabriel Anhaia

Posted on

Value Objects in Go: Making Invalid Domain State Unrepresentable


You ship a User struct with a string email field. The HTTP
handler validates the format before saving. Good enough.

Three months later the same email arrives through four other
doors. A CSV importer. An admin tool. A webhook from the billing
provider. A migration script that backfills old rows. Each one
forgot to validate, or validated differently. Now your users
table has " Bob@EXAMPLE.com ", an empty string, and one row
where someone pasted a full mailto link.

The bug is not the missing check in the importer. The bug is
that string let you build a User that should never have
existed. The type system knew nothing about what a valid email
is, so every caller had to remember the rule. Most did not.

A value object closes that door. You make a type whose only
legal way to exist is through a constructor that validates.
Once you hold one, it is valid. Not "probably valid because the
handler checked" — valid by construction.

The zero-value trap

Go hands you a working value for every type without asking. A
struct{} is born filled with zeros. That is convenient for
counters and accumulators. For a domain type it is a trap.

type Email struct {
    Value string
}

var e Email          // e.Value == ""
u := User{Email: e}  // compiles, saves, breaks later
Enter fullscreen mode Exit fullscreen mode

Email{} is a valid Go value and a meaningless domain value.
The empty string is not an email. Nothing stopped you from
creating it, passing it, persisting it. The exported field is
the leak: anyone can write Email{Value: "garbage"} and skip
every rule you wrote.

So the first move is to take the field away from them.

Unexported field, validating constructor

Lowercase the field. Now no package outside this one can set it.
The only entry point is a constructor that refuses to return a
value for bad input.

package valueobject

import (
    "errors"
    "regexp"
    "strings"
)

var emailRe = regexp.MustCompile(
    `^[^@\s]+@[^@\s]+\.[^@\s]+$`,
)

type Email struct {
    value string
}

func NewEmail(raw string) (Email, error) {
    norm := strings.ToLower(strings.TrimSpace(raw))
    if norm == "" {
        return Email{}, errors.New("email is empty")
    }
    if !emailRe.MatchString(norm) {
        return Email{}, errors.New("email is malformed")
    }
    return Email{value: norm}, nil
}

func (e Email) String() string {
    return e.value
}
Enter fullscreen mode Exit fullscreen mode

Two things happened here. The field is value, lowercase, so
no other package writes it directly. And NewEmail does the
normalization once, in one place: trim the spaces, lowercase
the domain. The " Bob@EXAMPLE.com " problem is gone because
the only way in runs TrimSpace and ToLower first.

A caller now reads like this:

email, err := valueobject.NewEmail(raw)
if err != nil {
    return fmt.Errorf("invalid email: %w", err)
}
user := User{Email: email}
Enter fullscreen mode Exit fullscreen mode

The handler validates. The importer validates. The migration
script validates. Not because anyone remembered, but because
there is no other way to get an Email. The check moved from
"every caller's responsibility" to "the type's responsibility,"
and the type never forgets.

The zero-value still leaks one way

There is a gap. Even with an unexported field, another package
in the same module can still write Email{} — the zero value
is always reachable inside the declaring package, and a struct
literal with no fields set is legal everywhere.

var e valueobject.Email   // still works, e is empty
Enter fullscreen mode Exit fullscreen mode

You cannot stop the zero value from existing in Go. What you can
do is make it detectable and reject it at the boundary where it
matters: when you read the value back out, or when you persist.

func (e Email) IsZero() bool {
    return e.value == ""
}
Enter fullscreen mode Exit fullscreen mode

Then the repository refuses to save a zero email:

func (r *UserRepo) Save(u User) error {
    if u.Email.IsZero() {
        return errors.New("user has no valid email")
    }
    // ... persist
}
Enter fullscreen mode Exit fullscreen mode

The constructor guarantees that a non-zero Email is valid. The
IsZero check guarantees a zero one never reaches the database.
Between the two, the invalid state has nowhere to live.

Money: where scattered checks really hurt

Email is the easy example. Money is where ad-hoc validation
turns into outages. People store money as a float64, add two
amounts in different currencies, or let a negative price through
because the discount logic underflowed.

A money value object carries the amount in minor units (cents)
as an integer, carries its currency, and refuses the operations
that do not make sense.

package valueobject

import (
    "errors"
    "fmt"
)

type Money struct {
    cents    int64
    currency string
}

func NewMoney(cents int64, currency string) (Money, error) {
    if len(currency) != 3 {
        return Money{}, errors.New(
            "currency must be ISO 4217 (3 letters)",
        )
    }
    return Money{
        cents:    cents,
        currency: currency,
    }, nil
}
Enter fullscreen mode Exit fullscreen mode

The integer choice removes the floating-point class of bug
before it starts. 0.1 + 0.2 is not 0.3 in float64; 10 + 20
cents is always 30. The currency stays attached to the amount,
which is what lets the next method exist.

func (m Money) Add(other Money) (Money, error) {
    if m.currency != other.currency {
        return Money{}, fmt.Errorf(
            "cannot add %s to %s",
            other.currency, m.currency,
        )
    }
    return Money{
        cents:    m.cents + other.cents,
        currency: m.currency,
    }, nil
}
Enter fullscreen mode Exit fullscreen mode

Adding USD to EUR is now a compile-time-shaped runtime
error you cannot ignore, because Add returns an error and
the result is unusable until you handle it. Compare that to two
loose int64 variables: total := usd + eur compiles fine and
ships a wrong number to a customer.

You decide per domain whether negative money is legal. A refund
is negative. A product price is not. Push that rule into a named
constructor so the meaning is in the type, not in a comment.

func NewPrice(cents int64, cur string) (Money, error) {
    if cents < 0 {
        return Money{}, errors.New("price cannot be negative")
    }
    return NewMoney(cents, cur)
}
Enter fullscreen mode Exit fullscreen mode

NewPrice and NewMoney return the same type but carry
different rules. A Money returned by NewPrice is guaranteed
non-negative. The caller asking for a price gets the price rule
for free.

Why this beats scattered checks

Think about where validation lives in the two designs.

With raw string and float64, the rule lives in every place
that builds the value. The HTTP handler. The importer. The
admin tool. The test fixtures. You audit correctness by reading
every call site and hoping you found them all. New code paths
are new chances to forget.

With a value object, the rule lives in one constructor. You
audit correctness by reading one function. A new call site
cannot skip the rule, because the rule is the only door in. The
type does not have a "valid" mode and an "unchecked" mode — it
has one mode, and the compiler will not let you reach it any
other way.

This is the line worth keeping: a value object turns a runtime
question ("is this email valid?") into a structural fact ("I am
holding an Email, therefore it is valid"). The cost is one small
type and one constructor per concept. The payoff is that the
"someone forgot to validate" bug stops being possible.

Where they fit in a hexagonal layout

Value objects live in the domain, next to your entities and
aggregates. The domain defines Email, Money, OrderID,
Quantity — the small types that carry the business rules of
what a valid value is.

The adapters do the translation at the edge. A Postgres adapter
reads a text column and calls NewEmail to bring it back into
the domain. An HTTP adapter reads a JSON string and does the
same. The constructor sits exactly on the boundary between the
outside world's loose strings and the domain's validated types.
Everything inside the boundary can assume the value is good,
because nothing crossed the boundary without going through the
constructor.

That is the whole point of the pattern. The edge is where you do
not trust the data. The domain is where you do. The value object
is the gate between them, and it only opens for valid input.


If you want the longer version of this, the spine of
Hexagonal Architecture in Go covers value objects, entities,
and aggregates wired through ports and adapters, with the
persistence and HTTP edges doing the translation. It walks the domain
layer from these small validated types up to the application
services that orchestrate them, and The Complete Guide to Go
Programming
covers the language pieces underneath (constructors,
error wrapping, the zero value) that make the pattern hold.

Thinking in Go — the 2-book series on Go programming and hexagonal architecture

Top comments (0)