Desarrollo backend

Validación y transformación de datos en Go: construcción de una capa de entrada robusta para REST API sin frameworks

Ruslan Ismailov Publicado 14 min de lectura
V

Introducción: por qué la validación es mucho más que verificar campos

La validación de datos de entrada es una de esas tareas que los desarrolladores subestiman hasta que ocurre el primer incidente grave. Parece sencillo: comprobar que email no esté vacío y contenga @. Pero en la práctica, una capa de validación mal diseñada destruye la arquitectura: la lógica de negocio se mezcla con el parseo HTTP, los objetos de dominio aceptan estados inválidos y los errores se devuelven al cliente en distintos formatos según quién haya escrito cada handler.

En el ecosistema de Go es habitual construir REST APIs sin frameworks pesados, y eso es lo correcto. La biblioteca estándar net/http proporciona todo lo necesario. Pero precisamente en ese contexto las decisiones arquitectónicas sobre validación deben tomarse de forma independiente. En este artículo veremos cómo construir una capa de validación y transformación de datos robusta, testeable y mantenible para REST APIs en Go.

Panorama de enfoques de validación en Go

Validación manual

El enfoque más simple: escribir las comprobaciones manualmente en el cuerpo del handler o del método. Ventajas: control total, sin dependencias, fácil de leer. Desventajas: duplicación de código, difícil recopilar todos los errores en un solo paso, las reglas se dispersan por toda la base de código.

Etiquetas de struct (struct tags)

Un enfoque popular en Go: las reglas de validación se escriben en las etiquetas de los campos de la estructura. Cómodo para casos simples, pero las etiquetas se vuelven ilegibles rápidamente, no admiten lógica condicional compleja y vinculan las reglas de validación a la estructura de datos, violando el principio de separation of concerns.

Biblioteca go-playground/validator

La solución más popular en el ecosistema. Admite etiquetas, validadores personalizados y localización. Ventajas: amplio conjunto de reglas integradas, comunidad activa. Desventajas: el uso de reflection bajo el capó afecta al rendimiento, la API puede resultar poco intuitiva y las reglas personalizadas mediante RegisterValidation son difíciles de testear de forma aislada. En servicios de alto rendimiento conviene evaluar este compromiso.

En este artículo implementaremos nuestra propia capa de validación ligera, indicando dónde se puede sustituir o complementar con go-playground/validator.

Diseño de la capa de validación: dónde viven las reglas

La pregunta arquitectónica clave es: ¿dónde debe vivir la validación? La respuesta depende del tipo de comprobación:

  • Validación sintáctica (formato de email, longitud de cadena, campos obligatorios): nivel del handler o middleware. Son la «puerta de entrada»; los datos estructuralmente incorrectos no deben llegar más lejos.
  • Validación semántica (unicidad del email en la base de datos, restricciones de negocio): nivel del usecase/servicio. Aquí se dispone de acceso a repositorios y al contexto de negocio.
  • Invariantes del modelo de dominio (por ejemplo, el importe de un pedido no puede ser negativo): nivel de dominio, en constructores o métodos de fábrica.

Mezclar estos niveles es la principal razón por la que la validación rompe la arquitectura. El handler no debe conocer las reglas de negocio, y el objeto de dominio no debe parsear peticiones HTTP.

Implementación de un validador personalizado

Definiremos la interfaz y los primitivos básicos. La idea: el validador recopila todos los errores en un único paso y los devuelve como lista, sin detenerse en el primero.

// validation/validator.go
package validation

import (
	"fmt"
	"net/mail"
	"regexp"
	"strings"
)

// FieldError describe el error de un campo específico.
type FieldError struct {
	Field   string `json:"field"`
	Message string `json:"message"`
	Code    string `json:"code"`
}

func (e FieldError) Error() string {
	return fmt.Sprintf("%s: %s", e.Field, e.Message)
}

// ValidationErrors — lista de errores de validación.
type ValidationErrors []FieldError

func (ve ValidationErrors) Error() string {
	msgs := make([]string, len(ve))
	for i, e := range ve {
		msgs[i] = e.Error()
	}
	return strings.Join(msgs, "; ")
}

