Arquitectura de manejo de errores en Go: límites y patrones
Maneja los errores en el límite adecuado.
El manejo de errores en Go es fácil de criticar. Todo desarrollador de Go ha escrito este código cientos de veces:
if err != nil {
return err
}
Esa no es la parte interesante. La parte interesante es lo que significa el error, dónde debe ser manejado, dónde debe ser envuelto, dónde debe ser traducido, dónde debe ser registrado y qué debe exponerse al llamante; esa es la pregunta de arquitectura.
Go trata los errores como valores. Eso hace que los fallos sean explícitos. También significa que tu base de código necesita un diseño claro de manejo de errores. Sin uno, los errores se convierten en cadenas aleatorias, los manejadores HTTP filtran detalles de la base de datos, los registros duplican el mismo fallo cinco veces, los reintentos ocurren por las razones equivocadas y los llamantes inspeccionan texto en lugar de comportamiento.

Este artículo no es una introducción para principiantes a if err != nil.
Es una guía práctica sobre la arquitectura de manejo de errores en Go: envoltura (wrapping), sentinels, tipos de error personalizados, errors.Is, errors.As, límites de errores, mapeo de API, registro (logging), reintentos, seguridad y patrones de producción.
La versión ligeramente de opinión: no intentes hacer que los errores de Go desaparezcan. Hazlos significativos en el límite correcto.
Qué son los errores en Go
En Go, un error es simplemente un valor que implementa esta interfaz:
type error interface {
Error() string
}
Esa pequeña interfaz es la razón por la que el manejo de errores en Go se siente tan directo.
Las funciones devuelven errores explícitamente:
func LoadUser(id string) (*User, error) {
// ...
}
Los llamantes deciden qué hacer:
user, err := LoadUser(id)
if err != nil {
return nil, err
}
No hay excepciones ni desenrollamiento de pila oculto. El fallo es parte de la firma de la función.
Eso es bueno, pero también significa que los errores necesitan diseño. Si cada paquete devuelve mensajes arbitrarios, los llamantes no pueden tomar decisiones confiables. Si cada capa envuelve cada error sin disciplina, los operadores obtienen mensajes ruidosos y los desarrolladores obtienen cadenas confusas. Si ninguna capa envuelve los errores, los fallos pierden contexto.
El objetivo no es menos manejo de errores, sino mejor significado de los errores.
Los tres trabajos de un error
Un error útil suele tener uno o más trabajos.
Trabajo 1: Explicar qué falló
Para los humanos, el error debería explicar qué operación falló.
Ejemplo:
return fmt.Errorf("load user %s: %w", id, err)
Esto proporciona contexto. Dice que el fallo ocurrió mientras se cargaba un usuario.
Trabajo 2: Preservar la causa
Para el código, el error debería preservar la causa subyacente cuando esa causa importa.
Ejemplo:
return fmt.Errorf("load user %s: %w", id, err)
El %w envuelve el error original para que los llamantes puedan inspeccionarlo con errors.Is o errors.As.
Trabajo 3: Permitir que un límite tome una decisión
En algún límite, el programa debe decidir qué hacer.
Ejemplos:
- Devolver HTTP 404
- Devolver HTTP 409
- Reintentar la operación
- Registrar en nivel de advertencia
- Mostrar un mensaje seguro para el usuario
- Abortar la transacción
- Enviar el error a la monitorización
- Ignorar la cancelación
Esa decisión generalmente debería basarse en la identidad o tipo del error, no en la coincidencia de cadenas.
Las principales herramientas de errores en Go moderno
Go moderno te ofrece un conjunto pequeño pero potente de herramientas.
errors.New
Usa errors.New para crear un valor de error simple:
var ErrNotFound = errors.New("not found")
Esto es útil para errores sentinela.
fmt.Errorf con %w
Usa fmt.Errorf con %w para envolver un error:
return fmt.Errorf("query user: %w", err)
La envoltura agrega contexto mientras preserva el error original para su inspección.
errors.Is
Usa errors.Is para verificar si un error coincide con un objetivo específico en algún lugar de su cadena:
if errors.Is(err, ErrNotFound) {
// manejar no encontrado
}
Usa esto para errores sentinela y condiciones conocidas.
errors.As
Usa errors.As para extraer un tipo de error específico de una cadena:
var validationErr *ValidationError
if errors.As(err, &validationErr) {
// usar validationErr.Field o validationErr.Reason
}
Usa esto cuando el error lleva datos estructurados.
errors.Join
Usa errors.Join cuando ocurren múltiples errores y todos deben ser preservados:
return errors.Join(closeErr, flushErr)
Los errores unidos aún pueden ser inspeccionados con errors.Is y errors.As.
Usa esto con cuidado. Un error unido significa que varios fallos forman parte de un solo resultado.
Errores sentinela
Un error sentinela es un valor de error a nivel de paquete que representa una condición conocida.
Ejemplo:
var ErrUserNotFound = errors.New("user not found")
var ErrDuplicateEmail = errors.New("duplicate email")
Los errores sentinela son útiles cuando el llamante solo necesita saber qué categoría de fallo ocurrió.
Ejemplo:
func (r *UserRepository) GetUser(ctx context.Context, id string) (*User, error) {
user, err := r.queryUser(ctx, id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
return nil, fmt.Errorf("query user: %w", err)
}
return user, nil
}
Entonces un servicio o manejador puede verificar:
if errors.Is(err, ErrUserNotFound) {
// devolver 404
}
Cuándo usar errores sentinela
Usa errores sentinela cuando:
- La condición es estable.
- El llamante necesita bifurcarse en ella.
- No se necesitan datos estructurados adicionales.
- El error pertenece a tu paquete o dominio.
Buenos ejemplos:
var ErrNotFound = errors.New("not found")
var ErrAlreadyExists = errors.New("already exists")
var ErrPermissionDenied = errors.New("permission denied")
var ErrConflict = errors.New("conflict")
Cuándo no usar errores sentinela
No crees sentinela para cada posible fallo.
Malo:
var ErrCouldNotOpenFile = errors.New("could not open file")
var ErrCouldNotReadFile = errors.New("could not read file")
var ErrCouldNotParseLine = errors.New("could not parse line")
Si los llamantes no bifurcan en estos, pueden ser solo mensajes.
También ten cuidado con exportar demasiados sentinela. Los errores sentinela exportados se convierten en parte de la API de tu paquete.
Tipos de error personalizados
Un tipo de error personalizado es útil cuando el error lleva información estructurada.
Ejemplo:
type ValidationError struct {
Field string
Reason string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation failed for %s: %s", e.Field, e.Reason)
}
Llamante:
var validationErr *ValidationError
if errors.As(err, &validationErr) {
fmt.Println(validationErr.Field)
}
Esto es mejor que analizar una cadena de error.
Cuándo usar tipos de error personalizados
Usa tipos de error personalizados cuando:
- Los llamantes necesitan datos estructurados.
- El error tiene campos significativos.
- El tipo es parte del contrato de tu paquete.
- El llamante puede necesitar manejar múltiples valores de manera diferente.
Ejemplos:
- Error de validación con nombre de campo
- Error de límite de tasa con tiempo de reintento
- Error HTTP con código de estado
- Error de análisis con línea y columna
- Error de dominio con ID de recurso
Cuándo no usar tipos de error personalizados
No crees tipos personalizados solo para evitar errors.New.
Esto es innecesario:
type NotFoundError struct{}
func (e NotFoundError) Error() string {
return "not found"
}
Si no hay datos útiles, un sentinela suele ser suficiente.
Envoltura de errores (Wrapping)
La envoltura agrega contexto a un error mientras preserva el error original.
Ejemplo:
func LoadConfig(path string) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read config %s: %w", path, err)
}
if err := parseConfig(data); err != nil {
return fmt.Errorf("parse config %s: %w", path, err)
}
return nil
}
Si os.ReadFile falla, el llamante obtiene ambos:
- la operación de alto nivel: leer configuración
- la causa de bajo nivel: permiso denegado, archivo no encontrado, etc.
Ambos están disponibles a través de la cadena de errores, lo cual es lo que hace que la envoltura con %w valga la pena hacer consistentemente.
Envolver con contexto útil
Una buena envoltura dice qué operación falló:
return fmt.Errorf("create invoice %s: %w", invoiceID, err)
Una mala envoltura agrega ruido:
return fmt.Errorf("error: %w", err)
Esto no le dice nada al llamante.
También evita repetir el mismo sustantivo en cada capa:
return fmt.Errorf("user service: get user: user repository: query user: %w", err)
Ese tipo de cadena es técnicamente correcta y prácticamente molesta.
Envuelve donde el contexto cambia de significado. Si no puedes explicar en una frase qué operación falló, probablemente estás envolviendo demasiado agresivamente o no lo suficiente.
Cuándo envolver y cuándo no envolver
Esta es una de las decisiones de arquitectura más importantes.
Envolver al cruzar un límite significativo
Envuelve cuando el error se mueve de una operación a una operación de nivel superior.
Ejemplo:
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
user, err := s.repo.GetUser(ctx, id)
if err != nil {
return nil, fmt.Errorf("get user %s: %w", id, err)
}
return user, nil
}
El error del repositorio ahora es parte de una operación de servicio, y ese contexto adicional es útil cuando los operadores rastrean un fallo a través de los registros.
No envolver solo para decir “falló”
Malo:
if err != nil {
return fmt.Errorf("failed: %w", err)
}
La palabra “falló” generalmente se implica por el hecho de que existe un error.
No envolver si estás traduciendo
A veces debes traducir un error a otro error de dominio.
Ejemplo:
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
Esto oculta intencionalmente el detalle de la base de datos y expone una condición de dominio.
Puedes preservar la causa si es útil, pero hazlo deliberadamente.
No exponer detalles de implementación accidentalmente
Si envuelves un error de bajo nivel con %w, los llamantes pueden inspeccionarlo.
Eso suele ser bueno dentro de tu aplicación.
Pero en una API de paquete pública, la envoltura puede exponer detalles de implementación como parte de tu contrato.
Por ejemplo, si tu paquete envuelve sql.ErrNoRows, los llamantes pueden empezar a depender de ello:
if errors.Is(err, sql.ErrNoRows) {
// el llamante ahora sabe que usas database/sql
}
Si puedes cambiar el almacenamiento más tarde, prefiere un sentinela de dominio:
var ErrUserNotFound = errors.New("user not found")
Entonces devuelve eso desde el límite del paquete.
Límites de errores
La forma más útil de pensar sobre el manejo de errores en Go es a través de límites.
Un límite es un lugar donde un error cambia de significado o audiencia.
Los límites comunes incluyen:
- base de datos a repositorio
- repositorio a servicio
- servicio a manejador HTTP
- servicio a comando CLI
- error interno a mensaje orientado al usuario
- fallo transitorio a decisión de reintento
- fallo de operación a evento de registro
- error de dominio a respuesta de API
La arquitectura de errores es en gran parte diseño de límites. Cada límite es un punto de decisión donde los errores ganan contexto, pierden detalles de implementación o se traducen a una forma en la que la siguiente capa pueda actuar.
Límite del repositorio
El repositorio habla con el almacenamiento.
Generalmente debería traducir errores específicos de la base de datos a errores de dominio.
Ejemplo:
var ErrUserNotFound = errors.New("user not found")
var ErrDuplicateEmail = errors.New("duplicate email")
type UserRepository struct {
db *sql.DB
}
func (r *UserRepository) GetUser(ctx context.Context, id string) (*User, error) {
const query = `
select id, email, name
from users
where id = $1
`
var user User
err := r.db.QueryRowContext(ctx, query, id).Scan(
&user.ID,
&user.Email,
&user.Name,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
return nil, fmt.Errorf("query user by id: %w", err)
}
return &user, nil
}
El repositorio oculta sql.ErrNoRows y expone ErrUserNotFound: un límite limpio que significa que el servicio no necesita saber nada sobre cómo el almacenamiento representa “no encontrado”.
Límite del servicio
El servicio posee el significado empresarial.
Generalmente debería agregar contexto de operación y preservar errores de dominio.
Ejemplo:
type UserService struct {
repo *UserRepository
}
func (s *UserService) GetUser(ctx context.Context, id string) (*User, error) {
user, err := s.repo.GetUser(ctx, id)
if err != nil {
if errors.Is(err, ErrUserNotFound) {
return nil, err
}
return nil, fmt.Errorf("get user %s: %w", id, err)
}
return user, nil
}
Esto preserva la condición de dominio mientras agrega contexto para errores inesperados.
Para reglas de negocio más complejas, el servicio puede crear errores de dominio directamente:
var ErrAccountDisabled = errors.New("account disabled")
func (s *UserService) Login(ctx context.Context, email string) (*Session, error) {
user, err := s.repo.GetUserByEmail(ctx, email)
if err != nil {
return nil, fmt.Errorf("get user by email: %w", err)
}
if user.Disabled {
return nil, ErrAccountDisabled
}
// ...
return session, nil
}
El servicio es el lugar correcto para errores de nivel empresarial: creados directamente desde la lógica de dominio en lugar de traducidos desde condiciones de infraestructura.
Límite del manejador HTTP
El manejador HTTP traduce errores de la aplicación a respuestas HTTP.
Este es un límite donde los detalles internos deben convertirse en respuestas seguras para el usuario.
Ejemplo:
func GetUserHandler(svc *UserService) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
user, err := svc.GetUser(r.Context(), r.PathValue("id"))
if err != nil {
writeHTTPError(w, err)
return
}
writeJSON(w, http.StatusOK, user)
}
}
Mapeo de errores:
func writeHTTPError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, ErrUserNotFound):
http.Error(w, "user not found", http.StatusNotFound)
case errors.Is(err, ErrAccountDisabled):
http.Error(w, "account disabled", http.StatusForbidden)
case errors.Is(err, context.Canceled):
return
case errors.Is(err, context.DeadlineExceeded):
http.Error(w, "request timed out", http.StatusGatewayTimeout)
default:
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}
El manejador mapea errores de dominio a semánticas HTTP en lugar de exponer detalles crudos de la base de datos o errores internos. Aquí es donde muchas aplicaciones Go se equivocan: o exponen demasiados detalles internos o colapsan todos los errores en HTTP 500. Para una imagen completa de patrones de manejadores y middleware en APIs Go, Building REST APIs in Go cubre autenticación, enrutamiento y manejo de errores a través de la biblioteca estándar, Gin, Echo y Fiber.
Límite de CLI
Un CLI tiene un límite diferente al de una API HTTP.
En un CLI, el error debe ser útil para la persona que ejecuta el comando.
Ejemplo:
func RunImport(ctx context.Context, args []string) error {
if len(args) == 0 {
return ErrMissingInputFile
}
if err := importFile(ctx, args[0]); err != nil {
return fmt.Errorf("import %s: %w", args[0], err)
}
return nil
}
En el límite del comando:
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, formatCLIError(err))
os.Exit(exitCode(err))
}
}
Mapea errores conocidos a códigos de salida:
func exitCode(err error) int {
switch {
case errors.Is(err, ErrMissingInputFile):
return 2
case errors.Is(err, ErrValidation):
return 3
default:
return 1
}
}
Un CLI a menudo puede mostrar más detalle que una API pública, pero aún debe evitar filtrar secretos.
Patrón de tipo de error de API
Para APIs HTTP, un pequeño tipo de error a nivel de aplicación puede ser útil.
Ejemplo:
type APIError struct {
Status int
Code string
Message string
Err error
}
func (e *APIError) Error() string {
if e.Err == nil {
return e.Message
}
return e.Message + ": " + e.Err.Error()
}
func (e *APIError) Unwrap() error {
return e.Err
}
Constructor:
func NewAPIError(status int, code string, message string, err error) *APIError {
return &APIError{
Status: status,
Code: code,
Message: message,
Err: err,
}
}
Uso:
return NewAPIError(
http.StatusConflict,
"duplicate_email",
"email is already registered",
ErrDuplicateEmail,
)
Manejador:
func writeAPIError(w http.ResponseWriter, err error) {
var apiErr *APIError
if errors.As(err, &apiErr) {
writeJSON(w, apiErr.Status, map[string]string{
"code": apiErr.Code,
"message": apiErr.Message,
})
return
}
writeJSON(w, http.StatusInternalServerError, map[string]string{
"code": "internal_error",
"message": "internal server error",
})
}
Este patrón es útil cuando deseas errores de API estructurados con códigos estables.
Úsalo en el límite de la API. No fuerces a cada paquete interno a devolver errores específicos de la API.
Errores de dominio vs errores de transporte
Mantén los errores de dominio separados de los errores de transporte.
Error de dominio:
var ErrInsufficientBalance = errors.New("insufficient balance")
Mapeo de transporte:
if errors.Is(err, ErrInsufficientBalance) {
http.Error(w, "insufficient balance", http.StatusConflict)
return
}
No hagas que tu capa de dominio devuelva códigos de estado HTTP:
return &APIError{Status: http.StatusConflict}
Eso acopla la lógica empresarial a HTTP y evita que tu capa de servicio funcione limpiamente a través de HTTP, CLI, trabajadores, pruebas y futuros adaptadores gRPC. El mapeo de transporte pertenece en el límite de transporte, no en el código de dominio. Para orientación sobre dónde definir errores de dominio, sentinela y adaptadores de transporte dentro de tu estructura de proyecto, Go Project Structure: Practices & Patterns cubre las convenciones de internal/, pkg/ y adaptadores que mantienen estas capas separadas limpiamente.
Errores reintentables
Algunos errores deberían desencadenar un reintento. Otros no.
No decidas esto coincidiendo cadenas.
Usa una interfaz de marcador o una función explícita.
Ejemplo:
type RetryableError struct {
Err error
}
func (e *RetryableError) Error() string {
return e.Err.Error()
}
func (e *RetryableError) Unwrap() error {
return e.Err
}
Ayudante:
func Retryable(err error) error {
if err == nil {
return nil
}
return &RetryableError{Err: err}
}
func IsRetryable(err error) bool {
var retryable *RetryableError
return errors.As(err, &retryable)
}
Uso:
if err := callRemoteAPI(ctx); err != nil {
if isTemporaryNetworkError(err) {
return Retryable(fmt.Errorf("call remote api: %w", err))
}
return fmt.Errorf("call remote api: %w", err)
}
Bucle de reintento:
err := doWork(ctx)
if err != nil {
if IsRetryable(err) {
// reintentar con backoff
}
return err
}
Esto es mucho mejor que verificar si la cadena de error contiene “timeout”: la coincidencia de cadenas se rompe silenciosamente cuando los mensajes cambian y crea un acoplamiento invisible entre productor y consumidor.
Errores de validación
Los errores de validación a menudo necesitan datos estructurados.
Ejemplo:
type FieldError struct {
Field string
Message string
}
type ValidationError struct {
Fields []FieldError
}
func (e *ValidationError) Error() string {
return "validation failed"
}
Uso:
func ValidateCreateUser(req CreateUserRequest) error {
var fields []FieldError
if req.Email == "" {
fields = append(fields, FieldError{
Field: "email",
Message: "email is required",
})
}
if len(fields) > 0 {
return &ValidationError{Fields: fields}
}
return nil
}
Manejador:
var validationErr *ValidationError
if errors.As(err, &validationErr) {
writeJSON(w, http.StatusBadRequest, validationErr)
return
}
Este es un buen uso de errors.As porque el llamante necesita información estructurada: nombres de campos y mensajes de validación, no solo una cadena de error opaca.
Múltiples errores
A veces varias cosas fallan.
Ejemplos:
- cerrar múltiples recursos
- validar muchos campos
- detener varios trabajadores
- ejecutar comprobaciones independientes
- vaciar y cerrar salida
Usa errors.Join cuando todos los errores deben ser preservados.
Ejemplo:
func CloseAll(closers ...io.Closer) error {
var errs []error
for _, closer := range closers {
if err := closer.Close(); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
Llamante:
if err := CloseAll(a, b, c); err != nil {
return fmt.Errorf("close resources: %w", err)
}
Tanto errors.Is como errors.As pueden inspeccionar errores unidos, lo que significa que los valores de error unidos permanecen totalmente compatibles con los patrones estándar de verificación de errores.
Cuándo no usar errors.Join
No uses errors.Join cuando hay un error primario y algo de contexto de registro.
No lo uses para evitar decidir qué error importa.
No devuelvas errores unidos enormes a los usuarios.
Los errores unidos son útiles, pero pueden volverse ruidosos rápidamente.
Panic no es manejo de errores
En código de aplicación normal, no uses panic para errores esperados.
Malo:
if err != nil {
panic(err)
}
Usa panic para errores del programador o situaciones verdaderamente irrecuperables.
Ejemplos:
- violación imposible de invariante interna
- inicialización de paquete inválida
- fallo del ayudante de prueba con
t.Fatalo panic en casos limitados - error de configuración de inicio irrecuperable, dependiendo del estilo
No uses panic porque una consulta de base de datos falló o un usuario envió entrada inválida.
Esos son errores normales.
Registro de errores (Logging)
Un error común en Go es registrar el mismo error en cada capa.
Malo:
func (r *Repo) GetUser(ctx context.Context, id string) (*User, error) {
user, err := r.query(ctx, id)
if err != nil {
log.Printf("query failed: %v", err)
return nil, err
}
return user, nil
}
func (s *Service) GetUser(ctx context.Context, id string) (*User, error) {
user, err := s.repo.GetUser(ctx, id)
if err != nil {
log.Printf("service failed: %v", err)
return nil, err
}
return user, nil
}
Esto crea registros duplicados para un solo fallo.
Mejor:
- envolver errores a medida que suben
- registrar una vez en el límite donde el error es manejado
- incluir contexto estructurado en el registro
Ejemplo:
func (s *Server) handleError(r *http.Request, err error) {
s.logger.ErrorContext(
r.Context(),
"request failed",
"method", r.Method,
"path", r.URL.Path,
"err", err,
)
}
Esto da un evento de registro con la cadena de errores completa. Para una configuración de registro estructurado lista para producción, Structured Logging in Go with slog cuba registros log/slog, manejadores JSON, correlación de contexto y redacción, todos los cuales se emparejan naturalmente con el registro de errores a nivel de límite.
Cuándo registrar dentro de capas inferiores
Registra dentro de capas inferiores solo cuando la capa está manejando el error o agregando contexto operativo importante que no será visible en otro lugar.
Por ejemplo, un bucle de reintento puede registrar cada intento de reintento en nivel de depuración o advertencia.
Pero un repositorio no debería registrar cada error de consulta si el manejador registrará el fallo final de la solicitud.
Errores orientados al usuario vs errores de operador
No muestres errores internos directamente a los usuarios.
Error interno:
query user by id: dial tcp 10.0.4.12:5432: connection refused
Mensaje orientado al usuario:
internal server error
Registro de operador:
request failed err="get user 123: query user by id: dial tcp 10.0.4.12:5432: connection refused"
Estas son audiencias diferentes, y una buena arquitectura de errores las mantiene separadas:
- error de diagnóstico interno
- respuesta segura para el usuario
- código de error de API estable
- contexto de registro para operadores
Forzar una cadena de error para servir a todas estas audiencias produce o un riesgo de exposición o una pesadilla de depuración. Diseña tu arquitectura de errores alrededor de valores distintos para consumidores distintos.
Manejo de errores seguro
Los errores pueden filtrar información sensible.
Evita exponer:
- cadenas de conexión de base de datos
- consultas SQL con secretos
- nombres de host internos
- rutas de archivos
- tokens de acceso
- claves de API
- trazas de pila
- datos privados de clientes
- detalles de políticas de autorización
Esto es especialmente importante en APIs HTTP.
Malo:
http.Error(w, err.Error(), http.StatusInternalServerError)
Bueno:
http.Error(w, "internal server error", http.StatusInternalServerError)
Registra el error interno de forma segura para los operadores. Devuelve un mensaje seguro al usuario.
Códigos de error
Para APIs públicas, los códigos de error estables suelen ser mejores que depender solo de mensajes.
Ejemplo de respuesta:
{
"code": "user_not_found",
"message": "user not found"
}
El mensaje puede cambiar. El código debería ser estable.
Usa códigos de error para:
- comportamiento del cliente
- documentación
- SDKs
- localización
- diagnósticos de soporte
No hagas que los clientes analicen mensajes de error en inglés.
Un diseño de errores en capas práctico
Aquí hay un patrón limpio para muchos servicios backend en Go.
Capa de repositorio
- Habla con la base de datos o almacenamiento externo.
- Convierte errores de no encontrado específicos del almacenamiento a errores de dominio.
- Envuelve errores de almacenamiento inesperados con contexto de operación.
- No devuelve errores HTTP.
- Generalmente no registra.
Ejemplo:
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
return nil, fmt.Errorf("query user by id: %w", err)
Capa de servicio
- Posee reglas de negocio.
- Crea errores de dominio.
- Preserva errores de dominio conocidos.
- Envuelve errores de nivel inferior inesperados.
- No devuelve códigos de estado HTTP.
- Generalmente no registra.
Ejemplo:
if user.Disabled {
return nil, ErrAccountDisabled
}
Capa de transporte
- Mapea errores de dominio a respuestas HTTP, gRPC o CLI.
- Registra errores no manejados o inesperados.
- Oculta detalles internos de los usuarios.
- Establece códigos de estado y códigos de error de API.
Ejemplo:
switch {
case errors.Is(err, ErrUserNotFound):
writeError(w, http.StatusNotFound, "user_not_found", "user not found")
default:
writeError(w, http.StatusInternalServerError, "internal_error", "internal server error")
}
Esta separación mantiene el manejo de errores comprensible y permite que cada capa evolucione independientemente: puedes cambiar la tecnología de almacenamiento sin tocar la lógica del servicio o el mapeo de transporte. El diseño en capas funciona mejor cuando las dependencias se inyectan en lugar de codificarse; Dependency Injection in Go: Patterns & Best Practices cubre los patrones de constructor e interfaz que hacen que cada límite sea fácil de probar de forma aislada.
Ejemplo completo
Aquí hay un ejemplo pequeño de extremo a extremo.
Errores de dominio:
package users
import "errors"
var (
ErrUserNotFound = errors.New("user not found")
ErrDuplicateEmail = errors.New("duplicate email")
ErrAccountDisabled = errors.New("account disabled")
)
Repositorio:
package users
import (
"context"
"database/sql"
"errors"
"fmt"
)
type Repository struct {
db *sql.DB
}
func (r *Repository) GetByID(ctx context.Context, id string) (*User, error) {
const query = `
select id, email, name, disabled
from users
where id = $1
`
var user User
err := r.db.QueryRowContext(ctx, query, id).Scan(
&user.ID,
&user.Email,
&user.Name,
&user.Disabled,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUserNotFound
}
return nil, fmt.Errorf("query user by id: %w", err)
}
return &user, nil
}
Servicio:
package users
import (
"context"
"errors"
"fmt"
)
type Service struct {
repo *Repository
}
func (s *Service) GetProfile(ctx context.Context, id string) (*Profile, error) {
user, err := s.repo.GetByID(ctx, id)
if err != nil {
if errors.Is(err, ErrUserNotFound) {
return nil, err
}
return nil, fmt.Errorf("get profile for user %s: %w", id, err)
}
if user.Disabled {
return nil, ErrAccountDisabled
}
return &Profile{
ID: user.ID,
Email: user.Email,
Name: user.Name,
}, nil
}
Manejador HTTP:
package httpapi
import (
"context"
"errors"
"net/http"
"example.com/app/users"
)
type Handler struct {
users *users.Service
}
func (h *Handler) GetProfile(w http.ResponseWriter, r *http.Request) {
profile, err := h.users.GetProfile(r.Context(), r.PathValue("id"))
if err != nil {
h.writeError(w, err)
return
}
writeJSON(w, http.StatusOK, profile)
}
func (h *Handler) writeError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, users.ErrUserNotFound):
writeJSON(w, http.StatusNotFound, map[string]string{
"code": "user_not_found",
"message": "user not found",
})
case errors.Is(err, users.ErrAccountDisabled):
writeJSON(w, http.StatusForbidden, map[string]string{
"code": "account_disabled",
"message": "account is disabled",
})
case errors.Is(err, context.Canceled):
return
case errors.Is(err, context.DeadlineExceeded):
writeJSON(w, http.StatusGatewayTimeout, map[string]string{
"code": "request_timeout",
"message": "request timed out",
})
default:
writeJSON(w, http.StatusInternalServerError, map[string]string{
"code": "internal_error",
"message": "internal server error",
})
}
}
Esta estructura te da:
- errores de dominio
- traducción de almacenamiento
- contexto de servicio
- mapeo HTTP seguro
- cadenas de errores inspeccionables
- sin coincidencia de cadenas
- sin filtración de transporte en el código de dominio
Ese es el tipo de arquitectura de errores que escala: lo suficientemente straightforward para que un nuevo contribuidor la entienda, pero lo suficientemente estructurada para que la lógica de dominio nunca se filtre en las respuestas de transporte.
Probando el comportamiento de errores
El comportamiento de errores debe probarse tan exhaustivamente como el camino feliz, porque las decisiones de límite: mapeo de sentinela, extracción de tipo, códigos HTTP — son a menudo donde los bugs se esconden más tiempo. Para una guía completa sobre estructura de pruebas Go, mocking y patrones de cobertura, ver Go Unit Testing: Structure & Best Practices.
Probar mapeo de sentinela
func TestGetByIDNotFound(t *testing.T) {
repo := newTestRepository(t)
_, err := repo.GetByID(t.Context(), "missing")
if !errors.Is(err, users.ErrUserNotFound) {
t.Fatalf("got %v, want ErrUserNotFound", err)
}
}
Probar extracción de error personalizado
func TestValidationError(t *testing.T) {
err := ValidateCreateUser(CreateUserRequest{})
var validationErr *ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("got %T, want ValidationError", err)
}
if len(validationErr.Fields) == 0 {
t.Fatal("expected validation fields")
}
}
Probar mapeo HTTP
func TestWriteErrorNotFound(t *testing.T) {
rec := httptest.NewRecorder()
writeHTTPError(rec, users.ErrUserNotFound)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound)
}
}
Las pruebas deberían demostrar que los errores conocidos producen el comportamiento correcto en cada límite, para que la refactorización de capas de almacenamiento o transporte no pueda cambiar silenciosamente el contrato de fallo.
Antipatrones comunes
Antipatrón 1: Coincidencia de cadenas
Malo:
if strings.Contains(err.Error(), "not found") {
// ...
}
Usa errors.Is o errors.As en su lugar: ambos manejan cadenas de errores envueltas automáticamente y no se rompen cuando los mensajes se reformatan o localizan.
Antipatrón 2: Perder la causa
Malo:
return errors.New("query failed")
Mejor:
return fmt.Errorf("query user: %w", err)
Antipatrón 3: Envolver sin significado
Malo:
return fmt.Errorf("error happened: %w", err)
Envuelve con contexto de operación que explique qué se estaba intentando, como "create invoice %s: %w" en lugar de un prefijo vago que no agrega valor de diagnóstico.
Antipatrón 4: Registrar en cada capa
Malo:
log.Println(err)
return err
en cada nivel. Registra una vez donde el error es finalmente manejado, no en cada capa intermedia que simplemente lo pasa hacia arriba.
Antipatrón 5: Devolver errores HTTP desde código de dominio
Malo:
return &APIError{Status: http.StatusNotFound}
desde un servicio de dominio. Mapea errores de dominio a códigos de estado HTTP y cuerpos de respuesta en el límite del manejador, manteniendo tu capa de servicio independiente de las preocupaciones de transporte.
Antipatrón 6: Exponer errores internos a usuarios
Malo:
http.Error(w, err.Error(), http.StatusInternalServerError)
Devuelve mensajes genéricos seguros a los usuarios y registra el error interno completo con contexto estructurado para operadores. Nunca expongas cadenas de conexión de base de datos, rutas de archivos o trazas de pila crudas en respuestas de API.
Antipatrón 7: Demasiados sentinela exportados
Los errores exportados son parte de tu API de paquete, y agregarlos te compromete a mantenerlos. No exportes cada condición interna a menos que los llamantes externos realmente necesiten bifurcarse en ella: prefiere mantener los sentinela no exportados hasta que haya una necesidad clara.
Antipatrón 8: Usar panic para fallos esperados
Malo:
panic(err)
para fallos de tiempo de ejecución normales. Reserva panic para condiciones verdaderamente irrecuperables o errores del programador, no para registros faltantes o entrada de usuario inválida: siempre devuelve errores en esos casos.
Antipatrón 9: Ignorar errores de contexto
Malo:
return fmt.Errorf("request failed")
cuando la causa real fue context.Canceled. Preserva los errores de contexto para que los llamantes puedan distinguir entre un fallo de operación genuino y una solicitud cancelada o con tiempo de espera, y responder apropiadamente a cada uno.
Lista de verificación de revisión de errores
Usa esta lista de verificación en la revisión de código.
Creación de errores
- ¿Es esta una condición conocida?
- ¿Debería ser un sentinela?
- ¿Necesita datos estructurados?
- ¿Debería ser un tipo personalizado?
- ¿Es el mensaje de error claro?
Envoltura de errores
- ¿Agrega la envoltura contexto de operación útil?
- ¿
%wpreserva la causa donde es necesario? - ¿El código está exponiendo accidentalmente detalles de implementación?
- ¿Es la cadena demasiado ruidosa?
Traducción de errores
- ¿Se traduce un error de bajo nivel en el límite correcto?
- ¿Se oculta el comportamiento específico de la base de datos del código del servicio?
- ¿Son los errores de dominio independientes de las preocupaciones HTTP o CLI?
Manejo de errores
- ¿El llamante bifurca con
errors.Isoerrors.As? - ¿Se manejan correctamente la cancelación de contexto y los plazos?
- ¿Se identifican explícitamente los errores reintentables?
- ¿Son los errores de validación estructurados?
Registro (Logging)
- ¿Se registra el error una vez, en el límite de manejo?
- ¿Son los registros estructurados?
- ¿Se excluyen los detalles sensibles de las respuestas al usuario?
- ¿Hay suficiente contexto para los operadores?
Pruebas
- ¿Se prueban los casos de error conocidos?
- ¿Se prueban los mapeos HTTP o CLI?
- ¿Se prueban los detalles de validación?
- ¿Se prueban las decisiones de reintento?
Mis reglas de opinión
Regla 1: Los errores deberían cruzar límites con significado
No solo pases errores de un lado a otro. Decide qué significan en cada capa.
Regla 2: Envolver para contexto, no para decoración
Si la envoltura no agrega información útil sobre qué operación falló, no envuelvas. Una capa extra de contexto sin significado hace que la cadena de errores sea más difícil de leer y no agrega valor de diagnóstico.
Regla 3: Traducir errores de implementación a errores de dominio
No permitas que sql.ErrNoRows se convierta en parte de tu lógica empresarial. Traduce errores de implementación a errores de dominio en el límite de almacenamiento, para que el resto de la aplicación nunca necesite saber qué base de datos u ORM hay debajo.
Regla 4: No analizar cadenas de errores
Si el código necesita bifurcar en el tipo de fallo, usa sentinela, tipos personalizados, errors.Is o errors.As. La inspección de cadenas crea un acoplamiento invisible que se rompe silenciosamente cuando los mensajes de error cambian.
Regla 5: Registrar una vez
Envuelve a medida que los errores suben. Registra donde el error es finalmente manejado.
Regla 6: Mantener los mensajes de usuario seguros
Los errores de diagnóstico interno son para registros. Los mensajes orientados al usuario son para usuarios.
Regla 7: Mantener los errores de transporte en el límite de transporte
Los códigos de estado HTTP pertenecen en manejadores o adaptadores de API, no en servicios de dominio. El código de dominio debería ser reutilizable a través de transportes: hoy HTTP, mañana CLI, gRPC o un trabajador impulsado por eventos.
Pensamientos finales
El manejo de errores en Go no se trata de escribir if err != nil para siempre: se trata de hacer que el fallo sea explícito y comprensible en cada límite.
La mecánica es simple:
return errors
wrap with %w
check with errors.Is
extract with errors.As
join when several errors matter
La arquitectura es la parte más difícil:
translate at boundaries
preserve causes
hide internals from users
log once
test known failures
Eso es el manejo de errores en Go bien hecho: no ingenioso, no mágico, pero lo suficientemente claro para que el siguiente desarrollador, operador, cliente de API y el tú del futuro puedan entender qué falló y qué debería pasar a continuación. Para una visión más amplia de los patrones de producción Go a través de integración, pruebas y acceso a datos, ver App Architecture in Production.