Value Objects vs Primitives en Domain-Driven Design: cómo diseñar el dominio

El diseño impulsado por el dominio (DDD) busca crear un modelo que refleje la lógica y las reglas del negocio. En el corazón del modelado táctico está decidir, para cada atributo de tu modelo, si debe ser un value object (objeto de valor) o un tipo primitivo. Elegir mal lleva a primitive obsession; elegir en exceso lleva a sobre-diseño. Este artículo te da criterios prácticos para decidir, con ejemplos en C#.

El modelo mental: tres tipos de "cosa" en el dominio

Tipo ¿Tiene identidad? ¿Cambia en el tiempo? ¿Qué la define?
Entity Sí (Id) Sí, tiene ciclo de vida Quién es (un User con su Id)
Value Object No No, inmutable Qué es, sus valores (Email)
Primitive No Valor bruto sin significado de dominio (string, int)

Como señala Eric Evans, "muchos objetos no tienen identidad conceptual; describen ciertas características de una cosa". Un Email no tiene identidad: dos emails iguales son el mismo email. Un User con el mismo nombre pero distinto Id son dos usuarios distintos. Esa es la diferencia esencial entre entity y value object.

Características de los Value Objects

Los value objects tienen dos características principales:

  1. Inmutabilidad: una vez creados, sus propiedades no cambian. Cualquier "modificación" produce una nueva instancia. Esto protege la integridad del modelo.
  2. Sin identidad única: su valor es lo que los define.
  3. Comparación por valor: dos value objects son iguales si todos sus atributos son iguales.

En C#, un record te da esta semántica de forma natural.

Cuándo crear un value object (las señales)

Creás un value object cuando se cumple al menos una de estas condiciones:

  1. Tiene reglas o validación. El formato del email, las reglas de complejidad de una contraseña, el rango válido de un porcentaje. Esa lógica es de dominio y no debería estar repartida en cada service o controlador.
  2. Tiene comportamiento que depende de su valor. Money.Add() que verifica que ambas monedas sean iguales, DateRange.Overlaps(), Percentage.IsValid(). El comportamiento vive junto al dato.
  3. Agrupa varios primitives en un concepto cohesivo. Money = amount + currency, Address = street + city + zip. Solos no tienen sentido completo; juntos forman un concepto del negocio.
  4. Se repite en varios lados con las mismas reglas. Si Email aparece en User, Contact y Vendor, y es un primitivo, la validación se duplica en tres lugares.
  5. La igualdad por valor importa y querés compararlo de forma natural (email1 == email2).

Ejemplo práctico

public sealed record Email
{
    public string Value { get; }

    private Email(string value) => Value = value;

    public static Email Create(string? value)
    {
        var normalized = value?.Trim().ToLowerInvariant();

        if (string.IsNullOrWhiteSpace(normalized) ||
            !Regex.IsMatch(normalized, @"^[^@\s]+@[^@\s]+\.[^@\s]+$"))
        {
            throw new ArgumentException("El correo electrónico no es válido.", nameof(value));
        }

        return new Email(normalized);
    }

    public override string ToString() => Value;
}

Observá el patrón: constructor privado, factory estática que valida y normaliza, propiedades de solo lectura, y record para igualdad por valor.

public sealed record Money
{
    public decimal Amount { get; }
    public string Currency { get; }

    public Money(decimal amount, string currency)
    {
        Amount = amount;
        Currency = currency;
    }

    public Money Add(Money other)
    {
        if (!string.Equals(Currency, other.Currency, StringComparison.OrdinalIgnoreCase))
            throw new InvalidOperationException("No se pueden sumar montos de monedas distintas.");

        return new Money(Amount + other.Amount, Currency);
    }
}

Acá el value object no solo valida, comporta: la regla de "no mezclar monedas" vive en el dominio.

Cuándo usar primitivos (lo que nadie te dice)

No todo debe ser value object. Un primitive está perfectamente bien cuando:

  • No hay reglas ni invariantes. Un nombre visible (DisplayName), un flag (IsActive), una fecha de auditoría (CreatedAtUtc). No inventes un VO "por disciplina".
  • Es un valor técnico-opaco, no un concepto de dominio. Un PasswordHash es un string cifrado sin reglas de negocio.
  • Es una identidad. El Guid Id de una entity se queda primitivo (o se vuelve un id fuertemente tipado, pero solo si aporta).

Regla de oro: empezá primitivo y extraé el value object cuando "te duela" — validación duplicada, estados inválidos posibles, o un concepto que aparece tres o más veces. Esto es el refactoring Replace Data Value with Object: primero funciona con el primitivo, y el VO emerge cuando hay una razón.

Tan malo es el primitive obsession como el VO obsession. El sobre-diseño también es un código que no se mantiene.

Cómo empezar a modelar (paso a paso)

  1. Ubiquitous language: hablá con el experto del negocio; anotá nombres y verbos del dominio, no columnas de tablas.
  2. Encontrá las entities: las cosas con identidad y ciclo de vida.
  3. Para cada atributo de cada entity preguntate: ¿tiene reglas? ¿comportamiento? ¿es un concepto cohesivo? → value object o primitive.
  4. Agrupá en aggregates: la frontera de consistencia. El aggregate root es quien se persiste; los value objects viven adentro.
  5. Extraé value objects iterativamente a medida que aparecen reglas. El diseño del dominio nunca está terminado: se refina con cada conversación de negocio.

Evaluando un caso real: un modelo de usuarios (IAM)

En un módulo de identidad típico:

  • Emailvalue object (tiene formato, normaliza, se repite). ✔
  • Usernameprimitivo al inicio (solo "no vacío"); se vuelve value object cuando crecen reglas de formato, longitud o unicidad.
  • DisplayNameprimitivo (texto libre). ✔
  • PasswordHashprimitivo (opaco, técnico). ✔
  • CreatedAtUtc / UpdatedAtUtcprimitivos (fechas de auditoría). ✔
  • Role, Permissionentities (tienen Id y son referenciadas).

El mejor value object para empezar suele ser el email: tiene reglas, se normaliza y se repite en varios lugares del dominio.

Conclusión

La decisión value object vs primitive es una de las más frecuentes del modelado de dominio. Los value objects encapsulan reglas y comportamiento, son inmutables y se comparan por valor; los primitives son simples y sirven cuando no hay lógica de negocio que proteger. La práctica recomendada: empezar simple, detectar las señales (reglas, comportamiento, cohesión, repetición) y extraer el value object cuando el dominio lo pida. Un dominio bien modelado no se mide por la cantidad de value objects, sino por qué tan fielmente refleja las reglas del negocio.

Fuentes

  • Domain-Driven Design Distilled (Vaughn Vernon)
  • .NET Microservices Architecture for Containerized .NET Applications (Microsoft)
  • Software Architecture with C# 12 and .NET 8 (Price, Wenzel, et al.)
  • Clean Code with C# — "Primitive obsession"
  • Strategic Monoliths and Microservices (Kumar, et al.)