DEV Community

Cover image for Errors as Domain Concepts: Typed Errors That Carry Meaning in Go
Gabriel Anhaia
Gabriel Anhaia

Posted on

Errors as Domain Concepts: Typed Errors That Carry Meaning in Go


You've seen this in a code review. A handler returns pq: duplicate key value violates unique constraint "users_email_key" to the client as a 500. The frontend dev opens a ticket, the on-call engineer writes a Slack message. The fix is a string match against a Postgres error code, copied into the handler. Three months later somebody migrates to MySQL and the string match silently rots. The 500 comes back.

The problem isn't the database driver. The problem is that an infrastructure error crossed the domain boundary and reached the client untranslated. In a hex/DDD service, that should never happen. The driver speaks Postgres; the domain speaks business. The adapter translates between them, and only domain errors cross inward.

This is the typed-error pattern that makes that boundary real in Go. Sentinels for the simple cases, typed errors with structured fields for the rich cases. %w to wrap, errors.Is and errors.As to inspect, and an adapter layer that translates pq.Error into ErrUserAlreadyExists so nothing downstream has to care which database you picked.

Domain errors are part of the contract

When you model an Order aggregate, the rules of the order are the contract: what you can do to it, and what fails. Confirming an order that's already paid fails. So does confirming an empty cart, or refunding more than was charged. Those failures are first-class results of the operation, not exceptional accidents.

Each of them deserves a name.

package order

import "errors"

var (
    ErrOrderAlreadyPaid    = errors.New("order: already paid")
    ErrOrderEmpty          = errors.New("order: no items")
    ErrInsufficientFunds   = errors.New("order: insufficient funds")
    ErrRefundExceedsCharge = errors.New("order: refund exceeds charge")
)
Enter fullscreen mode Exit fullscreen mode

Sentinels are the right tool when the existence of the error is the whole signal. The caller doesn't need fields, a status code, or a retry hint. It needs to know which named failure happened so it can branch.

func (o *Order) Confirm() error {
    if o.status == StatusPaid {
        return ErrOrderAlreadyPaid
    }
    if len(o.items) == 0 {
        return ErrOrderEmpty
    }
    o.status = StatusPaid
    return nil
}
Enter fullscreen mode Exit fullscreen mode

The HTTP adapter on the other end of the call doesn't run a string match. It uses errors.Is:

func writeOrderError(
    w http.ResponseWriter, err error,
) {
    switch {
    case errors.Is(err, order.ErrOrderEmpty):
        http.Error(w, "cart is empty", 422)
    case errors.Is(err, order.ErrOrderAlreadyPaid):
        http.Error(w, "already paid", 409)
    case errors.Is(err, order.ErrInsufficientFunds):
        http.Error(w, "payment declined", 402)
    default:
        http.Error(w, "internal error", 500)
    }
}
Enter fullscreen mode Exit fullscreen mode

Adding a new domain failure adds one var Err... and one case line. The handler's switch becomes the visible map of "what can go wrong when you confirm an order."

Typed errors when the error has data

Sentinels stop being enough the moment a caller wants to act on more than the error's identity. The classic case is a conflict that names which field collided. The HTTP layer wants to tell the client which field, the metrics layer wants to tag the cause, the audit log wants the resource and the actor.

A struct that implements error carries that data without flattening it into a string the caller has to re-parse.

package order

import "fmt"

type ConflictError struct {
    Resource string
    Field    string
    Value    string
}

func (e *ConflictError) Error() string {
    return fmt.Sprintf(
        "%s: conflict on %s=%q",
        e.Resource, e.Field, e.Value,
    )
}

type ValidationError struct {
    Field   string
    Reason  string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf(
        "validation: %s: %s",
        e.Field, e.Reason,
    )
}
Enter fullscreen mode Exit fullscreen mode

The handler matches with errors.As, which walks the wrap chain and binds the typed error into a local variable:

var conflict *order.ConflictError
if errors.As(err, &conflict) {
    writeJSON(w, 409, map[string]any{
        "error":    "conflict",
        "resource": conflict.Resource,
        "field":    conflict.Field,
    })
    return
}

var vErr *order.ValidationError
if errors.As(err, &vErr) {
    writeJSON(w, 422, map[string]any{
        "error":  "validation_failed",
        "field":  vErr.Field,
        "reason": vErr.Reason,
    })
    return
}
Enter fullscreen mode Exit fullscreen mode

The JSON response and the typed error now share one shape. The contract reads from one place.

Infra errors stop at the adapter

Here is the part most teams get wrong. The repository is allowed to know about Postgres. Nothing it returns to the domain is allowed to know about Postgres.

A naive repo leaks the driver's error directly:

// don't do this
func (r *UserRepo) Create(
    ctx context.Context, u *user.User,
) error {
    _, err := r.db.ExecContext(ctx, `
        INSERT INTO users (id, email)
        VALUES ($1, $2)`,
        u.ID, u.Email,
    )
    return err  // pq.Error walks straight out
}
Enter fullscreen mode Exit fullscreen mode

Now every layer above this repo has to know what a pq.Error looks like to handle a duplicate email. Swap to MySQL and every one of those checks is wrong. The domain's vocabulary depends on the choice of database driver, which is the exact coupling hexagonal exists to prevent.

The fix is a translation step at the boundary. The adapter inspects the driver error, maps the cases it cares about to domain errors, and wraps the rest as a generic infra failure with %w so the trace survives.

package postgres

import (
    "context"
    "database/sql"
    "errors"
    "fmt"

    "github.com/lib/pq"

    "myapp/user"
)

type UserRepo struct {
    db *sql.DB
}

func (r *UserRepo) Create(
    ctx context.Context, u *user.User,
) error {
    _, err := r.db.ExecContext(ctx, `
        INSERT INTO users (id, email)
        VALUES ($1, $2)`,
        u.ID, u.Email,
    )
    if err == nil {
        return nil
    }
    return translate(err, "users", u.Email)
}

func translate(
    err error, resource, value string,
) error {
    var pqErr *pq.Error
    if errors.As(err, &pqErr) {
        switch pqErr.Code.Name() {
        case "unique_violation":
            return &user.ConflictError{
                Resource: resource,
                Field:    "email",
                Value:    value,
            }
        case "foreign_key_violation":
            return user.ErrReferenceMissing
        }
    }
    return fmt.Errorf("postgres: %w", err)
}
Enter fullscreen mode Exit fullscreen mode

Get does the same thing for the read path:

func (r *UserRepo) Get(
    ctx context.Context, id string,
) (*user.User, error) {
    row := r.db.QueryRowContext(ctx, `
        SELECT id, email FROM users WHERE id = $1`,
        id,
    )
    var u user.User
    err := row.Scan(&u.ID, &u.Email)
    if errors.Is(err, sql.ErrNoRows) {
        return nil, user.ErrNotFound
    }
    if err != nil {
        return nil, fmt.Errorf("postgres: %w", err)
    }
    return &u, nil
}
Enter fullscreen mode Exit fullscreen mode

Two named cases that the domain cares about. Everything else gets wrapped opaquely: observable in logs, but not promoted to a domain concept. A hypothetical MySQL adapter would have a different translate. The domain doesn't change a line.

What the domain promises, and what it doesn't

The interface the domain defines spells out the promise:

package user

type Repository interface {
    Create(ctx context.Context, u *User) error
    Get(ctx context.Context, id string) (*User, error)
}

var (
    ErrNotFound         = errors.New("user: not found")
    ErrReferenceMissing = errors.New("user: reference missing")
)
Enter fullscreen mode Exit fullscreen mode

Create can return nil, a *ConflictError, ErrReferenceMissing, or an unspecified wrapped error. Get can return nil, ErrNotFound, or an unspecified wrapped error. That short list is the godoc on the interface — the named errors are the part of the contract callers can branch on. The "unspecified wrapped error" branch is the bucket for "the database is on fire," which the caller doesn't try to recover from at all; it logs and returns 500.

The application service that uses the repo passes those typed errors through unchanged. It doesn't decode them; the handler will:

func (s *Service) Register(
    ctx context.Context, email string,
) (*user.User, error) {
    u := &user.User{
        ID:    s.ids.New(),
        Email: email,
    }
    if err := s.users.Create(ctx, u); err != nil {
        return nil, err
    }
    return u, nil
}
Enter fullscreen mode Exit fullscreen mode

The handler does the matching:

u, err := s.Register(ctx, req.Email)
if err != nil {
    var conflict *user.ConflictError
    switch {
    case errors.As(err, &conflict):
        writeJSON(w, 409, conflict)
    case errors.Is(err, user.ErrReferenceMissing):
        http.Error(w, "bad reference", 422)
    default:
        log.Error("register failed", "err", err)
        http.Error(w, "internal error", 500)
    }
    return
}
Enter fullscreen mode Exit fullscreen mode

Three branches. The first two are the named domain failures. The third is the catch-all for the wrapped infra error, where the only honest answer to the client is "we'll look at it." That branch is also where the operator-visible log line lives, with the full wrap chain. A line like postgres: pq: connection refused points straight at the actual cause: the outer postgres: prefix comes from the adapter's fmt.Errorf("postgres: %w", err), and the inner pq: is the driver's own Error() rendering. Each layer adds the prefix that identifies it.

Sentinel or typed: how to pick

A heuristic that holds up:

  • Sentinel when the caller's only question is "did this specific failure happen?" — ErrNotFound, ErrAlreadyPaid, ErrEmptyOrder. The caller branches on identity. Done.
  • Typed when the caller needs data off the error — which field, which resource, what retry-after, which decline code. The struct's fields are the data the caller would otherwise re-parse out of a string.

A common smell: a sentinel error whose Error() string is templated with values the caller actually wants. errors.New(fmt.Sprintf("user %s not found", id)) gives a different sentinel each call and breaks errors.Is. The design wants to be a typed error. Make it one.

The reverse smell: a typed error with no fields. If the struct holds nothing, it's a sentinel with extra ceremony.

Where this leaves the codebase

Three things hold the discipline:

  1. The domain package declares its sentinels and typed errors. Those names are the public surface. New domain failures show up as new exported symbols, not as new strings.
  2. Every adapter has a translate function that maps its specific infra errors into domain errors. Postgres, Redis, the third-party HTTP client, the message queue — each one owns its translation table and nothing else does.
  3. Handlers and application services match domain errors with errors.Is and errors.As. Nothing above the adapter ever calls strings.Contains on an error message, ever.

The day someone proposes swapping the database, the diff lives entirely in internal/adapter/postgres. The day someone adds a new domain rule, the diff lives entirely in internal/domain/order. The two diffs never sit in the same pull request, and they never need to.


If this was useful

Error translation at boundaries is one of the chapters of Hexagonal Architecture in Go — the longer version walks through retry vs permanent classification, mapping rich third-party HTTP errors, and what the seam looks like when you have ten adapters instead of two. Pairs with The Complete Guide to Go Programming if the language-level error model is still settling in.

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

Top comments (0)