Validación y transformación de datos en Go: construcción de una capa de entrada robusta para REST API sin frameworks
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.MaxBytesReaderes obligatorio como protección mínima. - Dependencias mínimas: la biblioteca estándar cubre la mayoría de las necesidades; añade
go-playground/validatorsolo 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í →