Hello! Welcome back to our advanced Go tutorial.
In our last session, we achieved a significant milestone in system design: we made our storage layer completely swappable. By using an interface and some configuration in our main function, we can now switch between a real PostgreSQL database and a fast in-memory store without changing a single line of our API's business logic.
Your Goal for Today
Learning Outcome: Define and use custom error types with wrapping and unwrapping for domain-specific errors.
So far, our error handling has been functional but basic. If our database returns an error, we mostly just log it and pass it up the call stack. This creates a "leaky abstraction"—our service layer might end up dealing with database-specific errors like pgx.ErrNoRows. This couples our application logic to our database implementation, which undermines the very abstraction we just worked so hard to build.
Today, we'll fix that. You will learn to define your own domain-specific errors and use Go's powerful error handling features (wrapping and unwrapping) to translate low-level errors into meaningful, abstract errors that are part of your application's defined contract.
1. The Problem: Leaky Abstractions and Brittle Code
Imagine our PostgresStorage.Get() method tries to find an item that doesn't exist. The pgx driver will correctly return a pgx.ErrNoRows error. If our API handler receives this specific error, it might be tempted to check for it:
// In an API handler...
item, err := s.storage.Get(id)
if err == pgx.ErrNoRows { // <-- PROBLEM!
// return HTTP 404 Not Found
}
The problem is that our API handler now has a direct dependency on the pgx library. What happens if we swap to the InMemoryStorage? It won't return pgx.ErrNoRows. Or what if we switch to a MySQL database with a different driver? The code breaks.
Our goal is to create a clear boundary. The repository is responsible for talking to the database; the rest of the application should only talk to the repository in terms of abstract, domain-specific concepts, including errors.
This diagram illustrates the concept of abstracting away the root cause of an error. We want our API to respond to a well-defined "Application Error" (like "Not Found"), not the specific "Root Cause" (a database error).

