Un pedido cambia de pendiente a pagado y sigue siendo el mismo pedido. Un billete de 20 euros, en cambio, vale lo mismo que cualquier otro billete de 20 euros: si te devuelven uno distinto del que prestaste, nadie piensa que ha pasado algo raro.
Esas dos intuiciones son la base de dos piezas de DDD. Una entidad es algo que reconocemos por su identidad a lo largo del tiempo, aunque cambien sus datos. Un objeto de valor es algo que reconocemos solo por su contenido: si dos tienen los mismos datos, son intercambiables.
La diferencia parece filosófica, pero tiene consecuencias muy concretas en el código: decide qué significa que dos objetos sean iguales, qué puede cambiar y qué reglas protege cada tipo. Vamos a construir un ejemplo de cada uno.
Una pregunta para distinguirlos
Cuando dudes, imagina que cambian sus datos. ¿Seguimos hablando de la misma cosa? Si un pedido cambia de estado o de dirección de entrega, sigue siendo el pedido ORDER-1, con su historia: cuándo se creó, cuándo se pagó, quién lo pidió. Es una entidad.
Si un importe pasa de 20 a 25 euros, ya no es «el mismo importe con otro valor»: es otro importe. No nos interesa la historia del «importe número 742»; solo su cantidad y su moneda. Es un objeto de valor.
La respuesta depende del contexto, como vimos con los contextos delimitados. Una dirección es un valor para una tienda: si dos pedidos van a la misma calle y número, la dirección es la misma. Para el sistema de un ayuntamiento que gestiona domicilios oficiales, en cambio, cada dirección puede tener identidad propia, con sus altas y sus cambios. La referencia de DDD de Eric Evans insiste en esto: la elección depende del uso que hace el modelo.
El dinero como objeto de valor
Empecemos por el dinero. Trabajar con un long suelto tiene un problema: el número no dice si son euros, céntimos o unidades de stock, ni en qué moneda. Un objeto de valor reúne la cantidad y su significado:
enum Currency {
EUR,
USD
}
class Money {
private final long amountInCents;
private final Currency currency;
public Money(long amountInCents, Currency currency) {
if (amountInCents < 0) {
throw new IllegalArgumentException("Amount cannot be negative.");
}
if (currency == null) {
throw new IllegalArgumentException("Currency is required.");
}
this.amountInCents = amountInCents;
this.currency = currency;
}
public Money plus(Money other) {
if (currency != other.currency) {
throw new IllegalArgumentException("Cannot add " + other.currency + " to " + currency + ".");
}
return new Money(Math.addExact(amountInCents, other.amountInCents), currency);
}
public long getAmountInCents() {
return amountInCents;
}
public Currency getCurrency() {
return currency;
}
@Override
public boolean equals(Object other) {
if (!(other instanceof Money)) {
return false;
}
Money money = (Money) other;
return amountInCents == money.amountInCents && currency == money.currency;
}
@Override
public int hashCode() {
return Objects.hash(amountInCents, currency);
}
}
Hay cuatro decisiones en esta clase:
- Validación al nacer. El constructor rechaza importes negativos y monedas nulas. Si existe un
Money, ya sabemos que cumple esas condiciones, y nadie tiene que volver a comprobarlas. - Inmutabilidad. Los campos son
finaly no hay métodosset.plus()no modifica el importe: devuelve uno nuevo. Así, si dos partes del programa comparten un mismoMoney, ninguna puede cambiárselo a la otra por sorpresa. - Operaciones con significado.
plus()impide sumar euros con dólares, que daría una cifra sin sentido.Math.addExact()lanza un error si la suma no cabe en unlong, en lugar de dar silenciosamente un número negativo. - Igualdad por contenido.
equals()compara cantidad y moneda.hashCode()debe ser coherente conequals(): si dos objetos son iguales, tienen que devolver el mismo número. Así funcionan bien en unHashSeto como clave de unHashMap.Objects.hash()calcula ese número a partir de los dos campos.
Este Money está pensado para precios y cobros, que nunca son negativos. Un sistema de contabilidad, con saldos y ajustes, necesitaría otro contrato. Un objeto de valor no tiene que servir para todo, sino representar bien una necesidad concreta del dominio.
Seguir unas cuantas operaciones
Money first = new Money(2000, Currency.EUR);
Money second = new Money(2000, Currency.EUR);
boolean sameObject = first == second; // false: two different objects in memory
boolean sameValue = first.equals(second); // true: same amount and currency
Money total = first.plus(second);
total.getAmountInCents(); // 4000
first.getAmountInCents(); // 2000: plus() did not change it
first.plus(new Money(500, Currency.USD)); // IllegalArgumentException: Cannot add USD to EUR.
new Money(-100, Currency.EUR); // IllegalArgumentException: Amount cannot be negative.
== compara si dos variables apuntan al mismo objeto en memoria. Aquí son dos objetos distintos, así que da false. equals() compara lo que representan, y dos importes de 20 euros representan lo mismo. Para un objeto de valor, la segunda pregunta es la que importa.
La suma produce 4000 céntimos y deja intactos los dos importes originales. Los dos últimos intentos fallan justo donde nace el error: no llega a existir una suma de monedas distintas ni un precio negativo.
El pedido como entidad
Un pedido es distinto. Tiene un identificador que lo acompaña durante toda su vida, y su estado cambia:
enum OrderStatus {
PENDING,
PAID,
CANCELLED
}
class Order {
private final String id;
private final Money total;
private OrderStatus status;
public Order(String id, Money total) {
if (id == null || id.trim().isEmpty()) {
throw new IllegalArgumentException("Order id is required.");
}
this.id = id;
this.total = total;
this.status = OrderStatus.PENDING;
}
public void markPaid() {
if (status != OrderStatus.PENDING) {
throw new IllegalStateException("Only a pending order can be paid.");
}
status = OrderStatus.PAID;
}
public void cancel() {
if (status != OrderStatus.PENDING) {
throw new IllegalStateException("Only a pending order can be cancelled.");
}
status = OrderStatus.CANCELLED;
}
public String getId() {
return id;
}
public Money getTotal() {
return total;
}
public OrderStatus getStatus() {
return status;
}
@Override
public boolean equals(Object other) {
if (!(other instanceof Order)) {
return false;
}
return id.equals(((Order) other).id);
}
@Override
public int hashCode() {
return id.hashCode();
}
}
El identificador es final: puede cambiar el estado, pero no la identidad. El estado no se modifica con un setStatus() que acepte cualquier valor, sino con operaciones que expresan transiciones del negocio y comprueban si están permitidas. No se puede pagar un pedido cancelado ni cancelar uno ya pagado. Son reglas de la entidad, y viven en la entidad.
equals() solo compara el identificador. Dos objetos Order con el mismo id representan el mismo pedido aunque sus datos difieran, por ejemplo porque uno se cargó antes de un cambio y el otro después.
Order order = new Order("ORDER-1", new Money(4000, Currency.EUR));
order.getStatus(); // PENDING
order.markPaid();
order.getStatus(); // PAID
order.cancel(); // IllegalStateException: Only a pending order can be cancelled.
Order loadedAgain = new Order("ORDER-1", new Money(4000, Currency.EUR));
order.equals(loadedAgain); // true: same identity, even though one is PAID and the other PENDING
Esa última línea dice algo importante y también tiene un límite. Que sean iguales significa que hablan del mismo pedido, no que tengan la misma información. Si el programa trabaja a la vez con dos copias del mismo pedido con estados distintos, la igualdad no resuelve cuál es la buena. Ese problema, el de proteger un cambio cuando hay copias, se trata en agregados e invariantes.