// Rule — función-regla: recibe el valor del campo y devuelve un error o nil.
type Rule func(field, value string) *FieldError

// FieldValidator acumula reglas para un campo concreto.
type FieldValidator struct {
	field string
	value string
	rules []Rule
}

// Validator — objeto raíz que contiene los conjuntos de reglas para todos los campos.
type Validator struct {
	fields []*FieldValidator
}

// Field inicia la cadena de reglas para un campo.
func (v *Validator) Field(field, value string) *FieldValidator {
	fv := &FieldValidator{field: field, value: value}
	v.fields = append(v.fields, fv)
	return fv
}

// Required verifica que el campo no esté vacío.
func (fv *FieldValidator) Required() *FieldValidator {
	fv.rules = append(fv.rules, func(field, value string) *FieldError {
		if strings.TrimSpace(value) == "" {
			return &FieldError{Field: field, Message: "campo obligatorio", Code: "required"}
		}
		return nil
	})
	return fv
}

// MinLen verifica la longitud mínima de la cadena.
func (fv *FieldValidator) MinLen(min int) *FieldValidator {
	fv.rules = append(fv.rules, func(field, value string) *FieldError {
		if len([]rune(value)) < min {
			return &FieldError{
				Field:   field,
				Message: fmt.Sprintf("longitud mínima: %d caracteres", min),
				Code:    "min_length",
			}
		}
		return nil
	})
	return fv
}

// MaxLen verifica la longitud máxima de la cadena.
func (fv *FieldValidator) MaxLen(max int) *FieldValidator {
	fv.rules = append(fv.rules, func(field, value string) *FieldError {
		if len([]rune(value)) > max {
			return &FieldError{
				Field:   field,
				Message: fmt.Sprintf("longitud máxima: %d caracteres", max),
				Code:    "max_length",
			}
		}
		return nil
	})
	return fv
}

// Email verifica el formato de la dirección de correo electrónico.
func (fv *FieldValidator) Email() *FieldValidator {
	fv.rules = append(fv.rules, func(field, value string) *FieldError {
		_, err := mail.ParseAddress(value)
		if err != nil {
			return &FieldError{Field: field, Message: "email incorrecto", Code: "invalid_email"}
		}
		return nil
	})
	return fv
}

// Matches verifica la correspondencia con una expresión regular.
func (fv *FieldValidator) Matches(pattern, code, message string) *FieldValidator {
	re := regexp.MustCompile(pattern)
	fv.rules = append(fv.rules, func(field, value string) *FieldError {
		if !re.MatchString(value) {
			return &FieldError{Field: field, Message: message, Code: code}
		}
		return nil
	})
	return fv
}

// Validate ejecuta todas las reglas y devuelve la lista de errores.
func (v *Validator) Validate() ValidationErrors {
	var errs ValidationErrors
	for _, fv := range v.fields {
		for _, rule := range fv.rules {
			if err := rule(fv.field, fv.value); err != nil {
				errs = append(errs, *err)
				// Continuamos — recopilamos todos los errores del campo
			}
		}
	}
	if len(errs) == 0 {
		return nil
	}
	return errs
}

Este diseño ofrece una API fluida, fácil de leer y de testear. Cada regla es una función pura sin efectos secundarios.

Transformación de datos: patrón DTO y mapeo a objetos de dominio

Un DTO (Data Transfer Object) es una estructura que describe la forma de la petición entrante. No es un objeto de dominio y no contiene lógica de negocio. Tras la validación, el DTO se transforma en el modelo de dominio.

// dto/user.go
package dto

import (
	"strings"
	"time"

	"myapp/domain"
	"myapp/validation"
)

// CreateUserRequest — DTO de la petición entrante para crear un usuario.
type CreateUserRequest struct {
	Name     string `json:"name"`
	Email    string `json:"email"`
	Password string `json:"password"`
	Role     string `json:"role"`
}

