How do you translate system errors into context aware domain errors?
- What should the caller system receive when a file, the system is trying to read, is not available?
- How about a scenario when the OS denied access permissions to a resource?
To explore this, I built a small Go app that reads a JSON file and parses it. That’s it. The goal was not parsing JSON. The goal was to understand how system level errors are translated into domain aware errors and more importantly, when that translation actually matters.
This goes beyond just using errors.Is() and errors.As() in Go.
Reference Implementation: The complete example used in this article is available here: GitHub Repository.
The Core Idea
Suppose you are building a system whose core job is to process JSON files and the system tries to read a file, but the file does not exist at the given path
At the OS level, you’ll get something like os.PathError wrapping os.ErrNotExist
Now here’s the design question:
Should the upstream layer see os.PathError?
Or should it see something like ErrTaskFileUnavailable?
In an application whose domain is processing json files, “file not found” is not just a filesystem issue. It’s a domain failure, the task file required for execution is unavailable.
In this model, the caller receives the domain aware signal, which nudges the callers toward the correct recovery path instead of reacting to raw infrastructure details.
That is where translation makes sense.
The Mechanics
Any struct implementing Error() string satisfies the error interface in Go.
This makes it possible to wrap system error into domain specific types while preserving the original failure and isolating infrastructure concerns from domain layers
Implementing the Unwrap() error method ensures that your domain errors remain compatible with errors.Is and errors.As, allowing callers to inspect the underlying cause without breaking the abstraction.
The distinction is subtle. Renaming an error does not change its meaning. Semantic elevation does. When translation does not add meaning the semantic boundary remains as is.
The Translation layer
Here is the translation layer:
if err != nil {
if errors.Is(err, os.ErrNotExist) {
var pathError *os.PathError
if errors.As(err, &pathError) {
return false, &domain_errors.ErrTaskFileUnavailable{
Message: err.Error(),
Err: err,
Path: pathError.Path,
Operation: pathError.Op,
}
}
} else if errors.Is(err, os.ErrPermission) {
return false, err
}
}
What happens here is subtle.
We are not hiding the original error. We are wrapping it and adding domain semantics.
The boundary becomes point where the infrastructure semantics shift into domain semantics.
Avoiding “Error Translation Hell”
Not all errors require translation.
In the snippet:
os.ErrNotExistbecomes domain erroros.ErrPermissionremains untouched
Translation becomes meaningful when:
- The abstraction boundary changes
- The translation adds domain knowledge
- Infrastructure details are isolated from the caller
Without these shifts, renaming the error does not alter its meaning. It merely adds indirection.
That is how systems drift into error translation hell, a museum of renamed failures with no semantic elevation.
What the Consumer Sees
At the orchestration layer:
if errors.As(err, &errTaskFileUnavailable) {
fmt.Println("Error:: File does not exist")
}
The consumer is no longer aware of os.PathError.
It understands the failure in domain language.
That is the real value of translation.
Translation becomes relevant at abstraction boundaries, where infrastructure failures acquire domain meaning. When semantic value does not increase, the boundary has not truly shifted. Renaming the error changes nothing. Error handling is not incidental plumbing or an afterthought. It defines how the system expresses failure. In well designed systems, these patterns are not added deliberately at the end. They emerge from clear boundaries and intentional domain modeling.