- Book: Hexagonal Architecture in Go
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
Someone passes an empty string where an email address was expected. The function three layers down tries to send a welcome message. The SMTP server rejects it. The error bubbles up through four stack frames, each one adding context that has nothing to do with the root cause: the email was never valid in the first place.
Or the price that somehow became negative. Or the user ID that was actually a product ID because both are string. Or the currency amount stored as float64 that lost a cent somewhere between multiplication and rounding.
These bugs share the same origin. Your domain accepted raw primitives and hoped downstream code would validate them. It did not.
Value objects fix this. They are types that represent a domain concept, carry their own validation, and make invalid state impossible to construct. Go does not have classes, but it has everything you need to build value objects that are stricter than anything you would write in Java or C#.
Four patterns, each solving a different shape of the problem.
Pattern 1: Constructor Validation with Unexported Fields
This is the foundation. An unexported struct field forces every caller through your constructor. No constructor call, no instance.
package domain
import (
"errors"
"fmt"
"net/mail"
)
type Email struct {
address string // unexported — no direct access
}
func NewEmail(raw string) (Email, error) {
if raw == "" {
return Email{}, errors.New(
"email: cannot be empty",
)
}
_, err := mail.ParseAddress(raw)
if err != nil {
return Email{}, fmt.Errorf(
"email: %q is not valid: %w", raw, err,
)
}
return Email{address: raw}, nil
}
func (e Email) String() string {
return e.address
}
The address field starts with a lowercase letter. Code outside the domain package cannot set it. The only way to get an Email is through NewEmail, which validates. Once you have an Email, you know it is valid. Every function that accepts Email instead of string gets that guarantee for free.
func SendWelcome(to Email, name string) error {
// No need to validate 'to' — the type guarantees it.
msg := fmt.Sprintf("To: %s\nHello %s", to, name)
return smtp.Send(msg)
}
Compare that to func SendWelcome(to string, name string). With a bare string, you either validate again (duplicated logic) or you trust the caller (a bet you will lose).
There is one edge case to know about. Go's zero value for a struct with unexported string fields is the struct with an empty string. That means var e Email compiles and gives you an Email with an empty address. You cannot prevent this at the language level. What you can do is check for it:
func (e Email) IsZero() bool {
return e.address == ""
}
Any function that receives an Email from an untrusted boundary (say, deserializing from JSON) should call IsZero. But inside your domain layer, where every Email was constructed through NewEmail, the zero-value problem disappears.
Pattern 2: Named Types with Methods
Not every value object needs a constructor. Sometimes the type system alone is enough to prevent mixing up two concepts that happen to share a Go primitive.
package domain
type Money int64 // cents, not dollars
func (m Money) Add(other Money) Money {
return m + other
}
func (m Money) Subtract(other Money) Money {
return m - other
}
func (m Money) Multiply(quantity int) Money {
return m * Money(quantity)
}
func (m Money) IsNegative() bool {
return m < 0
}
The String method handles the formatting:
func (m Money) String() string {
dollars := m / 100
cents := m % 100
if cents < 0 {
cents = -cents
}
return fmt.Sprintf("$%d.%02d", dollars, cents)
}
There is no constructor here, and that is fine. Money(0) is valid. Money(1599) means $15.99. The value of this pattern is not validation — it is making the compiler catch category errors.
type UserID int64
type ProductID int64
func FindUser(id UserID) (*User, error) { ... }
func FindProduct(id ProductID) (*Product, error) { ... }
Try calling FindUser(productID) where productID is a ProductID. The compiler rejects it. No runtime check needed. No test needed. The bug cannot exist.
This matters more than it sounds. Teams routinely lose hours tracing bugs where an order ID gets passed to a function expecting a customer ID. Both are int64. Both are valid integers. The query returns zero rows, the code treats zero rows as "customer not found," and the user gets a 404 on their order confirmation page. A named type would have caught it at compile time.
This fits when:
- The underlying type is already valid in all its values (or you only care about a subset you can check cheaply).
- The main goal is to prevent accidentally swapping two values of the same underlying type.
- You want domain methods on the type (
Add,IsNegative,String).
Pattern 3: Functional Options for Complex Value Objects
Some value objects have many fields, several of which are optional. A constructor with eight parameters is unreadable. A constructor that takes a config struct pushes validation somewhere else. Functional options let you validate at construction time while keeping the API clean.
package domain
type Address struct {
line1 string
line2 string
city string
state string
zip string
country string
}
type AddressOption func(*Address) error
Each option function validates its own input and sets a single field. Required fields reject empty strings; optional ones pass through:
func WithLine1(v string) AddressOption {
return func(a *Address) error {
if v == "" {
return errors.New(
"address: line1 cannot be empty",
)
}
a.line1 = v
return nil
}
}
func WithLine2(v string) AddressOption {
return func(a *Address) error {
a.line2 = v // optional, no validation
return nil
}
}
WithCity, WithZip, and WithCountry follow the same shape as WithLine1 (reject empty). WithState follows the same shape as WithLine2 (optional, pass through).
The constructor runs every option, then enforces the required-field invariants:
func NewAddress(opts ...AddressOption) (Address, error) {
var a Address
for _, opt := range opts {
if err := opt(&a); err != nil {
return Address{}, err
}
}
if a.line1 == "" {
return Address{}, errors.New(
"address: line1 is required",
)
}
if a.city == "" {
return Address{}, errors.New(
"address: city is required",
)
}
if a.zip == "" {
return Address{}, errors.New(
"address: zip is required",
)
}
if a.country == "" {
return Address{}, errors.New(
"address: country is required",
)
}
return a, nil
}
Usage reads like documentation:
addr, err := NewAddress(
WithLine1("123 Main St"),
WithCity("Portland"),
WithState("OR"),
WithZip("97201"),
WithCountry("US"),
)
Each option validates its own input. The constructor validates the whole object after all options run. You get per-field validation, required-field enforcement, and a readable call site — all without a config struct that could be half-filled and passed around.
Use this pattern when:
- The value object has more than 3-4 fields.
- Some fields are optional but others are required.
- Validation rules vary per field (format checks, range checks, conditional requirements).
For simpler value objects, this is overkill. Pattern 1 is better when you have one or two fields.
Pattern 4: Parse, Don't Validate
This one is a design philosophy more than a code pattern. The idea: instead of validating data and then passing around the same raw type, parse it into a richer type that encodes the validation in its structure. You do the work once, and the type system remembers it.
The term comes from a Haskell blog post by Alexis King, but the idea translates directly to Go.
Here is the difference. Validation approach:
func ProcessOrder(
rawEmail string,
rawAmount string,
rawCurrency string,
) error {
if !isValidEmail(rawEmail) {
return errors.New("invalid email")
}
amount, err := strconv.ParseFloat(rawAmount, 64)
if err != nil || amount <= 0 {
return errors.New("invalid amount")
}
if rawCurrency != "USD" && rawCurrency != "EUR" {
return errors.New("unsupported currency")
}
// Now use rawEmail (string), amount (float64),
// rawCurrency (string)...
// Nothing stops the next developer from skipping
// the checks above and using the raw values directly.
return nil
}
Parse approach:
type Currency string
const (
USD Currency = "USD"
EUR Currency = "EUR"
)
func ParseCurrency(raw string) (Currency, error) {
switch raw {
case "USD":
return USD, nil
case "EUR":
return EUR, nil
default:
return "", fmt.Errorf(
"currency: %q is not supported", raw,
)
}
}
Now the parse function composes these types into a single validated request:
type OrderRequest struct {
Email Email // from Pattern 1
Amount Money // from Pattern 2
Currency Currency // parsed, not validated
}
func ParseOrderRequest(
rawEmail string,
rawAmount string,
rawCurrency string,
) (OrderRequest, error) {
email, err := NewEmail(rawEmail)
if err != nil {
return OrderRequest{}, fmt.Errorf(
"parsing order: %w", err,
)
}
cents, err := parseAmountToCents(rawAmount)
if err != nil {
return OrderRequest{}, fmt.Errorf(
"parsing order: %w", err,
)
}
if cents <= 0 {
return OrderRequest{}, errors.New(
"parsing order: amount must be positive",
)
}
currency, err := ParseCurrency(rawCurrency)
if err != nil {
return OrderRequest{}, fmt.Errorf(
"parsing order: %w", err,
)
}
return OrderRequest{
Email: email,
Amount: Money(cents),
Currency: currency,
}, nil
}
func parseAmountToCents(raw string) (int64, error) {
f, err := strconv.ParseFloat(raw, 64)
if err != nil {
return 0, fmt.Errorf(
"amount: %q is not a number: %w",
raw, err,
)
}
return int64(math.Round(f * 100)), nil
}
Now ProcessOrder takes an OrderRequest, not three strings:
func ProcessOrder(req OrderRequest) error {
// req.Email is guaranteed valid
// req.Amount is in cents, guaranteed positive
// req.Currency is guaranteed to be USD or EUR
// No validation needed here. The types carry it.
return nil
}
The parse function is the boundary. It sits at the edge of your system — the HTTP handler, the CLI argument parser, the message queue consumer. Everything inside the boundary works with parsed types. No re-validation. No defensive checks scattered through 12 files. No "I hope the caller checked this."
This is where the four patterns combine. Pattern 1 gives you Email. Pattern 2 gives you Money. Pattern 4 composes them into a parsed request object. Pattern 3 is there when the request is complex enough to need it.
Where It Breaks Down
Value objects are not free. There are trade-offs to keep in mind.
Serialization friction. JSON unmarshaling into a struct with unexported fields requires a custom UnmarshalJSON method. This is boilerplate, but it is also an opportunity — you validate during deserialization, which is exactly where you want it.
func (e *Email) UnmarshalJSON(
data []byte,
) error {
var raw string
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
parsed, err := NewEmail(raw)
if err != nil {
return err
}
*e = parsed
return nil
}
Zero-value awkwardness. As mentioned, var e Email gives you a zero-value Email that bypasses the constructor. You cannot prevent this. You can mitigate it with IsZero checks at trust boundaries, and you can document the invariant. In practice, this is rarely a problem inside a well-structured domain layer where all construction goes through factories.
Over-engineering small things. Not every string needs to be a value object. If a field is truly free-form text (a user's bio, a comment body), wrapping it adds cost with no benefit. Use value objects for domain concepts with rules: emails, money, coordinates, identifiers, time ranges, percentages.
The Decision Checklist
When you are staring at a string or int64 parameter in your domain code, ask yourself:
-
Does this value have rules? If an email must be RFC 5322 compliant, it is not a string. It is an
Email. -
Could this value be confused with another value of the same type? If
userIDandorderIDare bothstring, they will get swapped. Make them distinct types. - Does this value have operations? If you add two money amounts or compare two dates, the operations belong on a type, not in loose functions.
- Is this value crossing a boundary? HTTP input, database output, message queue payload — these are parsing opportunities. Convert raw data to domain types at the boundary.
If the answer to any of these is yes, you have a value object waiting to be born.
If this was useful
These four patterns come up in nearly every chapter of my book on hexagonal architecture in Go. Domain modeling is where hex-arch starts — your ports and adapters are only as clean as the types they carry. If the idea of making invalid state unrepresentable resonated with you, the book goes much deeper: entity design, aggregate boundaries, repository contracts, error translation across layers, and how all of it wires together in main().
The Thinking in Go series is two books. The first covers the language from the ground up. The second covers how to architect with it.

Top comments (0)