JavaRush /Courses /Go SELF /Wrapping strategy: what we keep for logs

Wrapping strategy: what we keep for logs

Go SELF
Level 55, Lesson 1
Available

1. Why “showing the user err.Error()” is a bad habit

When you write a CLI application, it is very easy to start living by the principle “if it crashed, print err and that’s it”. In small training programs, this can even look honest: “Well, here’s the error—what else do you want?”. But in the real world, an error has at least three audiences, and each has different expectations.

A user wants a short and clear explanation of what they did wrong (or what the program failed to do). Scripts and automation want stable behavior (usually an exit code and a predictable error format). And a developer wants details: which file, which operation, what the root cause was, and ideally in a way that can be found in the logs.

Let’s capture this picture as a diagram:

flowchart TD
    U[User] -->|reads| MSG[Short message in stderr]
    S[Script/CI] -->|reads| CODE[Exit code]
    D[Developer] -->|reads| LOG[Logs/diagnostics]

    ERR[Error in Go] --> MSG
    ERR --> CODE
    ERR --> LOG

The main idea: the same error should “feed” three channels at once, but not with the same text. If you “give the user everything you know”, it can look like an internal dump, and sometimes also like an information leak (file system paths, internal formats, table names, and so on). And if you, on the contrary, “flatten” the error into a string too early, you deprive yourself of the ability to later understand what exactly happened.

2. %w and %v: looks similar, but the contract is different

At the syntax level, the difference between %w and %v in fmt.Errorf looks almost funny: one letter, but enough drama for an 8-season TV series. But from an architecture standpoint it really matters: %w makes the root cause available for programmatic analysis via errors.Is/errors.As, while %v turns it into text and “cuts off the path back”.

When you wrap an error with %w, you are basically telling the calling code: “I allow you to look inside and make a decision based on the cause”. That is convenient, but risky: if inside you have sql.ErrNoRows, *os.PathError, or something from a third-party library, then external code may start depending on it. And then switching libraries will be almost as “pleasant” as moving to another planet without any boxes. In Go documentation and best-practice articles, this idea is stated plainly: wrapping makes a specific error part of your API, and if you are not ready to support that behavior, it is better not to wrap.

A quick example:

package main

import (
	"errors"
	"fmt"
)

var ErrNotFound = errors.New("not found")

func loadTask(id int) error {
	// %w: root cause preserved
	return fmt.Errorf("load task %d: %w", id, ErrNotFound)
}

And here is the “flattened” version:

package main

import (
	"errors"
	"fmt"
)

var ErrNotFound = errors.New("not found")

func loadTaskHidden(id int) error {
	// %v: the root cause became just text; Is/As will not see it
	return fmt.Errorf("load task %d: %v", id, ErrNotFound)
}

Both versions print almost the same for a human. The difference shows up when you want to do errors.Is(err, ErrNotFound) or extract a typed error via errors.As. And this is where we get to the strategic question: who are we allowing “inside” the error, and why?

3. Where to add context: application layers and the Op field

When you read someone else’s logs, you quickly understand a simple thing: “read error” without context is not an error—it is a riddle. In a good system, an error almost always carries operation context, so you can see at which step everything collapsed: “load tasks”, “parse args”, “write output”, “open data file”.

The funny part is that beginners often do the opposite: either they add no context at all, or they add it so aggressively that you get “read: read: read: read: permission denied”. So it is useful to think in terms of structure rather than a string: we have layers (command → application logic → storage → OS), and each layer can add a small semantic label, but it should not turn the error into an essay.

A layer diagram for our training CLI application (let’s call it tasker):

flowchart LR
    CMD[cmd/tasker: command] --> APP[app: use case]
    APP --> ST[storage: read/write]
    ST --> OS[os/fs/json: system causes]

Assume we already have AppError (from the previous lecture) with fields Kind, Op, Err. Then the basic idea is: Op describes “what we were doing” at this level, and Err stores the root cause (possibly already with additional wrapping).

A short, practical constructor example (so you do not have to write the same thing by hand):

package apperr

type Kind int

const (
	KindInternal Kind = iota
	KindValidation
	KindNotFound
	KindIO
)

type AppError struct {
	Kind Kind
	Op   string
	Err  error
}

func (e *AppError) Error() string {
	if e == nil {
		return "<nil>"
	}
	if e.Err == nil {
		return e.Op
	}
	if e.Op == "" {
		return e.Err.Error()
	}
	return e.Op + ": " + e.Err.Error()
}

func (e *AppError) Unwrap() error { return e.Err }

What matters here is that Unwrap() returns Err, which means errors.Is/errors.As can walk the cause chain.

4. When to preserve the root cause, and when to hide details

