Arquitectura de manejo de errores en Go: límites y patrones

Manejar los errores en el límite adecuado.

Índice

Manejar errores en Go es fácil de criticar. Todos los desarrolladores de Go han escrito este código cientos de veces:

if err != nil {
	return err
}

Esa no es la parte interesante. La parte interesante es qué significa el error, dónde debería manejarse, dónde debería envolverse, dónde debería traducirse, dónde debería registrarse y qué debería exponerse al llamador; 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 para el manejo de errores. Sin uno, los errores se convierten en cadenas aleatorias, los manejadores de 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 llamadores inspeccionan el texto en lugar del comportamiento.

Arquitectura del manejo de errores en Go: errores fluyendo entre capas

Este artículo no es una introducción para principiantes a if err != nil.

Es una guía práctica sobre la arquitectura del manejo de errores en Go: envolturas, errores centinela, tipos de error personalizados, errors.Is, errors.As, límites de errores, mapeo de API, registro, reintentos, seguridad y patrones de producción.

La versión ligeramente opinada: 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 llamadores deciden qué hacer:

user, err := LoadUser(id)
if err != nil {
	return nil, err
}

No hay excepciones ni desenrollado oculto de la pila de llamadas. 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 llamadores 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 un 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 humanos, el error debería explicar qué operación falló.

Ejemplo:

return fmt.Errorf("load user %s: %w", id, err)

Esto da 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 llamadores puedan inspeccionarlo con errors.Is o errors.As. Envolver tiene una nueva trampa en Go 1.27: la verificación vet detrás de go test ahora advierte sobre fmt.Errorf("...: %w", p) cuando p es un puntero a un tipo que él mismo implementa error, porque la indirección extra rompe las afirmaciones de tipo y el emparejamiento de errors.Is. La Guía de actualización a Go 1.27 cubre esto y las otras detecciones de vet que surgen después de aumentar la versión de la cadena de herramientas.

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 monitoreo
  • Ignorar cancelación

Esa decisión normalmente debe basarse en la identidad o el tipo de error, no en el emparejamiento de cadenas.

Las principales herramientas de error en Go moderno

Go moderno te da un conjunto pequeño pero poderoso 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 centinela.

fmt.Errorf con %w

Usa fmt.Errorf con %w para envolver un error:

return fmt.Errorf("query user: %w", err)

Envolver añade contexto mientras preserva el error original para 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 centinela 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 ocurrieron múltiples errores y todos deberían preservarse:

return errors.Join(closeErr, flushErr)

Los errores unidos todavía pueden inspeccionarse con errors.Is y errors.As.

Úsalo con cuidado. Un error unido significa que varios fallos forman parte de un solo resultado.

Errores centinela

Un error centinela 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 centinela son útiles cuando el llamador 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
}

Luego, un servicio o manejador puede verificar:

if errors.Is(err, ErrUserNotFound) {
	// devolver 404
}

Cuándo usar errores centinela

Usa errores centinela cuando:

  • La condición es estable.
  • El llamador necesita hacer una bifurcación en función de 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 centinela

No crees centinelas 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 llamadores no hacen bifurcaciones con estos, pueden ser solo mensajes.

También ten cuidado de exportar demasiados centinelas. Los errores centinela 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)
}

Llamador:

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 llamadores necesitan datos estructurados.
  • El error tiene campos significativos.
  • El tipo es parte del contrato de tu paquete.
  • El llamador 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 de 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 centinela suele ser suficiente.

Envoltura de errores

Envolver añade 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 llamador 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, que es lo que hace que envolver con %w valga la pena hacerlo de forma consistente.

Envolver con contexto útil

Un buen envolturado dice qué operación falló:

return fmt.Errorf("create invoice %s: %w", invoiceID, err)

Un mal envolturado añade ruido:

return fmt.Errorf("error: %w", err)

Esto no le dice nada al llamador.

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 el 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

