1. Introducción
Cuando un desarrollador ve por primera vez las anotaciones de JPA para herencia, la mano se acerca a ellas tan rápido como a @Data en Lombok (lo que ya de por sí es un síntoma preocupante). Pero inheritance mapping no es una «forma elegante de no escribir los mismos campos dos veces». En ORM, la herencia es siempre un acuerdo: aceptas complejidad adicional a cambio de polimorfismo y honestidad del dominio. Si no necesitas polimorfismo, simplemente te estás suscribiendo a un pago mensual en forma de complejidad de SQL y migraciones.
Empecemos con un síntoma común. La mayoría de las veces, la idea de herencia surge así: tienes dos entidades, comparten 5–6 campos y te da pereza copiar y pegar. Es un deseo humano, no hay vergüenza en ello. Pero ORM no es un concurso de «quien escribió menos líneas», ORM se trata de comportamiento predecible y un modelo correcto. Si eliges herencia solo para eliminar la duplicación, estás optimizando el lugar equivocado. La duplicación de campos es un detalle menor en comparación con lo que puedes obtener: consultas extrañas, tablas difíciles de indexar, restricciones de esquema poco obvias y «por qué se carga así».
Aquí es útil tener una regla simple: primero la formulación del dominio, luego la técnica. Por lo tanto, primero es útil pensar como si @Inheritance aún no existiera. Primero determinamos si la jerarquía es necesaria como parte del modelo. Y solo si la respuesta es «sí», tiene sentido discutir cómo exactamente el ORM lo almacenará.
2. Verificación is-a: subtipo honesto
La herencia en Java es muy fácil de confundir con «tienen los mismos campos, por lo tanto, clase base». Pero el modelo de dominio requiere una lógica más estricta. La pregunta principal: ¿la relación entre tipos es “es-un” (is-a) o “tiene-un” (has-a)? Si no puedes decir honestamente la frase «X es Y» sin sentir incomodidad, entonces la herencia es sospechosa.
Imagina que estás modelando campañas promocionales. «La campaña porcentual es una campaña promocional» suena normal. «El descuento fijo es una campaña promocional» también suena normal. Pero «La dirección es un cliente» ya es extraño. «La categoría es un producto» es realmente malo. No es una jerarquía, son otras relaciones: asociación, composición, value object, link entity, cualquier cosa, menos “is-a”.
Hay una verificación útil para la «honestidad del subtipo»: el subtipo debe poder sustituirse por el tipo base sin que el código comience a hacer «y si este es ese subtipo, entonces…» y se llene de instanceof. En el mundo OOP, esto suele llamarse principio de sustitución de Liskov, pero se puede simplificar: si tu servicio acepta el tipo base, no debería tener que adivinar cada vez a quién exactamente le pasaron.
Veamos esto en un ejemplo mínimo de jerarquía sin ORM, solo como idea de dominio:
import com.example.commerce.common.jpa.Money;
public abstract class PromotionCampaign {
// Campos comunes de la campaña que existen en cualquier subtipo
private String code;
private boolean active;
// Importante: el tipo base define un contrato, y el subtipo implementa los detalles
public abstract Money applyDiscount(Money originalPrice);
// Supongamos que la entidad tiene gestión de actividad (para los ejemplos del servicio a continuación)
public void setActive(boolean active) {
this.active = active;
}
}
Aquí el significado base es claro: la campaña tiene un código, está activa/inactiva y sabe aplicar un descuento. Y ya cómo exactamente lo hace, lo decide el subtipo. Esto es lo que llamamos «variantes de un mismo significado», para lo cual la herencia realmente existe.
Para afianzar la intuición, es útil ver no solo ejemplos correctos, sino también «casi correctos». Aquí hay un ejemplo donde “is-a” parece forzado:
public class ProductWithDetails extends Product {
// Esto se ve como un "subtipo", pero en el dominio suele ser "has-a":
// el producto tiene detalles, no "el producto es los detalles".
private String description;
private Integer warrantyMonths;
}
En palabras parece conveniente, pero en el dominio generalmente no es cierto. ProductDetails no es un «subtipo de producto», son «detalles del producto», es decir, “has-a”. En nuestro proyecto, precisamente creamos una entidad separada ProductDetails, y fue una composición consciente, no herencia.
3. Señales de una jerarquía viva
Para no convertir el curso en un conjunto de opiniones personales, formulemos criterios. No haremos una lista de «10 mandamientos» (el ORM ya ama las pseudorreligiones), pero reuniremos un modelo de decisión sólido.
La herencia está justificada cuando realmente tienes un tipo común que vive en el sistema como concepto, y sus variantes no son desviaciones aleatorias, sino subtipos estables. Por lo general, esto se nota en el código y los casos de uso. Si tienes un escenario como «obtener campañas activas» y luego procesarlas de la misma manera: desactivarlas, verificar el período de validez, aplicar al precio, registrar y mostrar en la lista del panel de administración, el tipo base se vuelve útil.
Ayuda mucho la pregunta: ¿existen lecturas polimórficas y código polimórfico? Es decir, ¿habrá un lugar donde trabajes con PromotionCampaign y no con un PercentPromotionCampaign concreto? Si no es así, la herencia se convierte en un «esquema por estética» (y la estética del esquema sin conexión con las consultas es el camino al sufrimiento).
Otro buen criterio: el subtipo debe añadir significado, no una columna. Si agregaste un subtipo solo por un campo nullable, la mayoría de las veces es una señal de que el modelo se puede expresar de manera más simple. El subtipo gana cuando tiene sus propias invariantes, por ejemplo: el descuento porcentual debe tener percentValue, y el descuento fijo debe tener discountAmount. Son reglas realmente diferentes, y es agradable mantenerlas en tipos distintos para no vivir en el «reino de los nullable».
Mostraré esta idea en un pequeño diagrama de decisión. Sí, es un poco «entrevista en la cabeza», pero ahorra semanas de vida.
flowchart TD
A["¿Hay 2+ variantes de entidad?"] --> B{"¿Es un mismo significado? ¿is-a?"}
B -->|no| C["Composición / relación / value object / link entity"]
B -->|sí| D{"¿Hay código/lectura común a través del tipo base?"}
D -->|no| E["Piensa: entidades separadas o un tipo con campo de tipo"]
D -->|sí| F{"¿Los subtipos tienen invariantes/comportamiento diferentes?"}
F -->|no| G["Generalmente un solo tipo + campo es suficiente"]
F -->|sí| H["La herencia está justificada (pasamos a la estrategia ORM)"]
Observa: en el diagrama, la herencia no es la primera respuesta, sino bastante tardía. Y es normal. La herencia en ORM es como un cuchillo en la cocina: una herramienta útil, pero si intentas hacerlo todo con él, rápidamente empiezas a entender por qué los chefs están tan nerviosos.
4. Beneficio del tipo base: polimorfismo
La herencia en el modelo no se trata solo de tablas, se trata de código. Si el tipo base no hace el código más simple, casi siempre es perjudicial. En una buena jerarquía, obtienes la capacidad de escribir métodos de servicio a los que no les importa qué subtipo concreto llegó. Trabajan con un contrato común.
El ejemplo más simple del plan del día es la desactivación de una campaña. No es «magia de negocio», sino una operación perfectamente honesta: a cualquier campaña se le puede poner active=false.
public class PromotionCampaignService {
public void deactivate(PromotionCampaign campaign) {
// Trabajamos a través del tipo base: el subtipo no importa
campaign.setActive(false);
}
}
Aquí el tipo base es útil porque la operación no depende del tipo de descuento. Es un detalle, pero muestra la idea: el tipo base tiene una parte común de comportamiento.
Ahora mostremos un ejemplo más «sabroso» que revela el poder de los subtipos: aplicar un descuento a un precio. Podemos lograr que el código de negocio no sepa qué campaña tiene delante — simplemente le pide a la campaña que se aplique al precio.
import com.example.commerce.common.jpa.Money;
public class PromotionEngine {
public Money apply(PromotionCampaign campaign, Money originalPrice) {
// Idea clave: una sola llamada en lugar de if/switch por tipos
return campaign.applyDiscount(originalPrice);
}
}
Esto ya es un modelo agradable: no hay if, no hay switch, no hay instanceof. Hay una sola llamada. Cada campaña se responsabiliza de sí misma. Y, lo que es importante para la capa de persistencia, de esta manera también evitas que el modelo se convierta en «anémico» (cuando las entidades son solo bolsas de campos y toda la lógica está en servicios y if-else).
Para sentir la diferencia, veamos una versión «mala» del mismo código, donde la herencia parece existir pero no hay polimorfismo, y el servicio se convierte en una adivinanza:
import com.example.commerce.common.jpa.Money;
public class PromotionEngineBad {
public Money apply(PromotionCampaign campaign, Money price) {
// Bandera roja: en lugar de polimorfismo, bifurcación manual por subtipos
if (campaign instanceof PercentPromotionCampaign percent) {
return percent.applyDiscount(price);
}
if (campaign instanceof FixedAmountPromotionCampaign fixed) {
return fixed.applyDiscount(price);
}
// Peligroso: se añade un nuevo subtipo y "silenciosamente" no se aplicará
return price;
}
}
Tal construcción es una bandera roja. Dice: «tenemos una jerarquía, pero no confiamos en ella». Y casi siempre significa que la jerarquía no es necesaria o que el contrato base está mal diseñado.
5. Ejemplo: PromotionCampaign en Commerce Lab
En nuestro proyecto Commerce Persistence Lab, las campañas promocionales están diseñadas específicamente como un caso de herencia limitado, no como un estilo «ahora hagamos todo con jerarquías». Es importante enfatizar esto: si comienzas a «multiplicar la herencia» en Product, Customer y PurchaseOrder, muy rápidamente convertirás el laboratorio en un museo del sufrimiento.
Empecemos con la forma de dominio, aún sin anotaciones ORM. El tipo base describe lo que es común para cualquier campaña: código, actividad y (generalmente) período de validez. Mantendré los campos mínimos para no ensuciar el ejemplo.
import java.time.LocalDate;
public abstract class PromotionCampaign {
// Identificador de la campaña en el sentido de negocio (no necesariamente DB id)
private String code;
// Indicador común: cualquier campaña se puede activar/desactivar
private boolean active;
// Período de validez común (si es necesario en el modelo)
private LocalDate validFrom;
private LocalDate validTo;
}
Luego aparecen los subtipos, cada uno con su parte «especial». La campaña porcentual contiene el porcentaje.
public class PercentPromotionCampaign extends PromotionCampaign {
// Invariante del subtipo: el porcentaje solo existe aquí, no como "nullable en la tabla común"
private Integer percentValue;
}
La campaña fija contiene el monto del descuento. Y aquí es donde entra elegantemente Money, que hicimos anteriormente como un value object @Embeddable (es decir, continuamos desarrollando la misma base de código, no dibujamos clases abstractas en el vacío).
import com.example.commerce.common.jpa.Money;
public class FixedAmountPromotionCampaign extends PromotionCampaign {
// Invariante del subtipo: el descuento fijo solo existe aquí
private Money discountAmount;
}
Ya en este nivel se ve por qué los subtipos son útiles. Permiten expresar las restricciones del modelo de manera más honesta. Un descuento porcentual sin porcentaje no es «simplemente null», es una entidad rota. Un descuento fijo sin monto también lo es. Si en lugar de herencia haces una sola clase con los campos percentValue y discountAmount, creas un estado «y esto es null, y aquello es null» — y luego heroicamente cazas errores en tiempo de ejecución.
Ahora agreguemos comportamiento. Haré la implementación lo más educativa y corta posible, sin convertir Money en una mini-contabilidad. Es importante ver el principio: el subtipo implementa su parte.
import com.example.commerce.common.jpa.Money;
public class PercentPromotionCampaign extends PromotionCampaign {
@Override
public Money applyDiscount(Money originalPrice) {
// Aquí debería ir la lógica "multiplicar por (100 - percent) / 100"
// Ahora dejamos un marcador, porque el ejemplo trata sobre la distribución de responsabilidades
return originalPrice; // simplificado para el ejemplo
}
}
import com.example.commerce.common.jpa.Money;
public class FixedAmountPromotionCampaign extends PromotionCampaign {
@Override
public Money applyDiscount(Money originalPrice) {
// Aquí debería ir la lógica "originalPrice - discountAmount" con comprobaciones
// (por ejemplo, no caer en costo negativo)
return originalPrice; // simplificado para el ejemplo
}
}
Sí, aquí hay «marcadores» porque las matemáticas reales de Money ya las tienes de días anteriores (y seguro las hiciste con más cuidado de lo que yo ahora en cinco líneas). No se trata del dinero, se trata de quién es responsable del comportamiento.
Y ahora el tipo base comienza a funcionar realmente como un contrato. Puedes mantener la lista de campañas como un solo tipo en el código:
import java.util.List;
public class PromotionCampaignRegistry {
// Almacenamos en una sola lista: esto es "almacenamiento/lectura polimórfica"
private final List<PromotionCampaign> campaigns;
public PromotionCampaignRegistry(List<PromotionCampaign> campaigns) {
this.campaigns = campaigns;
}
}
Esto parece trivial, pero aquí hay una idea importante: si tienes un lugar donde almacenas o devuelves campañas como PromotionCampaign, entonces realmente necesitas polimorfismo. Y son precisamente esos lugares los que luego influyen fuertemente en qué mapeo ORM tiene sentido.
Alternativas a la herencia
Incluso si has pasado honestamente la verificación “is-a”, la herencia no tiene por qué ser la única solución. En la práctica, casi siempre tienes al menos tres opciones: entidades separadas sin tipo base común, una sola clase con campo de tipo y campos opcionales, y finalmente, la jerarquía.
Para no adelantarnos, solo fijemos el significado: eliges el modelo no por estética, sino por cómo lo usarán los servicios y las consultas. Si nunca lees «todas las campañas juntas», sino que siempre trabajas por separado con las porcentuales y por separado con las fijas, el tipo base puede ser simplemente innecesario. Si, en cambio, tienes una lista común de campañas y un mecanismo común de aplicación al pedido, el tipo base comienza a ser beneficioso.
| Enfoque | Cuándo parece razonable | Qué suele empezar a doler |
|---|---|---|
| Una sola clase + type + campos nullable | Pocas variantes, diferencias mínimas, lógica casi idéntica | Estados no válidos (todo null), montón de if, difícil mantener invariantes |
| Dos entidades separadas sin base | Casi no hay casos de uso comunes, reglas de ciclo de vida diferentes | Duplicación en código y consultas, más difícil construir una lista unificada |
| Herencia | Hay un significado común y código/lectura polimórfica común | El coste del mapeo y SQL se convierte en parte de tu vida |
La idea principal: la herencia no es «más correcta», es «más cara, pero a veces más honesta». Y si la honestidad del modelo realmente aporta beneficios al código y las consultas, el coste puede estar justificado.
6. Errores típicos en la herencia
La herencia tiene una trampa especial: se ve «inteligente» solo por el hecho de existir. La clase AbstractSomething infunde respeto, incluso si internamente es solo un almacén de campos aleatorios. Por eso los errores aquí son bastante típicos y se repiten en proyectos con una estabilidad envidiable — como N+1, solo que sin un log de SQL (aunque luego el log de SQL también llegará).
Error n.º 1: hacer una jerarquía solo para eliminar la duplicación de campos.
Esta es la razón más popular para la aparición de herencia y una de las más débiles. La duplicación de un par de campos casi siempre es más barata que las consecuencias de una jerarquía ORM compleja. Una buena prueba es preguntarse: ¿el tipo base tiene un rol propio en el código y las consultas? Si el tipo base no se usa en ninguna parte, excepto «para no repetir campos», entonces no es una base, es un almacén.
Error n.º 2: meter en el tipo base todo lo que se pueda «por si acaso».
Así nacen monstruos como BaseEntity con 20 campos, de los cuales la mitad solo pertenece a una parte de los subtipos. Al final, los subtipos se ven «obligados a vivir» con campos ajenos, y el modelo deja de expresar significado. El tipo base debe contener solo lo que es realmente común para todas las variantes y no rompa sus invariantes.
Error n.º 3: construir una jerarquía profunda por adelantado.
En un proyecto de aprendizaje y en un backend real, las jerarquías profundas casi siempre crean más problemas de los que aportan beneficios. Cuantos más niveles, más difícil es entender dónde está el estado, cómo se actualiza y cómo se refleja en el esquema. Un enfoque práctico es mantener la herencia plana y estrecha: un tipo base y 2–3 subtipos, no más.
Error n.º 4: «hay herencia, pero no hay polimorfismo».
Si por todas partes escribes if (...) y instanceof, significa que el contrato base no funciona. Esto es una señal de mal diseño del tipo base o de que en realidad no necesitas una jerarquía, sino otro modelo (por ejemplo, composición o entidades separadas). La herencia sin polimorfismo es como una bicicleta que llevas en brazos: formalmente es transporte, pero en realidad es una pesa.
Error n.º 5: mezclar en una misma jerarquía objetos con diferentes ciclos de vida.
Los subtipos de una misma jerarquía deben ser «de la misma clase de vida»: mismas reglas de actividad, aproximadamente el mismo ciclo de vida, escenarios similares de lectura y actualización. Si un subtipo vive «un minuto y se elimina» y otro vive «años y se audita», es una señal de alarma. Luego el ORM intentará honestamente representar esto como un solo modelo, y obtendrás compromisos extraños.
GO TO FULL VERSION