2. The Go 1.13+ Error Handling Toolkit
Since Go 1.13, the standard library provides a suite of tools for handling errors in a structured way. Before we apply them, let's understand the key concepts.
Working with Errors in Go 1.13
The official Go blog post 'Working with Errors in Go 1.13' is the definitive source on this topic. It introduces the new functions and conventions that have become standard practice in modern Go.
Please read the following sections to get a solid grasp of the core mechanics: The Unwrap method: Understand the convention of an Unwrap() error method to create a chain of errors. Examining errors with Is and As: Learn how errors.Is checks for specific error values (sentinels) and errors.As checks for specific error types within the chain. Wrapping errors with %w: See how the %w format verb in fmt.Errorf is the primary way to create wrapped errors.
Key Summary:
- Wrapping (
%w):fmt.Errorf("operation failed: %w", originalErr)creates a new error that contains theoriginalErr. This forms a chain. - Inspecting with
errors.Is: This function walks the error chain to see if any error in it is equal to a target error value. It's perfect for checking for specific, predefined errors (called "sentinel errors"). - Inspecting with
errors.As: This function walks the error chain to see if any error can be assigned to a variable of a target type. It's used for custom error structs where you need to extract more data from the error.
3. Defining and Using a Domain-Specific Sentinel Error
Let's apply this by creating our first domain-specific error. The most common one is a "not found" error. We'll define this as a sentinel error: a package-level variable that represents a specific, known error condition.
Step 1: Define the Error
In your internal/storage package (e.g., in a new storage/errors.go file or at the top of your storage/storage.go), define the sentinel error.
// internal/storage/storage.go or a new errors.go
package storage
import "errors"
// ErrNotFound is returned when a requested item is not found.
var ErrNotFound = errors.New("not found")
// ... (rest of your Storage interface)
This ErrNotFound is now part of the public API of your storage package. Any code using the Storage interface can reliably check for this specific error.
The video below explains this pattern of using sentinel errors clearly.
The secret to making Golang error handling a breeze
The video 'The secret to making Golang error handling a breeze' from Earthly provides a great explanation of sentinel errors and how errors.Is is used to check for them.
Watch from 03:27 to 04:29. This segment demonstrates declaring a sentinel error (errorDivideByZero) and then using errors.Is in the calling code to check for that specific error condition.
Step 2: Translate the Error in the Repository
Now, let's modify PostgresStorage to use this new error. The goal is to catch pgx.ErrNoRows and return our own storage.ErrNotFound instead.
Find your Get method in internal/storage/postgres.go (or wherever you defined your PostgresStorage).
// In your PostgresStorage implementation...
import (
"errors" // Make sure errors package is imported
"fmt"
"github.com/jackc/pgx/v5" // And pgx
)
// In your Get method (or whichever method fetches a single item)
func (p *PostgresStorage) Get(id string) (*Item, error) {
// ... your query logic
err := p.db.QueryRow(ctx, query, id).Scan(...)
if err != nil {
// Check if the error is the specific 'no rows' error from pgx
if errors.Is(err, pgx.ErrNoRows) {
// If it is, return our domain-specific ErrNotFound.
// DO NOT wrap it. We are translating the error.
return nil, ErrNotFound
}
// For any other error, wrap it to provide context.
return nil, fmt.Errorf("failed to get item with id %s: %w", id, err)
}
// ...
return &item, nil
}
This is the core of the pattern. We inspect the low-level error. If it's a known condition we want to abstract (pgx.ErrNoRows), we return our clean, domain-specific ErrNotFound. For all other unexpected database errors, we wrap them to add context for logging and debugging, without exposing their type.
The dev.to article "Mastering Data Access in Go" provides an excellent, real-world example of this exact translation.
Mastering Data Access in Go with Repositories & sqlc (2) - Dev.to
This article provides a superb, practical implementation of the pattern we're building. It shows how to map pgx.ErrNoRows to a domain-specific ErrUserNotFound.
Find the code block for the pgxUserRepository implementation. Look inside the GetByEmail and GetByID functions. Notice the if errors.Is(err, pgx.ErrNoRows) check, which then returns ErrUserNotFound. This is precisely what we have just done.
4. Advanced Case: Custom Error Structs
Sentinel errors are great for simple conditions. But what if you need to convey more information? For instance, if a user tries to create an item with a key that already exists, you might want the error to include that key. For this, we can define a custom struct that implements the error interface.
Let's define an ErrDuplicate error.
Step 1: Define the Custom Error Struct
In the same place you defined ErrNotFound, add the following:
// internal/storage/storage.go or errors.go
import "fmt"
// ErrDuplicate is returned when an item with the same key already exists.
type ErrDuplicate struct {
Key string
}
// Error implements the error interface.
func (e *ErrDuplicate) Error() string {
return fmt.Sprintf("item with key %q already exists", e.Key)
}
Now we have an error type that carries structured data.
Step 2: Use the Custom Error
In a hypothetical Create method within PostgresStorage, you could check for a unique constraint violation from PostgreSQL and translate it to your ErrDuplicate. PostgreSQL's error code for a unique violation is 23505.
// In a hypothetical PostgresStorage.Create method...
import (
"github.com/jackc/pgx/v5/pgconn"
)
func (p *PostgresStorage) Create(item *Item) error {
query := `INSERT INTO items (id, data) VALUES ($1, $2)`
_, err := p.db.Exec(ctx, query, item.ID, item.Data)
if err != nil {
var pgErr *pgconn.PgError
// Use errors.As to check if the error is a postgres error
if errors.As(err, &pgErr) {
// If it is, check the specific error code
if pgErr.Code == "23505" { // Unique violation
return &ErrDuplicate{Key: item.ID}
}
}
// For other errors, wrap as before
return fmt.Errorf("failed to create item: %w", err)
}
return nil
}
Step 3: Inspecting with errors.As
In the calling code (e.g., an API handler), you can now check for this specific error type and extract its data.
// In an API handler...
err := s.storage.Create(newItem)
if err != nil {
var dupErr *ErrDuplicate
// Use errors.As to see if the error is our duplicate error type
if errors.As(err, &dupErr) {
// It is! We can now access the Key field.
// Return HTTP 409 Conflict with a detailed message.
return fmt.Errorf("conflict: item %s already present", dupErr.Key)
}
// Handle other errors, like our ErrNotFound, or generic 500s
}
This pattern of creating a custom struct, using errors.As, and checking for specific driver error codes is demonstrated perfectly in the resources.
Handling errors LIKE a 10x ENGINEER in Golang - Golang Service Pattern
The video 'Handling errors LIKE a 10x ENGINEER' shows a very similar pattern for creating a generic service error that wraps an application-specific error. It clearly demonstrates defining a custom error struct and using errors.As to inspect it.
First, watch from 02:27 to 03:37 to see how a custom error struct is defined to hold multiple underlying errors. Then, watch from 04:37 to 05:21 to see how errors.As is used to 'cast' the error back to the custom type to access its internal fields.
5. API Design Philosophy: To Wrap or Not to Wrap?
This is a critical point for experienced developers. When you encounter an error from a dependency, you have two choices:
- Translate it (Don't Wrap): Like we did with
pgx.ErrNoRows. We caught a specific error and returned our ownstorage.ErrNotFound. We intentionally hid the originalpgxerror. - Wrap it (
%w): Like we did for unexpected errors:fmt.Errorf("...: %w", err). We preserved the original error for debugging but added our own context.
The rule of thumb is: Wrapping an error makes it part of your package's public API.
If you wrap pgx.ErrNoRows, you are giving your callers the ability to use errors.Is(err, pgx.ErrNoRows) and create a dependency on pgx. You should only do this if you are willing to commit to that dependency forever. By translating the error instead, you preserve your abstraction boundary.
Working with Errors in Go 1.13
This is perhaps the most important concept in this lesson for system design. The official Go blog explains the API design implications of error wrapping with exceptional clarity.
Please read the section titled 'Whether to Wrap' very carefully. It explains why exposing internal errors (like sql.ErrNoRows in their example) by wrapping them can be a violation of abstraction.
Conclusion
Excellent work today. You've moved beyond basic error handling and have started to treat errors as a first-class part of your API design. This is a hallmark of robust, maintainable, and well-architected Go services.
Key Takeaways:
- Avoid "leaky abstractions" where implementation-specific errors (like
pgx.ErrNoRows) bubble up past your repository layer. - Define domain-specific errors (like
ErrNotFound) as part of your package's public contract. These can be sentinel errors (var Err...) or custom structs. - Use
errors.Isto check for sentinel errors anderrors.Asto check for and extract data from custom error types. - Make a conscious decision whether to translate an error (hiding the original) or to wrap it with
%w(exposing the original as part of your API).
Next Up
With our application now featuring a flexible, swappable backend and robust error handling, we are ready to tackle more production-oriented concerns. In the next lesson, we will begin Module 4 by exploring context propagation for managing request lifecycles, timeouts, and cancellations throughout our application.