Domain (decoupled) errors in Go

José Carlos Chávez
devthoughts
Published in
5 min readFeb 28, 2018
Unrelated picture to catch your interest

While working with Go you may notice that an idiomatic pattern for propagating errors is to return the error as the last value in the function. For example:

package usertype User struct {
...
}
type Repository interface {
// Returns a user.User having the provided userID
GetUser(userID string) (*User, error)
}

Repository is an interface, so let’s have a look at the MySQL implementation:

package mysqltype usersRepository struct {
db *sqlx.DB
}
const getUserQuery := "SELECT * FROM users WHERE user_id=$1"func (ur *usersRepository) GetUser(userID string) (*User, error) {
user := &User{}

if len(userID) != 6 {
return nil, errors.New("Invalid user ID.")
}

if err := ur.db.Get(user, getUserQuery, userID); err != nil {
return nil, err
}

return user, nil
}

This method works pretty well and does what it is expected, however when using it up in the stack:

package main// The handler
func GetUser(c web.C, w http.ResponseWriter, r *http.Request) {
userID := c.URLParams["user_id"]

user, err := repository.getUser(userID)
if err != nil {
if err == sql.ErrNoRows {
// Returns 404
} else {
// Presumably the invalid ID error.
}
}
...
}

This approach has a couple of problems:

  • The handler must now know the implementation details: in this specific case, handler has to know how to deal with sql errors, for example, it should know that error sql.ErrNoRows matches with a 404 status code which makes the usage of interfaces pointless, coupling to specific implementations.
  • Since the handler must be aware of each error type returned by repository.getUser, introducing a new storage will be a very difficult task as you also need to change the handler to treat the new implementation errors.

What about defining errors along with the interface?

That is a good idea, such a method describes a use case and that use case should include its own use case errors:

package uservar (
ErrInvalidUserID = errors.New("userID should be 6 length string")
ErrUserNotFound = errors.New("user not found")
)
type repository interface{
// Returns the ErrInvalidUserID error when a user ID is malformed.
// Returns the ErrUserNotFound error when the user can not be found.
// Returns the ErrPersistanceFailure error when there is a persistance failure.
// Returns a user.User having the provided userID
GetUser(userID string) (*User, error)
}

And the MySQL implementation might look like:

package mysqltype userRepository struct {
db *sqlx.DB
}
const getUserQuery := "SELECT * FROM users WHERE user_id=$1"func (ur *usersRepository) GetUser(userID string) (*user.User, error) {
user := &user.User{}
if len(userID) != 6 {
return nil, user.ErrInvalidUserID
}

if err := ur.db.Get(user, getUserQuery, userID); err != nil {
if err == sql.NoRows {
return nil, user.ErrUserNotFound
} else {
return nil, user.ErrPersistanceFailure
}
}

return user, nil
}

Then when using it in handlers:

package main// The handler
func getUser(c web.C, w http.ResponseWriter, r *http.Request) {
userID := c.URLParams["user_id"]

user, err := repository.getUser(userID)
if err != nil {
if err == user.ErrUserNotFound {
// Returns 404
} else if err == user.ErrInvalidUserID{
// Returns 403
}

// Returns 500
}
...
}

That looks great, but there is a lack of context in the errors. Domain errors lose all the context of the implementation details which makes it impossible to debug an error. A log record of this error looks like:

2017/09/24 09:47:40.071222 [ERROR] user not found

So what is underneath this user not found error? Is it that there are no records for this ID? Is it that the users table is locked? Is it that the database is down? It could be any of these three reasons, but when returning the domain error, we lose all the context of the underlying error.

pkg/errors to the rescue.

Fortunately there is a very smart package that solves this problem. pkg/errors provides a method that wraps an error, providing context from bottom to top while keeping the underlying error as cause.

package mysqltype userRepository struct {
db *sqlx.DB
}
const getUserQuery := "SELECT * FROM users WHERE user_id=$1"func (ur *usersRepository) GetUser(userID string) (*User, error) {
user := &User{}

if len(userID) != 6 {
return nil, errors.Wrapf(user.ErrInvalidUserID, "%s is not 6-long string", userID)
}

if err := ur.db.Get(u, getUserQuery, userID); err != nil {
if err == sql.NoRows {
return nil, errors.Wrap(user.ErrUserNotFound, err.Error())
} else {
return nil, errors.Wrap(user.ErrPersistanceFailure, err.Error())
}
}

return user, nil
}

And the handler now looks like:

package main// The handler
func getUser(c web.C, w http.ResponseWriter, r *http.Request) {
userID := c.URLParams["user_id"]
user, err := repository.getUser(userID)
if err != nil {
if errors.Cause(err) == user.ErrUserNotFound {
// Returns 404, and drop err.Error() to the logs
} else if errors.Cause(err) == user.ErrInvalidUserID {
// Returns 403, and drop err.Error() to the logs
}
// Returns 500
}
...
}

So this log record now looks like:

2017/09/24 09:47:40.071222 [ERROR] user could not be found. database/sql: no rows

Are there any drawbacks?

I would not say drawbacks, but you will certainly feel like

func (r *userRepository) GetActiveUser() (*user.User, error) {
userID := r.GetActiveUserID()
return r.GetUser(userID)
}

becomes

func (r *userRepository) GetActiveUser() (*user.User, error) {
userID := r.GetActiveUserID()
user, err := r.GetUser(userID)
if err != nil {
return nil, errors.Wrap(ErrActiveUserNotFound, err.Error())
}
return user, err
}

which is a few lines longer, but not too much.

Another valid concern is that since errors are variables and exported they can be changed in other packages, that is true however it sounds to me more as a culture problem than a problem of the approach itself.

Wrap up

We tackled some problems here:

  • Errors are not describing an implementation error anymore, but a use-case (application) error, making the error handling implementation agnostic (decoupling, decoupling, decoupling)
  • Errors carry context. A concern could be that when wrapping an error the implementation details become a non-structured string (err.Error()) but that is OK, you do not need structured information up in the stack, otherwise that will end up in coupling.
  • Having context in errors is crucial when debugging, this approach allows us not only to keep the error context to one layer up, but to the top of the stack, without coupling to implementation details:
2017/09/24 09:47:40.071222 [ERROR] active user not found. user could not be found. database/sql: no rows

Other approaches

There is a very educative post from Dave Cheney about handling errors in go. Worth to read and choose what works better for you.

--

--

José Carlos Chávez
devthoughts

Software Engineer @Traceableai, ex @ExpediaGroup. Wine lover and Llama ambassador