secture & code

Estrategias de validación en arquitectura hexagonal: las 3 capas y el papel de los value objects

Si trabajas con arquitectura hexagonal, tarde o temprano llegas a la misma pregunta incómoda: ¿dónde valido cada cosa? El DTO valida, la capa de aplicación valida, y encima tienes value objects que también validan. ¿No es todo lo mismo repetido tres veces?

No lo es. La validación en una app bien diseñada no es un único muro, sino una serie de filtros, y cada uno mira algo distinto. Cuando entiendes qué le toca a cada capa, dejas de duplicar reglas y tu dominio empieza a defenderse solo. En este artículo repaso esas tres capas y, dentro de ellas, por qué los value objects son la pieza que hace que la estrategia entera funcione.


Las tres capas de validación

La clave es que cada capa valida lo suyo, sin pisar a la siguiente. De fuera hacia dentro:

1. DTO en el controlador — validación estructural

¿Tienen los datos la forma correcta? Que email sea un string, que price sea un número, que los campos requeridos estén presentes. Es la primera línea y rechaza la basura obvia antes de que entre al sistema. Sintaxis, no semántica.

2. Capa de aplicación — reglas de negocio con contexto

Aquí van las reglas que necesitan mirar el mundo exterior: ¿existe este producto?, ¿tiene el usuario permiso?, ¿hay stock? Requieren repositorios y coordinación. No caben en un DTO ni en un value object aislado.

3. Value object en el dominio — invariantes

Las reglas que son always verdad sobre un concepto, sin importar el contexto. Un precio nunca es negativo. Un email tiene formato válido. Es la verdad última del dominio, y que algo falle aquí es normal: valida cosas distintas a las capas anteriores.

La clave es entender que cada capa valida independientemente lo suyo, sin dependencias entre ellas. Es completamente normal y legítimo que algo pase el DTO y la capa de aplicación pero falle después en el value object. No significa que algo anterior esté mal, simplemente que el value object protege invariantes del dominio que son diferentes a lo que valida el DTO. El DTO se enfoca en estructura, la capa de aplicación en contexto, y el value object en los invariantes que son siempre verdad sobre ese concepto, independientemente de dónde venga el dato. No duplican, complementan.


El value object: el corazón de la estrategia

De las tres capas, la del value object es la que más peso soporta, y la que más se malinterpreta. Merece que nos detengamos, porque sin ella las otras dos se quedan cojas.

A value object es un objeto que se define por sus valores, no por una identidad. Dos direcciones con la misma calle, ciudad y código postal son la misma dirección, aunque sean instancias distintas en memoria. Eso los diferencia de las entidades, que sí tienen un ID único.

Tienen dos propiedades que los hacen tan valiosos: son inmutables (una vez creados no cambian; si necesitas otro valor, creas uno nuevo) y encapsulan sus propias invariantes. No son un string con buena pinta: son un concepto de tu negocio que sabe protegerse. Un Email mal formado sencillamente no puede existir. Por eso viven en el centro de la cebolla, lejos de la base de datos y del framework, y son la unidad mínima de integridad de tus datos.


Ejemplos concretos en NestJS

Vamos al código. Contexto hexagonal, CQRS solo a nivel semántico (comandos y queries como intención, sin librería), y los value objects como ciudadanos de primera del dominio.

Email: pasa el DTO, cae en el value object

El DTO comprueba que sea un string no vacío. El value object comprueba que sea un email de verdad. Que uno pase y el otro falle no es un error: es cada capa haciendo su trabajo.

DTO — solo estructura
// interface/http/dto/create-user.dto.ts
import { IsString, IsNotEmpty } from 'class-validator';

export class CreateUserDto {
  @IsString()
  @IsNotEmpty()
  email!: string;   // solo estructura: es un string con algo dentro
}
Value object — significado
// domain/value-objects/email.vo.ts
export class Email {
  private static readonly PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

  private builder(private readonly value: string) {}

  static create(raw: string): Email {
    const normalized = raw.trim().toLowerCase();
    if (!Email.PATTERN.test(normalized)) {
      throw new InvalidEmailError(raw);
    }
    return new Email(normalized);
  }

  toString(): string { return this.value; }
  equals(other: Email): boolean { return this.value === other.value; }
}

Un valor como "pepe" supera el DTO sin problema (es un string), pero Email.create("pepe") revienta. Ahí ves la diferencia entre estructura y significado en estado puro. Fíjate también en el constructor privado: se crea siempre por create(), así que un Email inválido es literalmente inconstruible.

Quantity: reglas propias del negocio

La cantidad no es solo «un entero positivo». Tu negocio puede imponer un máximo o exigir múltiplos según el tipo de producto.

// domain/value-objects/quantity.vo.ts
export class Quantity {
  private static readonly MAX = 1000;

  private builder(private readonly value: number) {}

  static create(raw: number): Quantity {
    if (!Number.isInteger(raw)) {
      throw new InvalidQuantityError('debe ser un entero');
    }
    if (raw <= 0) {
      throw new InvalidQuantityError('debe ser mayor que cero');
    }
    if (raw > Quantity.MAX) {
      throw new InvalidQuantityError(`maximo ${Quantity.MAX}`);
    }
    return new Quantity(raw);
  }

  value(): number { return this.value; }
}

El DTO se queda en @IsInt() y @Min(1). El techo de 1000 es una regla de negocio, y vive donde le corresponde: en el dominio.