In a wrapping strategy there is a simple criterion: if the calling code can and should make a decision based on the cause, preserve the cause as a cause (wrap via %w or via Unwrap). If the cause is an internal implementation detail that should not “leak” outward, it is better to turn it into text or replace it with a more stable domain meaning.

It is helpful to keep a small table in mind (it is not “the ultimate truth”, but it is a good start):

Situation What happened Wrap the root cause? Why
The user provided a file path and we could not open it permission denied, file not found often yes the cause belongs to the user’s external world
We use an internal DB/format and it is an implementation detail sql.ErrNoRows, json.SyntaxError from an internal file often no (or only for logs) otherwise external code will start depending on our internal details
Argument validation error “limit must be > 0” usually not required decisions are made based on KindValidation; the cause is secondary
Unexpected error “something broke” yes, but show it to the user carefully we need investigation, but the user does not need a dump

Note: “wrap or not wrap” is not about “do I want to see the cause in the text”. You can always assemble the text. It is about whether we make the root cause part of a programmatic contract. If you return an error that wraps, for example, sql.ErrNoRows, external code can start writing errors.Is(err, sql.ErrNoRows), and then you are obligated to keep that behavior, or you will “break clients”.

By the same logic: if os.Open returned *os.PathError, and that type is an implementation detail of your function, then it is better not to expose it outward as an unwrap chain, but to “repackage” without %w.

5. User message and diagnostics are different decisions

A useful mental exercise: imagine that an error is a suitcase. Inside the suitcase is a bunch of stuff (causes, context, nested errors). The user is the person at the check-in desk asking, “What happened?”. The developer is the security staff who needs to open the suitcase and understand what is inside.

If you answer the user with “here is the whole suitcase, take it”, they get a pile of confusing details. If you answer “I have no idea”, you deprive yourself of investigation. So a normal CLI pattern is: the user message is built from meaning (Kind, sometimes Op), and the root cause stays inside the error for logs.

A small sketch of a function that forms a “human” message (we are not building a perfect UX dictionary here—we just want the principle):

package apperr

import (
	"errors"
)

func UserMessage(err error) string {
	if err == nil {
		return ""
	}

	var ae *AppError
	if errors.As(err, &ae) {
		switch ae.Kind {
		case KindValidation:
			return "invalid input"
		case KindNotFound:
			return "not found"
		case KindIO:
			return "i/o error"
		default:
			return "internal error"
		}
	}

	return "internal error"
}

Notice that we do not print ae.Err.Error() to the user. This is not because we are “greedy”, but because it is unstable and often too technical. But in logs we can safely keep ae.Err and even the entire error chain so the developer can understand what happened.

And this is where the wrapping strategy kicks in: we can store the root cause via %w without showing it to the user directly. That is, “wrap for diagnostics” and “show for UX” are different decisions, and it is useful to make them separately.

6. Practice on the tasker CLI: different wrapping policies

Let’s imagine our application stores tasks in a file (a typical and understandable model for a training CLI). Assume we get the file path from configuration or a flag, but the very existence of the file is already an I/O concern, which makes it an ideal example for a wrapping strategy.

Let’s start with a task-loading function. We will simplify: read the entire file and parse JSON (we are not discussing streaming JSON or complex formats here—the errors are what matter).

package storage

import (
	"encoding/json"
	"os"
)

type Task struct {
	ID    int    `json:"id"`
	Title string `json:"title"`
	Done  bool   `json:"done"`
}

func LoadTasks(path string) ([]Task, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, err
	}

	var tasks []Task
	if err := json.Unmarshal(data, &tasks); err != nil {
		return nil, err
	}
	return tasks, nil
}

This code “technically works”, but it says nothing about meaning. Next we have two wrapping-policy options.

Option A: the path is part of the contract, so OS causes can be preserved

If the user provides the file path, then errors like permission denied or no such file belong to their world. In that case, you can preserve the root cause so the upper layer can, for example, distinguish “file does not exist” from “no permission”. Then the storage layer can wrap with %w, adding a clear Op.

package storage

import (
	"fmt"
	"os"
)

func LoadTasks(path string) ([]Task, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, fmt.Errorf("read tasks file %s: %w", path, err)
	}
	// ...
	return nil, nil
}

Now errors.Is with os.ErrNotExist or os.ErrPermission (via the corresponding fs errors) can theoretically work at the upper level. This is OK only if you are ready to treat it as part of your program’s behavior.

Option B: an internal file is an implementation detail, so do not leak OS errors

If the file is strictly internal (for example, always ~/.tasker/tasks.json), and you do not want outsiders to make decisions based on *os.PathError, you can “flatten” the system error into text for the outer layer, while keeping it for logs inside your AppError.