// Validate realiza la validación sintáctica del DTO.
func (r *CreateUserRequest) Validate() validation.ValidationErrors {
	v := &validation.Validator{}
	v.Field("name", r.Name).Required().MinLen(2).MaxLen(100)
	v.Field("email", r.Email).Required().Email()
	v.Field("password", r.Password).Required().MinLen(8).MaxLen(72)
	v.Field("role", r.Role).Required().Matches(
		`^(admin|user|moderator)$`,
		"invalid_role",
		"roles permitidos: admin, user, moderator",
	)
	return v.Validate()
}

// ToDomain transforma el DTO en un objeto de dominio.
// Se invoca únicamente tras una validación exitosa.
func (r *CreateUserRequest) ToDomain() domain.CreateUserParams {
	return domain.CreateUserParams{
		Name:      strings.TrimSpace(r.Name),
		Email:     strings.ToLower(strings.TrimSpace(r.Email)),
		Password:  r.Password, // el hash se realiza en el nivel del usecase
		Role:      domain.Role(r.Role),
		CreatedAt: time.Now().UTC(),
	}
}

Principio importante: el método ToDomain() se invoca únicamente tras una validación exitosa. Puede realizar normalización de datos — strings.TrimSpace, conversión a minúsculas, conversión de tipos — pero no lógica de negocio.

Gestión y devolución de errores de validación: RFC 7807 Problem Details

El RFC 7807 define un formato estándar de respuesta de error para APIs HTTP. Usar este estándar hace que la API sea predecible para los clientes y compatible con la documentación OpenAPI.

// apierror/problem.go
package apierror

import (
	"encoding/json"
	"net/http"

	"myapp/validation"
)

// ProblemDetail corresponde al RFC 7807.
type ProblemDetail struct {
	Type     string       `json:"type"`
	Title    string       `json:"title"`
	Status   int          `json:"status"`
	Detail   string       `json:"detail,omitempty"`
	Instance string       `json:"instance,omitempty"`
	Errors   []FieldError `json:"errors,omitempty"`
}

// FieldError — campo en la respuesta de error.
type FieldError struct {
	Field   string `json:"field"`
	Message string `json:"message"`
	Code    string `json:"code"`
}

// WriteValidationError escribe la respuesta con los errores de validación.
func WriteValidationError(w http.ResponseWriter, r *http.Request, errs validation.ValidationErrors) {
	fields := make([]FieldError, len(errs))
	for i, e := range errs {
		fields[i] = FieldError{Field: e.Field, Message: e.Message, Code: e.Code}
	}

	problem := ProblemDetail{
		Type:     "https://api.example.com/errors/validation",
		Title:    "Validation Error",
		Status:   http.StatusUnprocessableEntity,
		Detail:   "Los datos enviados contienen errores.",
		Instance: r.RequestURI,
		Errors:   fields,
	}

	w.Header().Set("Content-Type", "application/problem+json")
	w.WriteHeader(http.StatusUnprocessableEntity)
	json.NewEncoder(w).Encode(problem)
}

// WriteInternalError escribe la respuesta de error interno del servidor.
func WriteInternalError(w http.ResponseWriter, r *http.Request) {
	problem := ProblemDetail{
		Type:     "https://api.example.com/errors/internal",
		Title:    "Internal Server Error",
		Status:   http.StatusInternalServerError,
		Instance: r.RequestURI,
	}
	w.Header().Set("Content-Type", "application/problem+json")
	w.WriteHeader(http.StatusInternalServerError)
	json.NewEncoder(w).Encode(problem)
}

Ejemplo de respuesta al cliente ante errores de validación:

HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json

{
  "type": "https://api.example.com/errors/validation",
  "title": "Validation Error",
  "status": 422,
  "detail": "Los datos enviados contienen errores.",
  "instance": "/api/v1/users",
  "errors": [
    {"field": "email", "message": "email incorrecto", "code": "invalid_email"},
    {"field": "password", "message": "longitud mínima: 8 caracteres", "code": "min_length"}
  ]
}

Localización de los mensajes de error

Si la API sirve a varios idiomas, los mensajes de error deben devolverse en el idioma del cliente. Un enfoque sencillo es mapear los códigos de error a cadenas localizadas.

