← Volver al listado de tecnologías

Validación de Comandos con Zod

Por: SiempreListo
cqrsvalidacionzodtypescriptgopython

Validacion de Comandos con Zod

En este capitulo implementaremos validacion robusta de comandos, asegurando que los datos sean correctos antes de que lleguen al dominio.

Por Que Validar Comandos

La validacion de comandos ocurre antes de llegar al dominio. Queremos rechazar datos invalidos lo mas temprano posible por varias razones:

  1. Feedback rapido: El usuario sabe inmediatamente si sus datos tienen problemas
  2. Proteccion del dominio: El agregado no debe preocuparse por datos malformados
  3. Seguridad: Previene inyecciones y datos maliciosos
  4. Claridad: Separa la validacion de formato de la validacion de negocio

Hay dos tipos de validacion:

La validacion de formato ocurre en el comando. La validacion de negocio ocurre en el handler/agregado.

Que es Zod

Zod es una libreria de validacion y parsing para TypeScript. Permite definir schemas (esquemas) que describen la estructura y restricciones de los datos. Zod valida los datos contra el schema y retorna un resultado tipado.

La ventaja de Zod sobre validacion manual es que:

Schemas con Zod - TypeScript

// src/application/commands/order/create-order.schema.ts
import { z } from "zod";

const orderItemSchema = z.object({
  productId: z.string().uuid("Product ID must be valid UUID"),
  quantity: z.number().int().min(1, "Quantity must be at least 1").max(100)
});

const addressSchema = z.object({
  street: z.string().min(5).max(200),
  city: z.string().min(2).max(100),
  zip: z.string().regex(/^\d{5}(-\d{4})?$/, "Invalid ZIP code"),
  country: z.string().length(2).default("US")
});

export const createOrderSchema = z.object({
  customerId: z.string().uuid("Customer ID must be valid UUID"),
  items: z.array(orderItemSchema).min(1, "Order must have at least one item"),
  shippingAddress: addressSchema
});

export type CreateOrderInput = z.infer<typeof createOrderSchema>;

El schema define todas las restricciones: UUIDs validos, cantidades entre 1 y 100, codigo postal con formato correcto, etc.

Command Validado - TypeScript

Ahora usamos el schema en el comando. El metodo estatico create valida los datos y lanza un error si son invalidos:

// src/application/commands/order/create-order.command.ts
import { BaseCommand } from "../command";
import { createOrderSchema, CreateOrderInput } from "./create-order.schema";
import { ValidationError } from "@domain/shared/errors";

export class CreateOrderCommand extends BaseCommand {
  readonly customerId: string;
  readonly items: readonly { productId: string; quantity: number }[];
  readonly shippingAddress: {
    street: string;
    city: string;
    zip: string;
    country: string;
  };

  private constructor(input: CreateOrderInput) {
    super();
    this.customerId = input.customerId;
    this.items = Object.freeze(input.items);
    this.shippingAddress = Object.freeze(input.shippingAddress);
  }

  static create(input: unknown): CreateOrderCommand {
    const result = createOrderSchema.safeParse(input);

    if (!result.success) {
      throw new ValidationError(
        "Invalid CreateOrderCommand",
        result.error.flatten().fieldErrors
      );
    }

    return new CreateOrderCommand(result.data);
  }
}

El constructor es privado (private constructor), forzando el uso de create() que siempre valida.

Validacion en Go

Go no tiene una libreria estandar como Zod, pero podemos implementar validacion con errores estructurados:

// internal/application/command/create_order_command.go
package command

import (
    "fmt"
    "regexp"

    "github.com/google/uuid"
)

type ValidationError struct {
    Field   string
    Message string
}

type ValidationErrors []ValidationError

func (e ValidationErrors) Error() string {
    return fmt.Sprintf("validation failed: %d errors", len(e))
}

func NewCreateOrderCommand(
    customerID string,
    items []OrderItemDTO,
    address AddressDTO,
) (CreateOrderCommand, error) {
    var errors ValidationErrors

    // Validate customerID
    if _, err := uuid.Parse(customerID); err != nil {
        errors = append(errors, ValidationError{
            Field: "customerId", Message: "must be valid UUID",
        })
    }

    // Validate items
    if len(items) == 0 {
        errors = append(errors, ValidationError{
            Field: "items", Message: "must have at least one item",
        })
    }
    for i, item := range items {
        if _, err := uuid.Parse(item.ProductID); err != nil {
            errors = append(errors, ValidationError{
                Field: fmt.Sprintf("items[%d].productId", i),
                Message: "must be valid UUID",
            })
        }
        if item.Quantity < 1 || item.Quantity > 100 {
            errors = append(errors, ValidationError{
                Field: fmt.Sprintf("items[%d].quantity", i),
                Message: "must be between 1 and 100",
            })
        }
    }

    // Validate address
    zipRegex := regexp.MustCompile(`^\d{5}(-\d{4})?$`)
    if !zipRegex.MatchString(address.Zip) {
        errors = append(errors, ValidationError{
            Field: "shippingAddress.zip", Message: "invalid ZIP code",
        })
    }

    if len(errors) > 0 {
        return CreateOrderCommand{}, errors
    }

    return CreateOrderCommand{
        commandID:       uuid.NewString(),
        CustomerID:      customerID,
        Items:           items,
        ShippingAddress: address,
    }, nil
}

Validacion en Python con Pydantic

Pydantic es el equivalente de Zod en Python. Permite definir modelos con validacion automatica:

# src/orderflow/application/commands/order/create_order_command.py
from dataclasses import dataclass
from typing import Self
from pydantic import BaseModel, Field, field_validator
import re