Esta es una de las decisiones de arquitectura más importantes.

Envolver cuando cruza 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 añadido 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 “failed” (falló) generalmente se implica por el hecho de que existe un error.

No envolver si estás traduciendo

A veces deberías traducir un error a otro error de dominio.

Ejemplo:

if errors.Is(err, sql.ErrNoRows) {
	return nil, ErrUserNotFound
}

Esto intencionalmente oculta el detalle de la base de datos y expone una condición de dominio.

También 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 llamadores pueden inspeccionarlo.

Eso suele ser bueno dentro de tu aplicación.

Pero en una API de paquete pública, envolver puede exponer detalles de implementación como parte de tu contrato.

Por ejemplo, si tu paquete envuelve sql.ErrNoRows, los llamadores pueden comenzar a depender de él:

if errors.Is(err, sql.ErrNoRows) {
	// el llamador ahora sabe que usas database/sql
}

Si puedes cambiar el almacenamiento más adelante, prefiere un centinela de dominio:

var ErrUserNotFound = errors.New("user not found")

Luego 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 de audiencia.

Los límites comunes incluyen:

  • de base de datos a repositorio
  • de repositorio a servicio
  • de servicio a manejador de HTTP
  • de servicio a comando de CLI
  • de error interno a mensaje para el usuario
  • de fallo transitorio a decisión de reintento
  • de fallo de operación a evento de registro
  • de 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 puede actuar.

Límite de repositorio

El repositorio habla con el almacenamiento.

Normalmente debería traducir los errores específicos de la base de datos en 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 de servicio

El servicio posee el significado de negocio.

Normalmente debería añadir 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 añade 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 a nivel de negocio: creados directamente desde la lógica de dominio en lugar de traducidos desde condiciones de infraestructura.

Límite de manejador de HTTP

El manejador de HTTP traduce los errores de la aplicación en respuestas de HTTP.

Este es un límite donde los detalles internos deben convertirse en respuestas seguras para el usuario.

Los marcos web difieren en cómo estructuran esta traducción. Echo enrutaa los errores del manejador a través de un único HTTPErrorHandler, mientras que los manejadores de Gin renderizan los errores directamente en el contexto de la solicitud; las contrapartidas entre los dos estilos se comparan en Marcos web de Go en 2026: net/http, chi, Gin, Echo, Fiber comparados.

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 los errores de dominio a semánticas de HTTP en lugar de exponer detalles crudos de la base de datos o errores internos. Es aquí donde muchas aplicaciones de Go se equivocan: ya sea que exponen demasiados detalles internos o colapsan todos los errores en HTTP 500. Para una imagen completa de los patrones de manejador y el middleware en APIs de Go, Construir APIs REST en Go cubre la autenticación, el enrutamiento y el manejo de errores a través de la librería estándar, Gin, Echo y Fiber.

Límite de CLI

Una CLI tiene un límite diferente al de una API de HTTP.

En una CLI, el error debería 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
	}
}

Una CLI a menudo puede mostrar más detalle que una API pública, pero todavía debería evitar filtrar secretos.

Patrón de tipo de error de API

Para APIs de 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 quieres errores de API estructurados con códigos estables.

Úsalo en el límite de API. No fuerces que cada paquete interno devuelva errores específicos de 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 de HTTP:

return &APIError{Status: http.StatusConflict}

Eso acopla la lógica de negocio a HTTP e impide que tu capa de servicio funcione limpiamente a través de HTTP, CLI, trabajadores, pruebas y adaptadores futuros de 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, centinelas y adaptadores de transporte dentro de tu estructura de proyecto, Estructura de proyectos en Go: Prácticas y Patrones cubre las convenciones de internal/, pkg/ y adaptadores que mantienen estas capas limpiamente separadas.

Errores reintentables

Algunos errores deberían desencadenar un reintento. Otros no.

No decidas esto mediante el emparejamiento de 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 retroceso
	}
	return err
}