En la parte superior, la flecha representa la evolución de una entidad en el tiempo: cambia el estado y se conserva la identidad. En la inferior, la comparación de dos valores por su contenido. No son dependencias de código, sino las dos formas de responder a «¿es el mismo?».
Elegir bien la identidad
Una identidad necesita un alcance. ORDER-1 puede ser único dentro de una tienda, pero en una plataforma con varias tiendas quizá haya un ORDER-1 en cada una. Entonces la identidad tendría que incluir la tienda o usar un identificador global. Comparar solo el texto, sin ese contexto, confundiría pedidos distintos.
También conviene no confundir el identificador del negocio con la clave técnica de la base de datos. El número que aparece en el correo de confirmación y el que usa la base de datos internamente pueden coincidir, pero no tienen por qué. Generar el identificador al crear el pedido, antes de guardarlo, permite usarlo desde el primer momento, por ejemplo para referirse a él en un pago.
Objetos de valor con criterio
Los objetos de valor ayudan mucho a evitar confusiones. Un método refund(String orderId, String paymentId) acepta los dos textos en el orden equivocado sin protestar. Con refund(OrderId orderId, PaymentId paymentId), el compilador detecta el error. Es una forma barata de que el código diga lo que significa.
Pero no todo necesita su clase. Envolver cada texto en un tipo que no valida nada y que siempre se desempaqueta añade nombres sin añadir protección. La pregunta útil es qué error evita el tipo nuevo. Si no evita ninguno, probablemente sobra; es la misma prudencia que recomienda YAGNI.
Lo mismo ocurre con las clasificaciones. Si un valor solo puede ser una opción de una lista cerrada, como el estado de un pedido o la modalidad de un puesto (presencial, híbrido o remoto), un enum ya lo representa bien y el compilador impide valores inventados. Hace falta una clase cuando el valor tiene varias partes o reglas propias. Un rango de precios, por ejemplo, necesita un mínimo, un máximo y una moneda, y decidir si el mínimo puede superar al máximo o qué significa un extremo abierto. Igual que Money no suma euros con dólares, ese rango no debería compararse con otro expresado en otra moneda o por otro periodo sin una regla explícita.
Y un aviso sobre la inmutabilidad: que los campos sean final no basta si uno de ellos es una lista. El campo no puede apuntar a otra lista, pero la lista sí puede cambiar por dentro. Para proteger un valor que contiene colecciones hay que copiarlas al crearlo y al devolverlas, como hacía el informe de la tubería de importación.
Para comprobar si un tipo aporta algo, intenta romperlo: suma dos monedas distintas, crea un precio negativo, paga un pedido cancelado. Si el tipo impide el error en el mismo punto en que se produce, está haciendo su trabajo. Si el error solo aparece tres clases más allá, la regla todavía no está en el sitio adecuado.