package storage

import (
	"encoding/json"
	"fmt"
	"os"

	"example/tasker/apperr"
)

func LoadTasks(path string) ([]Task, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, &apperr.AppError{
			Kind: apperr.KindIO,
			Op:   "load tasks",
			// The root cause exists, but the decision about its “publicness” is yours.
			Err: fmt.Errorf("read file: %v", err), // %v: do not allow unwrap outward
		}
	}

	var tasks []Task
	if err := json.Unmarshal(data, &tasks); err != nil {
		return nil, &apperr.AppError{
			Kind: apperr.KindInternal,
			Op:   "decode tasks",
			Err:  err, // can keep as-is or control it too
		}
	}

	return tasks, nil
}

Notice a subtlety: here we “flattened” the system error (%v) so that external code does not do errors.Is checks on types like *os.PathError and does not depend on the details. This matches the recommendation “do not wrap internal errors if you do not want to lock them in as part of your API”.

But we still have structural context KindIO and Op, and the upper layer can make a decision like “this is I/O” without dancing around the error text.

How to avoid “read: read: read”: one unique clarification per layer

When you start wrapping errors carefully, you almost inevitably hit a moment where the log looks like the program got stuck on the word read and cannot escape. This is a normal growing pain: you honestly add context at every step, but the context is the same.

Discipline helps: each layer adds only the context that is unique to that layer. At the storage layer, Op will be “load tasks”. At the app layer, “add task” or “list tasks”. At the cmd layer, “parse args” or “run command”. And if the context does not add new information, it is better not to add it at all.

A small example in the app layer where we do not change the meaning of the error, but only add “where it happened”:

package app

import (
	"fmt"

	"example/tasker/apperr"
	"example/tasker/storage"
)

type Service struct {
	Path string
}

func (s Service) List() ([]storage.Task, error) {
	tasks, err := storage.LoadTasks(s.Path)
	if err != nil {
		// Add use case-level context while preserving the cause:
		return nil, &apperr.AppError{
			Kind: apperr.KindOf(err), // suppose KindOf already exists
			Op:   "list tasks",
			Err:  fmt.Errorf("%w", err), // preserve the cause chain
		}
	}
	return tasks, nil
}

Note: if storage.LoadTasks already returned AppError{Kind: KindIO, Op: "load tasks"}, then the app layer will add Op: "list tasks". As a result, logs will get a good chain like “list tasks: load tasks: read file: permission denied”. For the user, you can show something like “i/o error” or a more friendly wording—but that is a separate decision and does not have to equal err.Error().

7. Common mistakes

Mistake #1: using %w “everywhere out of habit”, without realizing it is a public contract.
The most dangerous part of %w is that it seems like “just a more correct %v”. But %w is not about text prettiness; it is about allowing external code to analyze the root cause via errors.Is/errors.As. As a result, your internal choice of library or format becomes an obligation. It is like promising “never to change the door” because someone’s key fits.

Mistake #2: flattening the error too early and losing diagnostics.
Sometimes a developer does fmt.Errorf("something failed: %v", err) somewhere deep down, and then at the top they dream of distinguishing not found from permission denied. The dream will not come true, because you cut off the ability to use errors.Is/errors.As yourself. If you want both control and a clean UX, preserve the root cause, but decide separately what to print to the user.

Mistake #3: mixing Op and the user message into one string.
Op is good when it is short and technical: “load tasks”, “parse args”, “write output”. If you stuff “Please provide a correct file path, otherwise…” into it, it becomes an unpredictable mess for logs and breaks the idea of stable UX. User-facing text should be built by a separate function, and Op should help the developer.

Mistake #4: duplicating the same context at every level and getting “read: read: read”.
When context repeats, it stops being context and becomes noise. If the storage layer already said “load tasks”, there is no point for the app layer to write “load tasks” again. Add only the piece of meaning that is unique to the layer; otherwise logs get longer but not more informative.

Mistake #5: showing the user the “raw” err.Error() from lower layers.
Lower layers often contain system details: paths, internal formats, odd wording from the standard library. For diagnostics, this is useful; for users, it is almost always annoying and sometimes unsafe. It is much more reliable to print a short message based on Kind, and leave details in logs (with Op and the root cause inside the error).

1
Task
Go SELF, level 55, lesson 1
Locked
Error chain
Error chain
1
Task
Go SELF, level 55, lesson 1
Locked
Operation tag
Operation tag
1
Task
Go SELF, level 55, lesson 1
Locked
Public mode
Public mode
1
Task
Go SELF, level 55, lesson 1
Locked
Context ladder
Context ladder
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION