1. The application boundary: where an error becomes “human”
If you have ever seen a program that proudly prints to the user panic: runtime error: invalid memory address, then you understand: an “error user interface” can be… very honest. But we need a different kind of honesty: inside the code we want the most precise cause possible, and outside we want a clear and stable message. That place where the “inner world” meets the “outer world” is called the boundary.
By “boundary” I mean any layer that talks to the outside world: CLI (command line), HTTP API, sometimes UI, sometimes file import/export. Inner layers (domain/app) must not know how exactly we communicate with the user. Otherwise the domain will start printing to the console, and use-cases will start deciding HTTP statuses — and the architecture will turn into a salad.
Let’s imagine our learning application: a small todo manager. Inside we have a domain model Task, use-cases Add/Get, a storage adapter (for now in-memory), and a very simple CLI “entry point”. Today we will learn how to make errors pass through layers neatly: the meaning stays, and the shape changes only at the boundary.
2. Domain errors: meaning matters more than text
When we say “a domain error,” we usually do not mean “something broke,” but rather “a forbidden state occurred from the perspective of the business domain.” For example, a task cannot have an empty title. That is not a console or HTTP problem — it is a rule of our todo world.
It is important to keep one idea in mind: branching on errors must happen by meaning, not by string. A string is for humans to read. Code should rely on distinguishable values: sentinel errors or typed errors and checks via errors.Is/errors.As. In Go, this is supported directly via errors.Is and errors.As, which can “see” an error even inside a chain of wrappers (an error chain).
Sentinel errors in domain
Let’s start simple: a few “marker” errors in the domain.
// domain/errors.go
package domain
import "errors"
var ErrEmptyTitle = errors.New("empty title")
var ErrNotFound = errors.New("not found")
ErrEmptyTitle and ErrNotFound are not “messages for the user,” but identifiers of meaning: “the title is empty,” “the object was not found.” Their text is also important, but its job is to be useful during debugging and in logs.
Typed validation error
When there are more rules, we want not only to say “validation failed,” but also to attach details: which field, what is wrong. This is a classic case for a typed error.
// domain/validation.go
package domain
type ValidationError struct {
Fields map[string]string
}
func (e *ValidationError) Error() string {
return "validation failed"
}
Here Fields is a data structure for code, and Error() is a short string for humans. Yes, it is very boring — that is fine: we do not want to build a UI out of Error().
And yes, this is the case where later, at the boundary (CLI/HTTP), we will pull out Fields via errors.As.
Invariants: create an entity or return an error
The domain is where we enforce invariants. That is, we either create a valid Task, or we honestly say “not allowed.”
In our todo app, let Task be created via a constructor:
// domain/task.go
package domain
type Task struct {
ID int
Title string
Done bool
}
func NewTask(id int, title string) (Task, error) {
if title == "" {
return Task{}, ErrEmptyTitle
}
return Task{ID: id, Title: title, Done: false}, nil
}
Notice an important style point: we return the zero value (Task{}) together with the error. No “half-built” entities. This makes code in higher layers simpler: if err != nil, you cannot use the object — period.
If there were more rules, we could return a *ValidationError with fields:
// domain/task.go (variant, if we want fields)
package domain
func NewTask(id int, title string) (Task, error) {
fields := make(map[string]string)
if title == "" {
fields["title"] = "must not be empty"
}
if len(fields) > 0 {
return Task{}, &ValidationError{Fields: fields}
}
return Task{ID: id, Title: title, Done: false}, nil
}
This variant is especially convenient for a future HTTP API, but it is already useful for CLI: you can show the user exactly what they entered incorrectly.
3. Errors across layers: add context, preserve meaning
Now the use-case layer (app) is the one that “orchestrates” steps: get an ID, create a task, save it. Here it is very easy to make two typical mistakes: either lose context (“where exactly did it fail?”) or lose meaning (“why did it fail?”). Our goal is to preserve both.
Go directly nudges us toward wrapping via %w: this keeps the error “unwrap-able” for errors.Is/errors.As.
The dependency interface is declared by app, not the adapter
The use-case must not know which storage we have (memory, file, database), so it accepts an interface.
// app/store.go
package app
import (
"context"
"example.com/todoapp/domain"
)
type TaskStore interface {
NextID(ctx context.Context) (int, error)
Save(ctx context.Context, t domain.Task) error
}
Add-task use-case with wrapping
// app/add.go
package app
import (
"context"
"fmt"
"example.com/todoapp/domain"
)
func AddTask(ctx context.Context, store TaskStore, title string) (domain.Task, error) {
id, err := store.NextID(ctx)
if err != nil {
return domain.Task{}, fmt.Errorf("next id: %w", err)
}
t, err := domain.NewTask(id, title)
if err != nil {
return domain.Task{}, err
}
if err := store.Save(ctx, t); err != nil {
return domain.Task{}, fmt.Errorf("save task: %w", err)
}
return t, nil
}
There is a small philosophy here.
We wrap dependency errors (NextID, Save) — because above, it is useful to know at which step it failed.
We do not have to wrap domain errors (for example, ErrEmptyTitle): they are already at “our level.” But sometimes wrapping is OK too — as long as you preserve meaning via %w and do not turn the text into a contract.
Wrapping is part of an API decision
And here a subtle point appears. Wrapping makes an error “visible” to code above: errors.Is(err, X) can find X inside the chain. That is great… until you accidentally start leaking internal adapter errors (for example, sql.ErrNoRows), thereby promising the world: “we will always use this exact DB and this exact error.”
In official discussions of errors in Go, the wording is usually blunt: if you wrap someone else’s error and allow code above to rely on it via unwrapping, you make it part of the API — and then it becomes harder to change the implementation.
For our day, this means: domain errors can be made part of the contract, while technology-specific errors — are better “translated” into domain errors (or layer errors) so that implementation details do not stick out to the outside.
4. Adapter: translate technical problems into meaningful ones
Adapters are where technical reality meets our domain. Even if today we have a simple in-memory store, the style is already the same as for more serious storage: the adapter can return domain.ErrNotFound if the record does not exist.
Let’s make a minimal memstore:
// adapters/memstore/store.go
package memstore
import (
"context"
"example.com/todoapp/domain"
)
type Store struct {
next int
data map[int]domain.Task
}
func New() *Store {
return &Store{next: 1, data: make(map[int]domain.Task)}
}
And now the methods. Try to keep them simple, without being “too clever.”
// adapters/memstore/save.go
package memstore
import (
"context"
"example.com/todoapp/domain"
)
func (s *Store) NextID(ctx context.Context) (int, error) {
id := s.next
s.next++
return id, nil
}
func (s *Store) Save(ctx context.Context, t domain.Task) error {
s.data[t.ID] = t
return nil
}
And here is an example of reading with “not found”:
// adapters/memstore/get.go
package memstore
import (
"context"
"example.com/todoapp/domain"
)
func (s *Store) ByID(ctx context.Context, id int) (domain.Task, error) {
t, ok := s.data[id]
if !ok {
return domain.Task{}, domain.ErrNotFound
}
return t, nil
}
Here the adapter does not say “map key missing” (because that is not domain meaning at all). It says “not found” in the domain’s language.
5. The CLI boundary: message and exit code
CLI is a special boundary. It loves specifics: what to print to the user, where to print it (stdout or stderr), and which exit code to return. At the same time, CLI must not parse application “internals” by error strings — only by meaning via errors.Is/errors.As.
One of the most useful techniques in Go (and in life in general) is to move error formatting into a separate function: WriteError(...) or RenderError(...). This keeps main short, and the logic does not get smeared across the project.
Mini mapping table
Let’s make an agreement. No heavy standardization yet — just a reasonable minimum.
| Error meaning | What to show the user | Where to print | Exit code |
|---|---|---|---|
| validation | “Invalid input: …” | stderr | 2 |
| not found | “Not found: …” | stderr | 1 |
| other | “Internal error” | stderr | 1 |
Yes, this table will evolve, but the idea itself is more important: the boundary decides the shape, inner layers decide the meaning.
Render an error for CLI via errors.Is/As
// boundary/cli/errors.go
package cli
import (
"errors"
"fmt"
"io"
"example.com/todoapp/domain"
)
func WriteError(w io.Writer, err error) int {
var ve *domain.ValidationError
if errors.As(err, &ve) {
fmt.Fprintln(w, "invalid input:", ve.Fields) // invalid input: map[title:must not be empty]
return 2
}
if errors.Is(err, domain.ErrNotFound) {
fmt.Fprintln(w, "not found") // not found
return 1
}
fmt.Fprintln(w, "internal error") // internal error
return 1
}
Notice errors.As(err, &ve). Here we pass the address of the variable so the function can “write the found error” into it — this is the standard contract of errors.As.
One more detail: we do not print err.Error() to the user in “other” cases. Why? Because err may contain anything: storage details, file paths, internal hints for the developer. That usually does not make the user happy, and sometimes it is also unsafe.
Example main.go: a minimal skeleton
Let’s build a super-simple CLI without flag (we will cover it separately in another part of the course). Let the command be: todoadd <title>.
// cmd/todo/main.go
package main
import (
"context"
"fmt"
"os"
"example.com/todoapp/adapters/memstore"
"example.com/todoapp/app"
"example.com/todoapp/boundary/cli"
)
func main() {
store := memstore.New()
title := ""
if len(os.Args) >= 2 {
title = os.Args[1]
}
t, err := app.AddTask(context.Background(), store, title)
if err != nil {
code := cli.WriteError(os.Stderr, err)
os.Exit(code)
}
fmt.Println("added:", t.Title) // added: buy milk
}
The key thing here is the structure of thought: main should not “think” about error types. It delegates that to the cli boundary.
6. The HTTP boundary: same meaning, different shape
With HTTP it is the same story, just a different shape. The user there is often not a human, but a script or a frontend client. It needs to understand reliably: is this a validation error? not found? internal? So the HTTP boundary usually returns a “structured” error: status code + JSON (or at least an error code).
Today we are not building an HTTP server, but we can (and should) prepare a function that transforms an error into an HTTP response. Later it will be used in handlers.
By the way, centralizing error handling in one place for HTTP is normal practice in Go: instead of repeating if err != nil { ... } in every handler, you create a common layer that accepts an error and decides what to send outward.
Error response structure
// boundary/httpx/errors.go
package httpx
type ErrorResponse struct {
Status int
Code string
Message string
Fields map[string]string
}
This is not net/http and not real JSON — it is just a structure that is easy to encode later. The important idea: Message is for the outside world, Code is for machines, Fields is for validation.
Mapping errors to an HTTP response
// boundary/httpx/map.go
package httpx
import (
"errors"
"example.com/todoapp/domain"
)
func ToErrorResponse(err error) ErrorResponse {
var ve *domain.ValidationError
if errors.As(err, &ve) {
return ErrorResponse{Status: 400, Code: "validation", Message: "bad request", Fields: ve.Fields}
}
if errors.Is(err, domain.ErrNotFound) {
return ErrorResponse{Status: 404, Code: "not_found", Message: "not found"}
}
return ErrorResponse{Status: 500, Code: "internal", Message: "internal error"}
}
Again we use errors.As and errors.Is. So our “universal engine” is domain-level meaning errors and chain-aware checks.
If later in the app layer we wrapped an error like fmt.Errorf("get task: %w", domain.ErrNotFound), the boundary will still recognize ErrNotFound, because errors.Is walks the chain.
What to propagate upward, and what is better to hide
It is very tempting to do: “if os.Open returned an error, just wrap it with %w and return it upward.” Sometimes that is correct. But sometimes this decision accidentally exposes implementation details to the outside.
Practically speaking: wrapping is not just “to look nicer,” wrapping is a way to tell code above: “you may rely on this.” If you allowed unwrapping of someone else’s error, you effectively signed that it is part of your API, and you cannot painlessly switch the technology.
In our todo app, this means the following.
If the adapter “did not find a record,” it should return domain.ErrNotFound, not “some map error” and not “a database error.” Then domain.ErrNotFound becomes the meaningful anchor understood by both CLI and HTTP.
If the adapter hit an unexpected technical error (for example, “failed to open file”), the app layer can wrap it with context ("save task: %w"), and the boundary can show the user "internal error", while the log (or printing err for the developer) will contain the chain of causes.
And here the main balance shows up: for debugging, it is useful for a human to see everything, while for the user — only what is clear. That is the point of “errors at the boundaries.”
7. Common mistakes
Mistake #1: comparing errors by string (err.Error() == "not found").
It seems like a quick solution, but it turns the text into a contract, and text almost always changes: you add context, adjust wording, localize the message — and suddenly the branching logic breaks. Instead, use errors.Is for sentinel errors and errors.As for typed errors, so you branch by meaning.
Mistake #2: wrapping without %w where meaning matters.
If you write fmt.Errorf("save task: %v", err), it looks fine to a human, but the cause chain is lost for the program: errors.Is/errors.As will no longer be able to find the original domain error. If you rely on meaning recognition at the boundary — use %w.
Mistake #3: leaking implementation details via wrapping.
Sometimes a developer does it “by the book”: %w everywhere, everything “correct.” And then it turns out the top layer started checking errors.Is(err, sql.ErrNoRows) or some other library-specific error. This ties your code to a specific technology and makes refactoring painful. Where the technology is an internal detail, it is better to “translate” the error into a domain one (for example, domain.ErrNotFound) and expose that instead.
Mistake #4: the domain prints errors or chooses an exit code.
This is one of the most destructive habits: “I will just fmt.Println here so it is visible.” In a couple of weeks the domain starts depending on fmt, use-cases start depending on os.Exit, and testing everything becomes sad. Keep a hard rule: domain and app return an error; the boundary decides what to do with it and how it looks to the user.
Mistake #5: the boundary shows the user err.Error() for any error.
Inside the error there may be too much irrelevant information: internal paths, technical details, sometimes even pieces of configuration. That does not help the user, and can even be harmful. A solid style: at the boundary, recognize meaning (validation/notfound/internal) and show a stable message, while keeping the full error for developer logs/diagnostics.
GO TO FULL VERSION