// validation/i18n.go
package validation

type Locale string

const (
	LocaleES Locale = "es"
	LocaleEN Locale = "en"
)

// messages contiene las traducciones por código de error y localización.
var messages = map[Locale]map[string]string{
	LocaleES: {
		"required":      "campo obligatorio",
		"min_length":    "el valor es demasiado corto",
		"max_length":    "el valor es demasiado largo",
		"invalid_email": "dirección de correo electrónico no válida",
	},
	LocaleEN: {
		"required":      "field is required",
		"min_length":    "value is too short",
		"max_length":    "value is too long",
		"invalid_email": "invalid email address",
	},
}

// Translate devuelve el mensaje localizado según el código de error.
func Translate(locale Locale, code string) string {
	if msgs, ok := messages[locale]; ok {
		if msg, ok := msgs[code]; ok {
			return msg
		}
	}
	// Fallback al inglés
	if msg, ok := messages[LocaleEN][code]; ok {
		return msg
	}
	return code
}

El idioma se determina a partir de la cabecera Accept-Language y se pasa al handler a través del contexto de la petición.

Testing de la capa de validación

Una de las principales ventajas del enfoque descrito es la testeabilidad. Los validadores son funciones puras sin dependencias. Usamos pruebas basadas en tablas (table-driven tests):

// validation/validator_test.go
package validation_test

import (
	"testing"

	"myapp/validation"
)

func TestEmailValidation(t *testing.T) {
	tests := []struct {
		name     string
		email    string
		wantErr  bool
		wantCode string
	}{
		{name: "valid email", email: "user@example.com", wantErr: false},
		{name: "empty email", email: "", wantErr: true, wantCode: "required"},
		{name: "no at sign", email: "notanemail", wantErr: true, wantCode: "invalid_email"},
		{name: "no domain", email: "user@", wantErr: true, wantCode: "invalid_email"},
		{name: "with spaces", email: "user @example.com", wantErr: true, wantCode: "invalid_email"},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			v := &validation.Validator{}
			v.Field("email", tt.email).Required().Email()
			errs := v.Validate()

			if tt.wantErr && len(errs) == 0 {
				t.Errorf("se esperaba un error, pero la validación fue exitosa")
			}
			if !tt.wantErr && len(errs) > 0 {
				t.Errorf("no se esperaba error, pero se obtuvo: %v", errs)
			}
			if tt.wantErr && len(errs) > 0 && tt.wantCode != "" {
				found := false
				for _, e := range errs {
					if e.Code == tt.wantCode {
						found = true
					}
				}
				if !found {
					t.Errorf("se esperaba el código %q, pero no está en los errores: %v", tt.wantCode, errs)
				}
			}
		})
	}
}

// TestCreateUserRequestValidate testea el DTO completo.
func TestCreateUserRequestValidate(t *testing.T) {
	tests := []struct {
		name       string
		input      dto.CreateUserRequest
		wantFields []string // campos en los que se esperan errores
	}{
		{
			name: "valid input",
			input: dto.CreateUserRequest{
				Name: "Juan García", Email: "juan@example.com",
				Password: "securePass1", Role: "user",
			},
			wantFields: nil,
		},
		{
			name: "missing name and invalid role",
			input: dto.CreateUserRequest{
				Name: "", Email: "juan@example.com",
				Password: "securePass1", Role: "superadmin",
			},
			wantFields: []string{"name", "role"},
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			errs := tt.input.Validate()
			errFields := map[string]bool{}
			for _, e := range errs {
				errFields[e.Field] = true
			}
			for _, f := range tt.wantFields {
				if !errFields[f] {
					t.Errorf("se esperaba error en el campo %q, pero no existe", f)
				}
			}
			if len(tt.wantFields) == 0 && len(errs) > 0 {
				t.Errorf("no se esperaban errores, pero se obtuvo: %v", errs)
			}
		})
	}
}

Integración con middleware: validación automática a nivel del router

Para no duplicar el código de parseo y validación en cada handler, lo extraemos a un middleware. Usamos genéricos (Go 1.18+) para decodificación con seguridad de tipos.