Price: decimales, rango y una moneda

El dinero es el ejemplo clásico. Un precio no es un number: es una cantidad con una moneda, no negativo, y con un número de decimales controlado para no arrastrar errores de coma flotante.

// domain/value-objects/price.vo.ts
export class Price {
  private builder(
    private readonly cents: number,   // guardamos en centimos: sin floats sueltos
    private readonly currency: string,
  ) {}

  static fromAmount(amount: number, currency: string): Price {
    if (amount < 0) {
      throw new InvalidPriceError('no puede ser negativo');
    }
    const cents = Math.round(amount * 100);
    if (Math.abs(amount * 100 - cents) > Number.EPSILON) {
      throw new InvalidPriceError('maximo dos decimales');
    }
    return new Price(cents, currency.toUpperCase());
  }

  add(other: Price): Price {
    if (this.currency !== other.currency) {
      throw new CurrencyMismatchError();
    }
    return new Price(this.cents + other.cents, this.currency);
  }
}

Mira cómo el value object no solo valida: encapsula comportamiento (add) y protege reglas que un simple número jamás podría, como impedir sumar euros con dólares.


Cómo encajarlo todo sin solapar

El orden en que un dato atraviesa tu aplicación es siempre el mismo, y cada parada valida su parte: primero el DTO filtra en la puerta (NestJS ejecuta el ValidationPipe y descarta lo que no tenga forma), luego el caso de uso orquesta (recibe el DTO, consulta repositorios para las reglas contextuales y decide, sin volver a validar formato), y por último el mapper construye los value objects llamando a Email.create(), Price.fromAmount(), etc., que es donde las invariantes se aplican de verdad.

Ese último punto merece atención. El mapper es el traductor entre el mundo exterior (base de datos, HTTP) y el dominio. Y aquí hay un matiz importante según la dirección del flujo:

  • De entrada de usuario → dominio: validas a fondo. El usuario manda lo que sea, así que el value object es la guardia. Si algo no cumple, excepción.
  • De base de datos → dominio: los datos deberían ser confiables (ya pasaron por aquí una vez). Aun así, una validación defensiva no sobra: si un value object falla al leer de BD, no es culpa del usuario, es un problema de integridad de datos que quieres detectar cuanto antes.
// infrastructure/persistence/product.mapper.ts
export class ProductMapper {
  // BD -> dominio: reconstruye value objects (con red de seguridad)
  static toDomain(row: ProductRow): Product {
    return Product.rehydrate({
      id: ProductId.create(row.id),
      price: Price.fromAmount(row.price, row.currency),
    });
  }

  // dominio -> BD: value objects a primitivos que la BD entiende
  static toPersistence(product: Product): ProductRow {
    return {
      id: product.id.toString(),
      price: product.price.toAmount(),
      currency: product.price.currencyCode(),
    };
  }
}

Un apunte que suele generar dudas: que un mapper importe tipos, interfaces o value objects de otras partes del dominio no lo convierte en algo que debas inyectar. Si el mapper es una transformación pura y estática, sin estado ni dependencias de comportamiento en runtime, impórtalo como un módulo normal. La inyección de dependencias es para lo que la necesita de verdad: servicios y repositorios.


Cierre: las tres capas, sostenidas por el dominio

La validación en arquitectura hexagonal no es un muro único ni una regla repetida tres veces: es una estrategia por capas donde cada una responde a una pregunta distinta. El DTO pregunta «¿tiene esto forma?». La capa de aplicación pregunta «¿tiene sentido en este contexto?». Y el value object pregunta «¿es esto válido, siempre y en cualquier parte?».

El DTO es el portero que comprueba que traes documentación. El value object es quien verifica que esa documentación es legítima. Cada uno hace un trabajo distinto, y juntos forman una defensa que no deja pasar datos inválidos.

Pero si tuviera que quedarme con una sola idea, sería esta: las dos primeras capas filtran, y el value object es quien define la verdad. Puedes tener DTOs y casos de uso impecables, pero si tu dominio permite construir un objeto en estado inválido, la estrategia entera se cae. Los value objects son el suelo sobre el que se apoya todo lo demás: mientras un Email o un Price inválido sea literalmente inconstruible, tu dominio es incorruptible desde fuera.

Coloca cada validación donde le toca, deja que el dominio se proteja a sí mismo, y el resto del sistema se vuelve más simple casi por arte de magia. Ese es el objetivo.


Bibliografía y lecturas recomendadas

  1. Eric EvansDomain-Driven Design: Tackling Complexity in the Heart of Software. Addison-Wesley, 2003. El origen de la clasificación entidad / value object / servicio y de conceptos como invariantes, factories y bounded contexts.
  2. Alistair CockburnHexagonal Architecture (Ports and Adapters), 2005. Artículo original que define el patrón y la separación entre dominio, aplicación e infraestructura.
  3. Martin FowlerValueObject y DomainDrivenDesign, martinfowler.com. Referencias breves y claras sobre la «Evans Classification» y el patrón value object.

Si te interesan más artículos como este, encuéntralos en our blog

Value Objects en Arquitectura Hexagonal: cómo validar correctamente cada capa

Full-Stack Developer

Picture of Vicent Gisbert Soto

Vicent Gisbert Soto

In this life everything is trained
Picture of Vicent Gisbert Soto

Vicent Gisbert Soto

In this life everything is trained

We are HIRING!

What Can We Do