DEV Community

Cover image for Type-Driven Domain Design in Go: Encoding Invariants at Compile Time
Gabriel Anhaia
Gabriel Anhaia

Posted on

Type-Driven Domain Design in Go: Encoding Invariants at Compile Time


You ship a code path that calls order.Pay(). The ticket goes out. Three weeks later, an alert fires: a draft order is sitting in the payments table marked PAID. It was never submitted. It was never reviewed. It never went to fulfilment. You read the code. There is a guard at the top of Pay: if o.Status != "submitted" { return ErrInvalidState }. It worked. It returned the error. Nobody checked the return value.

The bug is not in Pay. The bug is that Pay accepted an Order in the first place. An order in draft status should not have been a candidate for payment at the type level. The fact that Pay had to inspect a string field at runtime to find out is the symptom.

This post is about pushing that decision earlier: into the type system, where the compiler refuses to even build code that calls Pay on a draft. Three patterns. Each one moves a class of runtime check into a compile error.

Pattern 1: Parse, Don't Validate

The framing here comes from Alexis King's 2019 post Parse, don't validate. The starting point is the value-object idea taken to its logical end: if a function needs a valid email, it should accept a type whose existence is proof of validity. Validation is a one-time event at the system boundary. After that, the type carries the guarantee.

package domain

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

type ValidEmail struct {
    address string // unexported
}

func NewEmail(raw string) (ValidEmail, error) {
    if raw == "" {
        return ValidEmail{}, errors.New(
            "email: cannot be empty",
        )
    }
    if _, err := mail.ParseAddress(raw); err != nil {
        return ValidEmail{}, fmt.Errorf(
            "email: %q invalid: %w", raw, err,
        )
    }
    return ValidEmail{address: raw}, nil
}

func (e ValidEmail) String() string {
    return e.address
}
Enter fullscreen mode Exit fullscreen mode

The unexported address field is the lock. No code outside the domain package can build a ValidEmail directly. The only door is NewEmail, and that door validates. From there, every function that takes ValidEmail is freed from re-checking:

// mailer is whatever transport the app injects.
func SendWelcome(
    mailer Mailer, to ValidEmail, name string,
) error {
    // 'to' is valid by construction. No checks here.
    body := fmt.Sprintf("Hello %s", name)
    return mailer.Send(to.String(), body)
}
Enter fullscreen mode Exit fullscreen mode

The shift is small in code and large in design. A string is a value that might be valid. A ValidEmail is valid by construction. The type system tracks the proof for you. The function signature becomes documentation that the compiler enforces.

This pattern composes. Build ValidEmail, PositiveAmount, Currency, CustomerID, then assemble them into a parsed request struct at the HTTP boundary. The handler is the only place where validation runs. Everything inward operates on parsed types. No defensive checks scattered across twelve files.

The boundary is the interesting part. Most codebases sprinkle validation across every layer "just in case." With parse-don't-validate, validation happens once, at the edge, and the type carries the result inward. Twelve files of defensive checks become one constructor.

Pattern 2: States as Types

The Pay-on-a-draft bug is a different shape of the same problem. The order has a lifecycle: Draft → Submitted → Paid → Fulfilled → Cancelled. Each state has its own legal operations. Most teams encode this as a single Order struct with a Status string and runtime guards. Scott Wlaschin's Domain Modeling Made Functional is the canonical write-up of the alternative — make illegal states unrepresentable in the type system itself.

// The fragile version. Every method checks status.
type Order struct {
    ID     string
    Status string // "draft", "submitted", "paid"
    Lines  []Line
}

func (o *Order) Pay() error {
    if o.Status != "submitted" {
        return ErrInvalidState
    }
    o.Status = "paid"
    return nil
}
Enter fullscreen mode Exit fullscreen mode

This compiles for any Order, regardless of state. The type system gives you nothing. Every state transition is a if status != X check that someone forgot to call, or called and ignored.

The type-driven version uses distinct types for distinct states. Operations only exist on the states where they are legal. A DraftOrder has no Pay method at all. Calling it does not compile.

package domain

import "errors"

// Line is the line-item type defined elsewhere in the package.
type DraftOrder struct {
    id    string
    lines []Line
}

type SubmittedOrder struct {
    id    string
    lines []Line
}

type PaidOrder struct {
    id        string
    lines     []Line
    paymentID string
}

func NewDraft(id string) DraftOrder {
    return DraftOrder{id: id}
}

func (d DraftOrder) AddLine(l Line) DraftOrder {
    d.lines = append(d.lines, l)
    return d
}

func (d DraftOrder) Submit() (SubmittedOrder, error) {
    if len(d.lines) == 0 {
        return SubmittedOrder{}, errors.New(
            "order: cannot submit empty draft",
        )
    }
    return SubmittedOrder{
        id: d.id, lines: d.lines,
    }, nil
}

func (s SubmittedOrder) Pay(
    paymentID string,
) (PaidOrder, error) {
    if paymentID == "" {
        return PaidOrder{}, errors.New(
            "order: paymentID required",
        )
    }
    return PaidOrder{
        id:        s.id,
        lines:     s.lines,
        paymentID: paymentID,
    }, nil
}
Enter fullscreen mode Exit fullscreen mode

Submit is a method on DraftOrder and returns a SubmittedOrder. Pay is a method on SubmittedOrder and returns a PaidOrder. The compiler now enforces the lifecycle. This will not build:

draft := NewDraft("o-123")
paid, err := draft.Pay("pay-456") // compile error
Enter fullscreen mode Exit fullscreen mode

The error message names the missing method on DraftOrder. There is no path to ship the bug. Reviewers do not need to scan for if Status != checks because there are none. The state machine lives in the function signatures.