// middleware/validate.go
package middleware

import (
	"context"
	"encoding/json"
	"net/http"

	"myapp/apierror"
	"myapp/validation"
)

// Validatable — interfaz para DTOs con método de validación.
type Validatable interface {
	Validate() validation.ValidationErrors
}

// contextKey — tipo para las claves de contexto, para evitar colisiones.
type contextKey[T any] struct{}

// DecodeAndValidate — fábrica de middleware para decodificar y validar el cuerpo de la petición.
// Usa genéricos: T debe implementar Validatable.
func DecodeAndValidate[T Validatable](next func(http.ResponseWriter, *http.Request, T)) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		var dto T

		// Limitamos el tamaño del cuerpo de la petición (protección contra DoS)
		r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MB

		decoder := json.NewDecoder(r.Body)
		decoder.DisallowUnknownFields() // modo estricto

		if err := decoder.Decode(&dto); err != nil {
			apierror.WriteBadRequestError(w, r, "JSON no válido")
			return
		}

		if errs := dto.Validate(); errs != nil {
			apierror.WriteValidationError(w, r, errs)
			return
		}

		next(w, r, dto)
	}
}

Ejemplo de uso en el handler:

// handler/user.go
package handler

import (
	"net/http"

	"myapp/dto"
	"myapp/middleware"
	"myapp/usecase"
)

type UserHandler struct {
	userUC usecase.UserUseCase
}

func (h *UserHandler) Register(mux *http.ServeMux) {
	// middleware.DecodeAndValidate se encarga del decodificado y la validación
	mux.HandleFunc("POST /api/v1/users",
		middleware.DecodeAndValidate(h.createUser),
	)
}

func (h *UserHandler) createUser(w http.ResponseWriter, r *http.Request, req dto.CreateUserRequest) {
	// Aquí req ya es válido — se puede transformar de forma segura
	params := req.ToDomain()

	user, err := h.userUC.CreateUser(r.Context(), params)
	if err != nil {
		// Gestión de errores de negocio
		return
	}

	w.WriteHeader(http.StatusCreated)
	// ... serialización de la respuesta
	_ = user
}

Este enfoque elimina por completo el boilerplate de los handlers. El handler recibe un DTO ya validado y se ocupa únicamente de su tarea: orquestar la lógica de negocio.

Conclusión: checklist de una buena capa de validación

Para cerrar, resumimos los atributos clave de una capa de validación de calidad en una REST API con Go:

  • Separation of concerns: validación sintáctica en el handler/middleware, semántica en el usecase, invariantes en el dominio.
  • Todos los errores en un único paso: el cliente debe recibir la lista completa de problemas, no corregirlos uno a uno.
  • El DTO está separado del objeto de dominio: la transformación ocurre de forma explícita mediante el método ToDomain() únicamente tras la validación.
  • Formato de error estandarizado: RFC 7807 hace que la API sea predecible para todos los consumidores.
  • Localización mediante códigos de error: no incluyas cadenas de texto directamente en las reglas; usa códigos y tradúcelos por separado.
  • Cobertura de tests del 100% en los validadores: cada regla se testea de forma aislada con el enfoque table-driven.
  • El middleware elimina el boilerplate: el handler no se ocupa del parseo ni la validación, solo de la lógica.
  • Limitación del tamaño del cuerpo de la petición: http.MaxBytesReader es obligatorio como protección mínima.
  • Dependencias mínimas: la biblioteca estándar cubre la mayoría de las necesidades; añade go-playground/validator solo cuando sea realmente necesario.

La capa de validación construida escala fácilmente: añadir una nueva regla es una función de tipo Rule, y añadir un nuevo DTO es implementar la interfaz Validatable. La arquitectura se mantiene limpia, los tests son rápidos y la API es predecible para los clientes.

Tecnologías

Etiquetas

Ruslan Ismailov

Desarrollador Senior Web / Backend. Desarrollador senior web/backend con 9 años de experiencia. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservicios, CI/CD. Más sobre mí →