class OrderItemInput(BaseModel):
    product_id: str = Field(pattern=r"^[0-9a-f-]{36}$")
    quantity: int = Field(ge=1, le=100)

class AddressInput(BaseModel):
    street: str = Field(min_length=5, max_length=200)
    city: str = Field(min_length=2, max_length=100)
    zip: str
    country: str = Field(default="US", min_length=2, max_length=2)

    @field_validator("zip")
    @classmethod
    def validate_zip(cls, v: str) -> str:
        if not re.match(r"^\d{5}(-\d{4})?$", v):
            raise ValueError("Invalid ZIP code")
        return v

class CreateOrderInput(BaseModel):
    customer_id: str = Field(pattern=r"^[0-9a-f-]{36}$")
    items: list[OrderItemInput] = Field(min_length=1)
    shipping_address: AddressInput

@dataclass(frozen=True)
class CreateOrderCommand(Command):
    customer_id: str
    items: tuple[OrderItemDTO, ...]
    shipping_address: AddressDTO

    @classmethod
    def create(cls, data: dict) -> Self:
        validated = CreateOrderInput.model_validate(data)
        return cls(
            customer_id=validated.customer_id,
            items=tuple(
                OrderItemDTO(i.product_id, i.quantity)
                for i in validated.items
            ),
            shipping_address=AddressDTO(
                validated.shipping_address.street,
                validated.shipping_address.city,
                validated.shipping_address.zip,
                validated.shipping_address.country,
            )
        )

Middleware de Validacion

Opcionalmente, puedes crear un middleware que envuelva al Command Bus y agregue funcionalidad transversal como logging o metricas:

// TypeScript - Validating Command Bus
export class ValidatingCommandBus implements CommandBus {
  constructor(private readonly inner: CommandBus) {}

  async dispatch<T extends Command>(command: T): Promise<void> {
    // La validación ya ocurrió en el factory del Command
    // Aquí podríamos agregar logging, métricas, etc.
    console.log(`Dispatching ${command.constructor.name}`);
    await this.inner.dispatch(command);
  }
}

Manejo de Errores en la API

Cuando la validacion falla, debemos retornar errores claros al cliente:

// src/domain/shared/errors.ts
export class ValidationError extends Error {
  constructor(
    message: string,
    readonly fieldErrors: Record<string, string[]>
  ) {
    super(message);
    this.name = "ValidationError";
  }
}

// API Handler
app.post("/orders", async (req, res) => {
  try {
    const command = CreateOrderCommand.create(req.body);
    await commandBus.dispatch(command);
    res.status(201).json({ success: true });
  } catch (error) {
    if (error instanceof ValidationError) {
      res.status(400).json({ errors: error.fieldErrors });
    } else {
      res.status(500).json({ error: "Internal error" });
    }
  }
});

Proximos Pasos

En el siguiente capitulo implementaremos la persistencia del Write Model.


Glosario

Schema (Esquema)

Definicion: Definicion formal de la estructura, tipos y restricciones que deben cumplir los datos. Es como un contrato que los datos deben satisfacer.

Por que es importante: Permite validar datos de forma declarativa. El schema documenta los requisitos y los aplica automaticamente.

Ejemplo practico: Un schema Zod que define que quantity debe ser un entero entre 1 y 100 rechazara automaticamente valores como -5, 0, 150 o “diez”.


Zod

Definicion: Libreria de validacion y parsing para TypeScript que permite definir schemas y validar datos en tiempo de ejecucion, con inferencia automatica de tipos.

Por que es importante: Proporciona validacion segura de tipos tanto en compilacion como en ejecucion. Los tipos TypeScript se derivan del schema, garantizando consistencia.

Ejemplo practico: z.string().email() crea un validador que rechaza cualquier string que no sea un email valido.


Pydantic

Definicion: Libreria de validacion de datos para Python que usa anotaciones de tipo para definir la estructura de datos y validarlos automaticamente.

Por que es importante: Es el estandar de facto para validacion en Python moderno. Integra perfectamente con FastAPI y otros frameworks.

Ejemplo practico: Un modelo Pydantic con email: EmailStr validara automaticamente que el campo contenga un email valido.


Validacion de Formato vs Validacion de Negocio

Definicion: Validacion de formato verifica que los datos tienen la estructura correcta (tipos, longitudes, patrones). Validacion de negocio verifica reglas del dominio (stock disponible, limites de credito).

Por que es importante: Separarlas mantiene el codigo organizado. La validacion de formato es rapida y puede hacerse sin base de datos. La validacion de negocio requiere contexto.

Ejemplo practico: Validacion de formato: “quantity es un numero positivo”. Validacion de negocio: “hay suficiente stock para esta quantity”.


Factory Method (Metodo Fabrica)

Definicion: Patron donde la creacion de objetos se hace a traves de un metodo estatico en lugar del constructor directo. Permite agregar logica de creacion como validacion.

Por que es importante: Garantiza que no se puedan crear instancias invalidas. El metodo fabrica puede validar y rechazar datos incorrectos.

Ejemplo practico: CreateOrderCommand.create(data) valida los datos y retorna un comando valido, o lanza un error. No puedes hacer new CreateOrderCommand(datosInvalidos).


Field Errors (Errores por Campo)

Definicion: Estructura que asocia cada campo con sus errores de validacion. Permite mostrar mensajes de error junto a cada campo del formulario.

Por que es importante: Mejora la experiencia de usuario al indicar exactamente que campos tienen problemas y por que.

Ejemplo practico: { "email": ["Invalid email format"], "quantity": ["Must be at least 1"] } indica claramente que campos corregir.