Esto es mucho mejor que verificar si la cadena de error contiene “timeout” (tiempo agotado): el emparejamiento de cadenas se rompe silenciosamente cuando los mensajes cambian y crea un acoplamiento invisible entre el productor y el 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 llamador necesita información estructurada: nombres de campos y mensajes de validación: no solo una cadena de error opaca.

Múltiples errores

A veces fallan varias cosas.

Ejemplos:

  • cerrar múltiples recursos
  • validar muchos campos
  • apagar varios trabajadores
  • ejecutar comprobaciones independientes
  • volcar y cerrar la salida

Usa errors.Join cuando todos los errores deberían preservarse.

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...)
}

Llamador:

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 siguen siendo 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 el 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 una invariante interna
  • inicialización de paquete inválida
  • fallo del ayudante de prueba con t.Fatal o panic en casos limitados
  • error de configuración de inicio irrecuperable, dependiendo del estilo

No hagas panic porque una consulta de base de datos falló o un usuario envió entrada inválida.

Esos son errores normales.

Registro de errores

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 los errores a medida que suben
  • registrar una vez en el límite donde el error se maneja
  • 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, Registro estructurado en Go con slog cubre los registros de log/slog, los manejadores JSON, la correlación de contexto y la redacción: todos los cuales se combinan naturalmente con el registro de errores a nivel de límite.

Cuándo registrar en capas inferiores

Registra en capas inferiores solo cuando la capa está manejando realmente el error o añadiendo un 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 para el usuario vs errores para el 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 para el usuario:

internal server error

Registro del 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 el operador

Forzar una sola cadena de error para servir a todas estas audiencias produce ya sea 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 archivo
  • tokens de acceso
  • claves de API
  • trazas de pila
  • datos privados de clientes
  • detalles de política de autorización

Esto es especialmente importante en APIs de 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 por capas práctico

Aquí hay un patrón limpio para muchos servicios backend de Go.

Capa de repositorio

  • Habla con la base de datos o el almacenamiento externo.
  • Convierte los errores de no encontrado específicos del almacenamiento en errores de dominio.
  • Envuelve los errores de almacenamiento inesperados con contexto de operación.
  • No devuelve errores de HTTP.
  • Normalmente 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 de HTTP.
  • Normalmente no registra.

Ejemplo:

if user.Disabled {
	return nil, ErrAccountDisabled
}

Capa de transporte

  • Mapea errores de dominio a respuestas de 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 de forma independiente: puedes cambiar la tecnología de almacenamiento sin tocar la lógica de servicio o el mapeo de transporte. El diseño por capas funciona mejor cuando las dependencias se inyectan en lugar de estar codificadas; Inyección de dependencias en Go: Patrones y Mejores Prácticas cubre los patrones de constructor e interfaz que hacen que cada límite sea fácil de probar en aislamiento.

Ejemplo completo

Aquí hay un pequeño ejemplo de principio a fin.

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 de 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 seguro de HTTP
  • cadenas de errores inspeccionables
  • sin emparejamiento de cadenas
  • sin filtrado de transporte al código de dominio

Ese es el tipo de arquitectura de errores que escala: lo bastante sencillo para que un contribuyente nuevo la entienda, pero lo bastante estructurada para que la lógica de dominio nunca filtre en las respuestas de transporte.

Pruebas del comportamiento de errores

El comportamiento de los errores debería probarse tan a fondo como el camino feliz, porque las decisiones de límite: mapeo de centinelas, extracción de tipos, códigos de HTTP: a menudo es donde los bugs se ocultan más tiempo. Para una guía completa sobre la estructura de pruebas en Go, el simulacro y los patrones de cobertura, consulta Pruebas unitarias en Go: Estructura y Mejores Prácticas.

Probar el mapeo de centinelas

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 la extracción de errores personalizados

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 el mapeo de 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 las capas de almacenamiento o transporte no pueda cambiar silenciosamente el contrato de fallo.