A function that wants to operate on any order regardless of state can still do so, through an interface:

type Order interface {
    OrderID() string
}

func (d DraftOrder) OrderID() string     { return d.id }
func (s SubmittedOrder) OrderID() string { return s.id }
func (p PaidOrder) OrderID() string      { return p.id }
Enter fullscreen mode Exit fullscreen mode

A function with Order as its parameter type works on any of the three. A function with SubmittedOrder as its parameter type works on exactly one. The narrowness is the point. Narrower types accept fewer inputs and ship fewer bugs.

There is a cost: persistence is more verbose. Loading from the database means switching on a discriminator column and returning the matching type. That code lives in the repository adapter. Inside the domain, the win is permanent: the compiler enforces the lifecycle on every build.

Pattern 3: Phantom Types for Unit Safety

The third pattern handles a class of bug that has crashed actual spacecraft: mixing units that share a numeric representation. Meters and feet are both float64. Cents and dollars are both int64. Seconds and milliseconds are both int64. The compiler cannot tell them apart unless you teach it.

Phantom types are a generic type parameter that is never actually used in the value — it only exists at the type level, to mark a value as belonging to a particular dimension or unit. Go added generics in 1.18, which makes this pattern cleaner than the older "newtype per unit" approach.

package units

type Meters struct{}
type Feet struct{}

// U is a phantom tag — only the type checker sees it.
// It has no runtime presence and is intentionally unused
// inside the struct.
type Length[U any] struct {
    value float64
}

func NewLength[U any](v float64) Length[U] {
    return Length[U]{value: v}
}

func (l Length[U]) Value() float64 {
    return l.value
}

func (l Length[U]) Add(other Length[U]) Length[U] {
    return Length[U]{value: l.value + other.value}
}
Enter fullscreen mode Exit fullscreen mode

The U parameter never shows up in the runtime representation. It is purely a tag the compiler uses to keep two Length values apart. Length[Meters] and Length[Feet] are different types. You cannot add them, assign them, or pass one where the other is expected.

m := NewLength[Meters](10)
f := NewLength[Feet](5)

total := m.Add(f) // compile error: type mismatch
Enter fullscreen mode Exit fullscreen mode

If you want to convert, you write the conversion explicitly. That is the whole point — the conversion is a real piece of code with a real factor, not an implicit cast that the team has to remember to apply:

func FeetToMeters(
    f Length[Feet],
) Length[Meters] {
    return NewLength[Meters](f.Value() * 0.3048)
}
Enter fullscreen mode Exit fullscreen mode

Now the conversion is a function call you can grep for, audit, and test. The unit boundary is explicit. Code that mixes meters and feet either converts or does not compile.

The same pattern fits anywhere units would otherwise live in variable names and code review:

type Cents struct{}
type Dollars struct{}

type Money[C any] struct {
    amount int64
}

type Seconds struct{}
type Milliseconds struct{}

type Duration[T any] struct {
    value int64
}
Enter fullscreen mode Exit fullscreen mode

A function that takes Money[Cents] cannot accept Money[Dollars]. A function that takes Duration[Milliseconds] cannot accept Duration[Seconds]. The class of bug where someone passes 5000 ms to a function expecting seconds and the system sleeps for 83 minutes is gone at compile time. The conversion functions still need tests; the mixing bug cannot ship.

The cost is a small amount of generics ceremony. The win is that an entire category of unit-mixing bugs cannot ship.

When the Compiler Is Not Enough

Type-driven design is not a silver bullet, and pretending otherwise is how teams end up with type systems they hate.

Some invariants cannot be encoded in Go's type system without contortion. "This Order was created in the same database transaction as this Customer" is not expressible as a type. Cross-aggregate invariants, temporal constraints, and rules that depend on external state still need runtime checks.

The zero-value problem from Pattern 1 still applies: var e ValidEmail compiles and gives you a ValidEmail whose address is the empty string. Inside the domain layer, where every value flows through a constructor, this is a non-issue. At trust boundaries (JSON unmarshaling, struct literals from outside the package), an IsZero check or a custom UnmarshalJSON closes the gap.

Phantom types add a small cognitive load. Engineers reading the code for the first time need to understand that Length[Meters] and Length[Feet] are different types because of the type parameter, not because of the data. A short comment at the type definition usually settles it.

Encode the invariants where the cost of a runtime miss is highest. Money, identifiers, lifecycle states, and units are usually worth it. Free-form text fields rarely are.

The Pattern Behind the Patterns

All three patterns share a shape. There is a class of value (a valid email, a submitted order, a length in meters) that is more constrained than its raw representation. Build a type whose existence carries the constraint. Make construction the only path that can produce the type. From then on, the constraint is free. The compiler tracks it for you, and every function signature carries the proof.

The runtime check you delete is real code that someone has to write, test, and remember to call. The compile error you get instead is one the build pipeline catches before the PR opens. Construction at the boundary gets slightly heavier; the domain layer becomes harder to misuse.

Stop asking what to validate. Ask what to parse, and what the type system can remember for you.


If this was useful

Type-driven domain design is the connective tissue between value objects, aggregates, and ports in hexagonal architecture. The cleaner your domain types, the cleaner the contracts your adapters have to satisfy. Hexagonal Architecture in Go covers the full picture: aggregate boundaries, repository contracts that return parsed types, error translation across layers, and how all of it wires together in main().

The companion book, The Complete Guide to Go Programming, covers the language foundations — the type system, generics, error handling, and the design choices that make patterns like these feel natural in Go rather than bolted on.

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

Top comments (0)