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

Maneje los errores en el límite adecuado.

Índice

La gestión de errores en Go es un tema que suele generar quejas.

Todo desarrollador de Go ha escrito este código cientos de veces:

if err != nil {
	return err
}

Eso no es la parte interesante. La parte interesante es qué significa el error, dónde debe manejarse, dónde debe envolverse (wrap), dónde debe traducirse, dónde debe registrarse (log) 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 gestión de errores. Sin él, los errores se convierten en cadenas de texto aleatorias, los manejadores HTTP filtran detalles de la base de datos, los registros (logs) duplican el mismo fallo cinco veces, los reintentos ocurren por las razones equivocadas y los llamantes inspeccionan texto en lugar de comportamiento.

Arquitectura de gestión de errores en Go: errores fluyendo entre capas

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

Es una guía práctica sobre la arquitectura de gestión de errores en Go: envoltura (wrapping), sentinels, tipos de error personalizados, errors.Is, errors.As, límites de errores (error boundaries), mapeo de API, registro (logging), reintentos, seguridad y patrones de producción.

La versión ligeramente opinativa: no intentes hacer que los errores de Go desaparezcan. Haz que tengan significado en el límite adecuado.

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 la gestión de errores en Go se siente tan directa.

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 desenrollado de pila oculto. El fallo es parte de la firma de la función.

Esto es bueno, pero también significa que los errores necesitan diseño. Si cada paquete devuelve mensajes arbitrarios, los llamantes no pueden tomar decisiones fiables. Si cada capa envuelve cada error sin disciplina, los operadores obtienen mensajes ruidosos y los desarrolladores obtienen cadenas de errores confusas. Si ninguna capa envuelve los errores, los fallos pierden contexto.

El objetivo no es menos gestión 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 los humanos, el error debe 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 debe 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 al monitoreo
  • Ignorar la cancelación

Esa decisión generalmente debe 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 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 (sentinel errors).

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 inspección.

errors.Is

Usa errors.Is para verificar si un error coincide con un objetivo específico en alguna parte de su cadena:

if errors.Is(err, ErrNotFound) {
	// handle not found
}

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) {
	// use validationErr.Field or validationErr.Reason
}

Usa esto cuando el error lleva datos estructurados.

errors.Join

Usa errors.Join cuando ocurren múltiples errores y todos deben preservarse:

return errors.Join(closeErr, flushErr)

Los errores unidos aún pueden inspeccionarse con errors.Is y errors.As.

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

Errores centinela (Sentinel errors)

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

Luego, un servicio o manejador puede verificar:

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

Cuándo usar errores centinela

Usa errores centinela cuando:

  • La condición es estable.
  • El llamante necesita bifurcar (branch) sobre 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.

Mal:

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 sobre estos, pueden ser solo mensajes.

También ten cuidado con 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)
}

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 centinela suele ser suficiente.

Envoltura de errores (Error 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 envolver con %w valga la pena hacerlo consistentemente.

Envolver con contexto útil

Un buen 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 añadido es útil cuando los operadores rastrean un fallo a través de los registros.

No envolver solo para decir “falló”

Mal:

if err != nil {
	return fmt.Errorf("failed: %w", err)
}

La palabra “failed” (falló) suele estar implícita en 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 generalmente es 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 él:

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

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

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

Y devuelve eso desde el límite del paquete.

Límites de errores (Error boundaries)

La forma más útil de pensar sobre la gestión 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 principalmente 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 debe 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 debe 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 adecuado para errores a nivel de negocio; 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 de Go se equivocan; o bien exponen demasiado detalle interno o colapsan todos los errores en HTTP 500. Para una visión completa de los patrones de manejadores y middleware en APIs de Go, Construyendo APIs REST en Go cubre autenticación, enrutamiento y gestión 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 quieres 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 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, workers, 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, 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 deben 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) {
		// retry with 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 workers
  • ejecutar comprobaciones independientes
  • vaciar y cerrar salida

Usa errors.Join cuando todos los errores deben 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...)
}

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 completamente 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 principal y algún 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 gestión de errores

En código de aplicación normal, no uses panic para errores esperados.

Mal:

if err != nil {
	panic(err)
}

Usa panic para errores del programador o situaciones verdaderamente irrecuperables.

Ejemplos:

  • violación de invariante interno imposible
  • inicialización de paquete inválida
  • fallo de 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 (Logging errors)

Un error común en Go es registrar el mismo error en cada capa.

Mal:

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 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 registros de log/slog, manejadores JSON, correlación de contexto y redacción; todo lo cual se empareja 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 realmente 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 para operadores

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 para operadores:

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 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 bien un riesgo de exposición o una pesadilla de depuración. Diseña tu arquitectura de errores alrededor de valores distintos para consumidores distintos.

Gestión de errores segura

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 (stack traces)
  • datos privados de clientes
  • detalles de políticas de autorización

Esto es especialmente importante en APIs HTTP.

Mal:

http.Error(w, err.Error(), http.StatusInternalServerError)

Bien:

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 a menudo son mejores que depender solo de los mensajes.

Ejemplo de respuesta:

{
  "code": "user_not_found",
  "message": "user not found"
}

El mensaje puede cambiar. El código debe ser estable.

Usa códigos de error para:

  • comportamiento del cliente
  • documentación
  • SDKs
  • localización
  • diagnóstico 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 las 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 a 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 la gestión 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 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 de forma aislada.

Ejemplo completo

Aquí hay un pequeño ejemplo 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 sencilla para que un nuevo contribuyente la entienda, pero lo suficientemente estructurada para que la lógica de dominio nunca 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 centinelas, extracción de tipos, códigos HTTP; son a menudo donde los bugs se esconden más tiempo. Para una guía completa sobre estructura de pruebas en Go, mocks y patrones de cobertura, ver Pruebas Unitarias en Go: Estructura y Mejores Prácticas.

Probar 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 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 deben demostrar que los errores conocidos producen el comportamiento correcto en cada límite, para que refactorizar capas de almacenamiento o transporte no pueda cambiar silenciosamente el contrato de fallo.

Antipatrones comunes

Antipatrón 1: Coincidencia de cadenas

Mal:

if strings.Contains(err.Error(), "not found") {
	// ...
}

Usa errors.Is o errors.As en su lugar; ambos manejan cadenas de errores envueltos automáticamente y no se rompen cuando los mensajes se reformatean o localizan.

Antipatrón 2: Perder la causa

Mal:

return errors.New("query failed")

Mejor:

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

Antipatrón 3: Envolver sin significado

Mal:

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 diagnóstico.

Antipatrón 4: Registrar en cada capa

Mal:

log.Println(err)
return err

en cada nivel. Registra una vez donde el error se maneja finalmente, no en cada capa intermedia que simplemente lo pasa hacia arriba.

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

Mal:

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 los usuarios

Mal:

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 archivos o trazas de pila crudas en respuestas de API.

Antipatrón 7: Demasiados centinelas 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 bifurcar sobre ella; prefiere mantener los centinelas no exportados hasta que haya una necesidad clara.

Antipatrón 8: Usar panic para fallos esperados

Mal:

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

Mal:

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 genuino de operación y una solicitud cancelada o con tiempo agotado, y responder apropiadamente a cada uno. Para un tratamiento exhaustivo 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, ver Go context.Context Hecho Correctamente.

Lista de revisión de errores

Usa esta lista en la revisión de código.

Creación de errores

  • ¿Esta es una condición conocida?
  • ¿Debe ser un centinela?
  • ¿Necesita datos estructurados?
  • ¿Debe ser un tipo personalizado?
  • ¿El mensaje de error es claro?

Envoltura de errores

  • ¿La envoltura agrega contexto de operación útil?
  • ¿%w preserva la causa donde es necesario?
  • ¿El código está exponiendo accidentalmente detalles de implementación?
  • ¿La cadena es 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?
  • ¿Los errores de dominio son independientes de las preocupaciones HTTP o CLI?

Manejo de errores

  • ¿El llamante bifurca con errors.Is o errors.As?
  • ¿La cancelación de contexto y los tiempos de espera se manejan correctamente?
  • ¿Los errores reintentables están identificados explícitamente?
  • ¿Los errores de validación están estructurados?

Registro

  • ¿El error se registra una vez, en el límite de manejo?
  • ¿Los registros están estructurados?
  • ¿Los detalles sensibles están excluidos de las respuestas del usuario?
  • ¿Hay suficiente contexto para los operadores?

Pruebas

  • ¿Los casos de error conocidos están probados?
  • ¿Los mapeos HTTP o CLI están probados?
  • ¿Los detalles de validación están probados?
  • ¿Las decisiones de reintento están probadas?

Mis reglas opinadas

Regla 1: Los errores deben 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 diagnóstico.

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

No dejes que sql.ErrNoRows se convierta en parte de tu lógica de negocio. 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 o ORM está debajo.

Regla 4: No analizar cadenas de error

Si el código necesita bifurcar sobre el 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 los mensajes de error cambian.

Regla 5: Registrar una vez

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

Regla 6: Mantener mensajes de usuario seguros

Los errores de diagnóstico interno son para registros. Los mensajes orientados al usuario son para usuarios.

Regla 7: Mantener 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 debe ser reutilizable a través de transportes; hoy HTTP, mañana CLI, gRPC o un worker impulsado por eventos.

Pensamientos finales

La gestión de errores en Go no se trata de escribir if err != nil para siempre; se trata de hacer que el fracaso 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 la gestión de errores en Go hecha bien; no astuta, no mágica, pero lo suficientemente clara para que el próximo desarrollador, operador, cliente de API y tu futuro yo puedan entender qué falló y qué debería pasar después. Para una visión más amplia de los patrones de Go en producción a través de integración, pruebas y acceso a datos, ver Arquitectura de Aplicaciones en Producción.

Fuentes

Suscribirse

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