Antipatrones comunes

Antipatrón 1: Emparejamiento 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 añade 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 finalmente se maneja, no en cada capa intermedia que simplemente lo pasa hacia arriba.

Antipatrón 5: Devolver errores de HTTP desde el código de dominio

Malo:

return &APIError{Status: http.StatusNotFound}

desde un servicio de dominio. Mapea los errores de dominio a códigos de estado de 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 los 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 los operadores. Nunca expongas cadenas de conexión de base de datos, rutas de archivo o trazas de pila crudas en respuestas de API.

Antipatrón 7: Demasiados centinelas exportados

Los errores exportados son parte de la API de tu paquete, y añadirlos te compromete a mantenerlos. No exportes cada condición interna a menos que los llamadores externos realmente necesiten hacer bifurcaciones con ella: prefiere mantener los centinelas 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 era context.Canceled. Preserva los errores de contexto para que los llamadores puedan distinguir entre un fallo genuino de la operación y una solicitud cancelada o con tiempo agotado, y responder apropiadamente a cada una. Para un tratamiento detallado de cómo funciona la cancelación de contexto y la propagación de tiempos de espera a través de las capas de servicio, consulta Go context.Context bien hecho.

Lista de verificación para la 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 centinela?
  • ¿Necesita datos estructurados?
  • ¿Debería ser un tipo personalizado?
  • ¿Es el mensaje de error claro?

Envoltura de errores

  • ¿Añade el envolturado contexto de operación útil?
  • ¿Preserva %w la causa donde se necesita?
  • ¿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 de servicio?
  • ¿Son los errores de dominio independientes de las preocupaciones de HTTP o CLI?

Manejo de errores

  • ¿El llamador hace bifurcaciones con errors.Is o errors.As?
  • ¿Se manejan correctamente la cancelación de contexto y los límites de tiempo?
  • ¿Se identifican explícitamente los errores reintentables?
  • ¿Son estructurados los errores de validación?

Registro

  • ¿Se registra el error una vez, en el límite de manejo?
  • ¿Son estructurados los registros?
  • ¿Se excluyen 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 de HTTP o CLI?
  • ¿Se prueban los detalles de validación?
  • ¿Se prueban las decisiones de reintento?

Mis reglas con opinión

Regla 1: Los errores deberían cruzar límites con significado

No solo pases los errores de un lado a otro. Decide qué significan en cada capa.

Regla 2: Envolver para contexto, no para decoración

Si el envolturado no añade 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 añade valor de diagnóstico.

Regla 3: Traducir errores de implementación en errores de dominio

No dejes que sql.ErrNoRows se convierta en parte de tu lógica de negocio. Traduce los 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 error

Si el código necesita hacer bifurcaciones en función del tipo de fallo, usa centinelas, tipos personalizados, errors.Is o errors.As. La inspección de cadenas crea un acoplamiento invisible que se rompe silenciosamente cuando cambian los mensajes de error.

Regla 5: Registrar una vez

Envuelve a medida que los errores suben. Registra donde el error finalmente se maneja.

Regla 6: Mantener los mensajes del usuario seguros

Los errores de diagnóstico interno son para registros. Los mensajes para el usuario son para los usuarios.

Regla 7: Mantener los errores de transporte en el límite de transporte

Los códigos de estado de HTTP pertenecen en los manejadores o adaptadores de API, no en los servicios de dominio. El código de dominio debería ser reutilizable a través de los transportes: hoy HTTP, mañana CLI, gRPC o un trabajador basado en 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 astuto, no mágico, pero lo bastante claro para que el siguiente desarrollador, operador, cliente de API y tú en el 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 de Go a través de la integración, las pruebas y el acceso a datos, consulta Arquitectura de aplicaciones en producción.

Fuentes

Suscribirse

Recibe nuevas publicaciones sobre sistemas, infraestructura e ingeniería de IA.