Patrón Composite Entity
Mapea una entidad de grano grueso a varias tablas de base de datos componiendo objetos dependientes, de modo que el agregado completo se carga y guarda como una unidad.
Descripción General
El Patrón Composite Entity mapea un objeto de entidad de grano grueso a varias tablas de grano fino componiendo objetos dependientes dentro de él. En lugar de dar a cada objeto dependiente su propia interfaz remota o repositorio, la entidad compuesta los agrupa para que todo el grafo se cargue, modifique y persista en una sola operación.
El patrón nació con los entity beans de EJB 2.x, cuando cada llamada remota era cara y las entidades de grano fino suponían un viaje de red por campo. Hoy sobrevive como estrategia de mapeo de persistencia: un aggregate root como Order posee value objects como las líneas de pedido, la dirección de envío o los datos de pago, que no significan nada por sí solos.
Cuándo Usar
Usa el Patrón Composite Entity cuando:
- Un aggregate root contiene objetos dependientes que deberían persistirse juntos
- Los objetos dependientes no tienen significado fuera de su entidad padre
- Necesitas un único límite de carga/guardado alrededor del grafo de objetos
- Quieres mantener integridad referencial a través de tablas relacionadas
- Las llamadas remotas de grano fino o por tabla añadirían overhead real
Para una alternativa más simple cuando cada objeto se mapea a una sola tabla, consulta el Patrón Data Mapper.
Cuándo Evitar
- Los objetos dependientes se comparten entre varios padres — los hijos compartidos son entidades independientes
- Los objetos hijo necesitan CRUD propio (p. ej., un admin edita líneas de pedido fuera del pedido)
- El grafo de objetos está profundamente anidado y cargarlo de forma ansiosa perjudica el rendimiento
- Los límites de microservicio quedarían violados por un agregado de grano grueso
- Una base de datos documental ya te da documentos embebidos — el patrón viene incorporado
Solución
Python
Un ejemplo ejecutable con sqlite3: el mapper carga el pedido desde tres tablas y lo guarda en una sola llamada.
from dataclasses import dataclass, field
from typing import List, Optional
@dataclass
class LineItem:
product_id: str
quantity: int
unit_price: float
@property
def total(self) -> float:
return self.quantity * self.unit_price
@dataclass
class ShippingAddress:
street: str
city: str
country: str
postal_code: str
@dataclass
class PaymentDetails:
method: str
transaction_id: str
amount: float
@dataclass
class Order:
order_id: Optional[str] = None
customer_id: str = ""
line_items: List[LineItem] = field(default_factory=list)
shipping_address: Optional[ShippingAddress] = None
payment: Optional[PaymentDetails] = None
@property
def total(self) -> float:
return sum(item.total for item in self.line_items)
class OrderMapper:
"""Mapper de entidad compuesta que carga desde varias tablas"""
def __init__(self, conn):
self._conn = conn
def find_by_id(self, order_id: str) -> Optional[Order]:
# Cargar pedido padre
row = self._conn.execute(
"SELECT order_id, customer_id FROM orders WHERE order_id = ?",
(order_id,)
).fetchone()
if not row:
return None
order = Order(order_id=row["order_id"], customer_id=row["customer_id"])
# Cargar líneas de pedido dependientes
for item_row in self._conn.execute(
"SELECT product_id, quantity, unit_price FROM line_items WHERE order_id = ?",
(order_id,)
):
order.line_items.append(LineItem(
product_id=item_row["product_id"],
quantity=item_row["quantity"],
unit_price=item_row["unit_price"]
))
# Cargar dirección de envío
addr_row = self._conn.execute(
"SELECT street, city, country, postal_code FROM shipping_addresses WHERE order_id = ?",
(order_id,)
).fetchone()
if addr_row:
order.shipping_address = ShippingAddress(
street=addr_row["street"],
city=addr_row["city"],
country=addr_row["country"],
postal_code=addr_row["postal_code"]
)
return order
def save(self, order: Order):
# Una transacción mantiene consistentes las tres tablas
with self._conn:
self._conn.execute(
"INSERT OR REPLACE INTO orders (order_id, customer_id) VALUES (?, ?)",
(order.order_id, order.customer_id)
)
# Eliminar líneas viejas y re-insertar
self._conn.execute("DELETE FROM line_items WHERE order_id = ?", (order.order_id,))
for item in order.line_items:
self._conn.execute(
"INSERT INTO line_items (order_id, product_id, quantity, unit_price) VALUES (?, ?, ?, ?)",
(order.order_id, item.product_id, item.quantity, item.unit_price)
)
if order.shipping_address:
self._conn.execute(
"""INSERT OR REPLACE INTO shipping_addresses
(order_id, street, city, country, postal_code)
VALUES (?, ?, ?, ?, ?)""",
(order.order_id, order.shipping_address.street,
order.shipping_address.city, order.shipping_address.country,
order.shipping_address.postal_code)
)
# Uso
import sqlite3
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
conn.execute("CREATE TABLE orders (order_id TEXT PRIMARY KEY, customer_id TEXT)")
conn.execute("""CREATE TABLE line_items (
order_id TEXT, product_id TEXT, quantity INTEGER, unit_price REAL
)""")
conn.execute("""CREATE TABLE shipping_addresses (
order_id TEXT PRIMARY KEY, street TEXT, city TEXT, country TEXT, postal_code TEXT
)""")
mapper = OrderMapper(conn)
order = Order(
order_id="ORD-001",
customer_id="CUST-001",
line_items=[
LineItem("PROD-1", 2, 29.99),
LineItem("PROD-2", 1, 49.99),
],
shipping_address=ShippingAddress("123 Main St", "Springfield", "USA", "62701")
)
mapper.save(order)
loaded = mapper.find_by_id("ORD-001")
print(f"Total del pedido: ${loaded.total:.2f}") # Total del pedido: $109.97
Java
El mismo mapper con JDBC, ahora con save() incluido para que el ciclo completo funcione.
import java.sql.*;
import java.util.*;
public class LineItem {
private final String productId;
private final int quantity;
private final double unitPrice;
public LineItem(String productId, int quantity, double unitPrice) {
this.productId = productId; this.quantity = quantity; this.unitPrice = unitPrice;
}
public double getTotal() { return quantity * unitPrice; }
public String getProductId() { return productId; }
public int getQuantity() { return quantity; }
public double getUnitPrice() { return unitPrice; }
}
public class ShippingAddress {
private final String street, city, country, postalCode;
public ShippingAddress(String street, String city, String country, String postalCode) {
this.street = street; this.city = city; this.country = country; this.postalCode = postalCode;
}
public String getStreet() { return street; }
public String getCity() { return city; }
public String getCountry() { return country; }
public String getPostalCode() { return postalCode; }
}
public class Order {
private final String orderId;
private final String customerId;
private final List<LineItem> lineItems = new ArrayList<>();
private ShippingAddress shippingAddress;
public Order(String orderId, String customerId) {
this.orderId = orderId; this.customerId = customerId;
}
public String getOrderId() { return orderId; }
public String getCustomerId() { return customerId; }
public List<LineItem> getLineItems() { return lineItems; }
public ShippingAddress getShippingAddress() { return shippingAddress; }
public void setShippingAddress(ShippingAddress addr) { this.shippingAddress = addr; }
public double getTotal() { return lineItems.stream().mapToDouble(LineItem::getTotal).sum(); }
}
class OrderMapper {
private final Connection conn;
public OrderMapper(Connection conn) { this.conn = conn; }
public Order findById(String orderId) throws SQLException {
try (PreparedStatement stmt = conn.prepareStatement(
"SELECT customer_id FROM orders WHERE order_id = ?")) {
stmt.setString(1, orderId);
try (ResultSet rs = stmt.executeQuery()) {
if (!rs.next()) return null;
Order order = new Order(orderId, rs.getString("customer_id"));
// Cargar líneas de pedido
try (PreparedStatement itemStmt = conn.prepareStatement(
"SELECT product_id, quantity, unit_price FROM line_items WHERE order_id = ?")) {
itemStmt.setString(1, orderId);
try (ResultSet items = itemStmt.executeQuery()) {
while (items.next()) {
order.getLineItems().add(new LineItem(
items.getString("product_id"),
items.getInt("quantity"),
items.getDouble("unit_price")
));
}
}
}
// Cargar dirección de envío
try (PreparedStatement addrStmt = conn.prepareStatement(
"SELECT street, city, country, postal_code FROM shipping_addresses WHERE order_id = ?")) {
addrStmt.setString(1, orderId);
try (ResultSet addr = addrStmt.executeQuery()) {
if (addr.next()) {
order.setShippingAddress(new ShippingAddress(
addr.getString("street"), addr.getString("city"),
addr.getString("country"), addr.getString("postal_code")
));
}
}
}
return order;
}
}
}
public void save(Order order) throws SQLException {
boolean auto = conn.getAutoCommit();
conn.setAutoCommit(false); // una transacción para las tres tablas
try {
try (PreparedStatement stmt = conn.prepareStatement(
"INSERT OR REPLACE INTO orders (order_id, customer_id) VALUES (?, ?)")) {
stmt.setString(1, order.getOrderId());
stmt.setString(2, order.getCustomerId());
stmt.executeUpdate();
}
try (PreparedStatement del = conn.prepareStatement(
"DELETE FROM line_items WHERE order_id = ?")) {
del.setString(1, order.getOrderId());
del.executeUpdate();
}
try (PreparedStatement ins = conn.prepareStatement(
"INSERT INTO line_items (order_id, product_id, quantity, unit_price) VALUES (?, ?, ?, ?)")) {
for (LineItem item : order.getLineItems()) {
ins.setString(1, order.getOrderId());
ins.setString(2, item.getProductId());
ins.setInt(3, item.getQuantity());
ins.setDouble(4, item.getUnitPrice());
ins.executeUpdate();
}
}
if (order.getShippingAddress() != null) {
try (PreparedStatement addr = conn.prepareStatement(
"INSERT OR REPLACE INTO shipping_addresses (order_id, street, city, country, postal_code) VALUES (?, ?, ?, ?, ?)")) {
ShippingAddress a = order.getShippingAddress();
addr.setString(1, order.getOrderId());
addr.setString(2, a.getStreet());
addr.setString(3, a.getCity());
addr.setString(4, a.getCountry());
addr.setString(5, a.getPostalCode());
addr.executeUpdate();
}
}
conn.commit();
} catch (SQLException e) {
conn.rollback();
throw e;
} finally {
conn.setAutoCommit(auto);
}
}
}
// Uso
Connection conn = DriverManager.getConnection("jdbc:sqlite:orders.db");
conn.createStatement().execute("CREATE TABLE IF NOT EXISTS orders (order_id TEXT PRIMARY KEY, customer_id TEXT)");
conn.createStatement().execute("CREATE TABLE IF NOT EXISTS line_items (order_id TEXT, product_id TEXT, quantity INTEGER, unit_price REAL)");
conn.createStatement().execute("CREATE TABLE IF NOT EXISTS shipping_addresses (order_id TEXT PRIMARY KEY, street TEXT, city TEXT, country TEXT, postal_code TEXT)");
OrderMapper mapper = new OrderMapper(conn);
Order order = new Order("ORD-001", "CUST-001");
order.getLineItems().add(new LineItem("PROD-1", 2, 29.99));
order.setShippingAddress(new ShippingAddress("123 Main St", "Springfield", "USA", "62701"));
mapper.save(order);
Order loaded = mapper.findById("ORD-001");
System.out.println("Total del pedido: $" + loaded.getTotal());
JavaScript
La misma estructura con un mapper asíncrono, incluyendo save().
class LineItem {
constructor(productId, quantity, unitPrice) {
this.productId = productId;
this.quantity = quantity;
this.unitPrice = unitPrice;
}
get total() {
return this.quantity * this.unitPrice;
}
}
class ShippingAddress {
constructor(street, city, country, postalCode) {
this.street = street;
this.city = city;
this.country = country;
this.postalCode = postalCode;
}
}
class Order {
constructor(orderId, customerId) {
this.orderId = orderId;
this.customerId = customerId;
this.lineItems = [];
this.shippingAddress = null;
}
get total() {
return this.lineItems.reduce((sum, item) => sum + item.total, 0);
}
}
class OrderMapper {
constructor(db) {
this.db = db;
}
async findById(orderId) {
const row = await this.db.get('SELECT customer_id FROM orders WHERE order_id = ?', orderId);
if (!row) return null;
const order = new Order(orderId, row.customer_id);
const items = await this.db.all('SELECT product_id, quantity, unit_price FROM line_items WHERE order_id = ?', orderId);
for (const item of items) {
order.lineItems.push(new LineItem(item.product_id, item.quantity, item.unit_price));
}
const addr = await this.db.get('SELECT street, city, country, postal_code FROM shipping_addresses WHERE order_id = ?', orderId);
if (addr) {
order.shippingAddress = new ShippingAddress(addr.street, addr.city, addr.country, addr.postal_code);
}
return order;
}
async save(order) {
// Envuelve las tres escrituras en una transacción
await this.db.exec('BEGIN');
try {
await this.db.run(
'INSERT OR REPLACE INTO orders (order_id, customer_id) VALUES (?, ?)',
order.orderId, order.customerId
);
await this.db.run('DELETE FROM line_items WHERE order_id = ?', order.orderId);
for (const item of order.lineItems) {
await this.db.run(
'INSERT INTO line_items (order_id, product_id, quantity, unit_price) VALUES (?, ?, ?, ?)',
order.orderId, item.productId, item.quantity, item.unitPrice
);
}
if (order.shippingAddress) {
await this.db.run(
'INSERT OR REPLACE INTO shipping_addresses (order_id, street, city, country, postal_code) VALUES (?, ?, ?, ?, ?)',
order.orderId, order.shippingAddress.street, order.shippingAddress.city,
order.shippingAddress.country, order.shippingAddress.postalCode
);
}
await this.db.exec('COMMIT');
} catch (err) {
await this.db.exec('ROLLBACK');
throw err;
}
}
}
// Uso (con un wrapper sqlite asíncrono como `sqlite` o un adaptador de better-sqlite3)
// const mapper = new OrderMapper(db);
// await mapper.save(order);
// const loaded = await mapper.findById('ORD-001');
// console.log(loaded.total);
Explicación
El Patrón Composite Entity trata un grupo de objetos relacionados como una única unidad de persistencia:
- Entidad compuesta (Order): el aggregate root que posee los objetos dependientes
- Objetos dependientes (LineItem, ShippingAddress, PaymentDetails): objetos que solo existen dentro del padre — sin repositorio propio, sin identidad propia
- Mapper: coordina la carga y el guardado a través de todas las tablas que toca el agregado
La idea que hace funcionar el patrón es la propiedad. Un LineItem no tiene identidad global; “línea 3 del pedido ORD-001” es toda su identidad. Eso significa que el agregado decide cuándo se crean, cambian y eliminan los dependientes — nunca el código que lo invoca.
Elegir el límite del agregado
La parte difícil no es el código; es decidir qué pertenece al interior del compuesto. Una prueba útil: si borrar el padre debería borrar el objeto, pertenece adentro. LineItem pasa la prueba — las líneas de un pedido mueren con él. Customer la falla — borrar un pedido no debe borrar al cliente, así que customer_id queda como clave foránea, no como objeto compuesto.
Mantén el límite pequeño. Cada dependiente extra hace findById más lento (una query más) y save() más largo (una escritura más en la transacción). Si una colección hija puede crecer sin tope — digamos, miles de líneas — cargarla entera cada vez se convierte en un problema, y esa hija probablemente merece su propia entidad.
Las transacciones son parte del patrón
Fíjate en que los save() de arriba envuelven todas las escrituras en una transacción (with conn, setAutoCommit(false), BEGIN/COMMIT). No es un detalle opcional: escribir orders y line_items en commits separados significa que un fallo intermedio deja un pedido sin sus líneas. El punto del compuesto es persistir como una unidad; las escrituras parciales rompen el contrato.
La tentación del N+1
Los ejemplos ejecutan tres queries secuenciales: pedidos, luego líneas, luego direcciones. Está bien para un pedido, pero si listas 100 pedidos así obtienes 201 queries. Dos soluciones, según tu stack:
- JOIN en el mapper: una query con
LEFT JOIN line_itemsy agrupar las filas en código — un solo viaje, algo más de código de mapeo. - Lote por lista de IDs:
WHERE order_id IN (?, ?, ?)una vez por tabla y repartir las filas a los pedidos — 3 queries en total, sin importar cuántos pedidos haya.
No hace falta un ORM completo para resolver esto; ambas soluciones caben en un mapper pequeño.
El Ciclo de Vida de un Compuesto
Una entidad compuesta tiene un ciclo de vida distinto al de un registro plano, y acertarlo es la mayor parte del trabajo:
- Crear: instancia el aggregate root y adjunta los dependientes en memoria. Nada toca la base de datos todavía — el compuesto existe solo como un grafo consistente en memoria.
- Persistir:
save()escribe primero la fila padre (es la dueña de la clave) y luego cada fila dependiente dentro de una transacción. Al insertar, el ID generado del padre fluye hacia la clave foránea de cada hijo. - Cargar:
findById()rehidrata el grafo desde sus tablas y devuelve un agregado completo — nunca un objeto parcial con ganchos perezosos que el llamador tenga que conocer. - Modificar: el llamador muta el agregado en memoria y vuelve a llamar a
save(). El mapper no compara diferencias; borra los hijos antiguos y re-inserta los actuales. Tosco, pero correcto y fácil de razonar. - Eliminar: borrar el padre borra todos los dependientes. Con claves
ON DELETE CASCADEes una sola sentencia; sin ellas, borra primero los hijos y luego el padre, en la misma transacción.
De este ciclo se derivan dos reglas. Primero, el agregado siempre se carga y se guarda entero — no existe un camino de “cargar solo las líneas” en un compuesto (usa una proyección o una entidad separada para eso). Segundo, las invariantes se comprueban antes de save(), no dentro: un pedido con total negativo o cero líneas nunca debería llegar al mapper.
Composite Entity frente a las Alternativas
Es fácil confundir este patrón con sus vecinos de la familia de persistencia:
- Data Mapper resuelve el problema general de mantener objetos y esquema independientes. Composite Entity es una especialización: un mapper que sabe que un objeto en realidad abarca varias tablas. Si cada clase se mapea a una tabla, no lo necesitas.
- Active Record pone la persistencia en la propia entidad. Una entidad compuesta podría usar active records internamente, pero entonces cada dependiente conoce la base de datos y la garantía de “una transacción” se complica mucho — por eso los compuestos casi siempre se combinan con un mapper o un repositorio.
- Unit of Work rastrea cambios en muchos objetos y los confirma juntos. Composite Entity es más estrecho: un grafo de objetos, un guardado. Si coordinas varios agregados en una transacción, ese es el trabajo de Unit of Work encima.
- Aggregate Pattern (DDD) es la misma idea a nivel de dominio — la entidad compuesta es esencialmente la implementación en persistencia de un agregado.
La conclusión práctica: si ya piensas en agregados, este patrón es cómo los almacenas. Si no, adoptarlo solo para ahorrar un join se sentirá como ceremonia — ajusta el patrón a una entidad de grano grueso real.
Probar el Mapper
Como el mapper posee SQL real, merece tests reales — contra una base de datos de verdad, no mocks:
import sqlite3
import pytest
@pytest.fixture
def mapper():
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row
conn.execute("CREATE TABLE orders (order_id TEXT PRIMARY KEY, customer_id TEXT)")
conn.execute("CREATE TABLE line_items (order_id TEXT, product_id TEXT, quantity INTEGER, unit_price REAL)")
conn.execute("CREATE TABLE shipping_addresses (order_id TEXT PRIMARY KEY, street TEXT, city TEXT, country TEXT, postal_code TEXT)")
return OrderMapper(conn)
def test_save_and_load_round_trip(mapper):
order = Order(order_id="ORD-1", customer_id="C-1",
line_items=[LineItem("P1", 2, 10.0)])
mapper.save(order)
loaded = mapper.find_by_id("ORD-1")
assert loaded.total == 20.0
assert len(loaded.line_items) == 1
def test_removed_line_item_is_orphan_deleted(mapper):
order = Order(order_id="ORD-2", customer_id="C-1",
line_items=[LineItem("P1", 1, 10.0), LineItem("P2", 1, 5.0)])
mapper.save(order)
order.line_items.pop() # quitar una línea
mapper.save(order)
loaded = mapper.find_by_id("ORD-2")
assert len(loaded.line_items) == 1 # la fila huérfana se borró de verdad
El segundo test es el importante: el borrado de huérfanos es donde los mappers de compuestos más fallan en producción. Un test unitario contra SQLite en memoria lo detecta en milisegundos.
Diseñar el Esquema
Las tablas detrás de una entidad compuesta deberían codificar la propiedad, no solo almacenar datos. Para el ejemplo del pedido:
CREATE TABLE orders (
order_id TEXT PRIMARY KEY,
customer_id TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE line_items (
order_id TEXT NOT NULL REFERENCES orders(order_id) ON DELETE CASCADE,
line_no INTEGER NOT NULL,
product_id TEXT NOT NULL,
quantity INTEGER NOT NULL CHECK (quantity > 0),
unit_price REAL NOT NULL CHECK (unit_price >= 0),
PRIMARY KEY (order_id, line_no)
);
CREATE TABLE shipping_addresses (
order_id TEXT PRIMARY KEY REFERENCES orders(order_id) ON DELETE CASCADE,
street TEXT NOT NULL,
city TEXT NOT NULL,
country TEXT NOT NULL,
postal_code TEXT NOT NULL
);
Tres detalles hacen trabajo real aquí:
- Clave compuesta
(order_id, line_no)enline_items: la identidad del dependiente es local a su padre, exactamente como pretende el patrón — sin UUID sintético por línea, sin forma de que dos pedidos compartan una fila. ON DELETE CASCADE: borrar el pedido padre elimina los hijos a nivel de base de datos, lo que convierte “borrar el agregado” en una sola sentencia y garantiza que los huérfanos no sobrevivan aunque el código de aplicación los olvide.- Restricciones
CHECKen la tabla hija: invariantes como cantidades positivas viven lo más cerca posible de los datos, respaldando la validación que el agregado hace en memoria.
Si más adelante añades payments u order_events, se aplica la misma forma: tabla con clave del padre, borrado en cascada, identidad local. El esquema y el agregado avanzan a la par — que es lo que hace que el modelo mental del compuesto aguante bajo consultas reales.
Variantes
| Variante | Estrategia de mapeo | Caso de uso |
|---|---|---|
| Tabla por clase | Cada dependiente tiene su propia tabla | Consultas complejas sobre datos hijo |
| Tabla única | Todos los datos en una tabla desnormalizada | Lecturas simples, sin joins |
| Columna JSON | Dependientes almacenados como JSON | Esquema flexible, bases documentales |
| Valor embebido | Aplanado en columnas del padre | Value objects simples |
Cuándo ganan las columnas JSON
Las columnas jsonb de PostgreSQL y JSON de MySQL permiten guardar line_items y shipping_address dentro de la propia fila de orders. Cambias capacidad de consulta por simplicidad: una tabla, una escritura, cero limpieza de huérfanos. Elígela cuando nunca consultes dentro de los dependientes (nada de “busca todos los pedidos que contienen el producto X”) y los documentos sean pequeños. En cuanto necesites filtrar o indexar dentro del JSON, vuelve a tablas de verdad.
Lo que funciona
- Haz los objetos dependientes inmutables. Los cambios deberían pasar por el aggregate root.
- Impón las invariantes a nivel de agregado. La entidad compuesta valida el todo (p. ej., el total del pedido no puede ser negativo).
- Guarda el agregado entero en una transacción. Las escrituras parciales corrompen el límite.
- Borra y re-inserta las colecciones hijas. Más simple que comparar fila a fila; correcto dentro de una transacción.
- Limita el anidamiento a 2-3 niveles. Los grafos más profundos son difíciles de cargar y razonar.
- Considera columnas JSON para flexibilidad. Las bases modernas indexan y validan datos estructurados.
Errores Comunes
- Exponer los dependientes directamente. Los clientes deberían interactuar con el aggregate root, nunca mutar un
LineItemque devolvió el mapper. - Permitir persistencia independiente de los dependientes. Un
LineItemRepositoryjunto aOrderRepositoryrompe el límite — elige un único dueño. - Cargar el grafo entero en vistas de lista. Proyecta solo
order_id,customer_id,totalpara listados; carga los dependientes bajo demanda. - Compartir dependientes entre padres. Cada compuesto posee sus hijos; los hijos compartidos son entidades, no dependientes.
- Ignorar el borrado de huérfanos. Quitar una línea en memoria debe borrar su fila — la estrategia borrar-e-insertar lo resuelve gratis.
- Guardar sin transacción. Sin ella, un fallo a mitad de escritura deja padre e hijos inconsistentes.
Ejemplos del Mundo Real
JPA @Embeddable
La anotación @Embeddable de JPA marca objetos dependientes almacenados dentro de la tabla del padre; @Embedded los compone en la entidad. @ElementCollection cubre la variante con tabla hija para colecciones como las líneas de pedido — el ejemplo Java de arriba es la versión manual de lo que genera Hibernate.
Aggregate Roots en DDD
Domain-Driven Design formalizó la misma idea como Aggregates: un clúster de entidades y value objects con una raíz, un único límite de consistencia transaccional, y referencias externas permitidas solo hacia la raíz.
Documentos embebidos en MongoDB
MongoDB hace el patrón nativo: los documentos embebidos guardan los dependientes dentro del documento padre, así que un findOne devuelve el agregado completo y una escritura lo actualiza atómicamente.
Lecturas Recomendadas
Preguntas frecuentes
¿Cuál es la diferencia entre Composite Entity y Composite Pattern?
El Composite Pattern (GoF) trata de estructuras de árbol donde hojas y compuestos comparten una interfaz — un árbol de widgets, un sistema de archivos. Composite Entity trata de mapeo de persistencia: un aggregate root que posee objetos dependientes repartidos en tablas. Comparten nombre y nada más.
¿Pueden los objetos dependientes tener sus propios IDs?
Sí, pero solo locales. Una línea puede ser la #3 dentro del pedido ORD-001; nunca debería tener una clave global única a la que apunten otros objetos. Si algo más necesita referenciarla, es una entidad, no un dependiente.
¿Siempre debería borrar en cascada?
Para dependientes de verdad, sí — no pueden sobrevivir a su padre. En el momento en que un hijo podría sobrevivir al borrado del padre, deja de ser dependiente y debería modelarse como entidad propia con clave foránea.
¿Qué tan grande puede ser una entidad compuesta antes de ser un problema?
Mira las colecciones hijas, no el número de objetos. Un puñado de dependientes fijos (dirección, pago) está bien para siempre. Una colección que crece con el uso — líneas, eventos, adjuntos — acaba obligándote a paginarla o cargarla de forma perezosa, y entonces pide su propio agregado. Regla aproximada: si una colección dependiente puede superar unos cientos de filas, déjala fuera.
¿Tabla por dependiente o columna JSON — cómo elijo?
Pregúntate si alguna vez consultas dentro del dependiente. ¿Necesitas "todos los pedidos que contienen PROD-1"? Tabla real. ¿Solo lees y escribes el bloque completo? La columna JSON te ahorra un join y la limpieza de huérfanos. Muchos equipos empiezan con JSON y migran a tablas cuando aparecen necesidades de reporting.
Recursos Relacionados
Patrón Data Mapper
Separa objetos de dominio en memoria de la base de datos delegando la persistencia a una capa de mappers dedicada, manteniendo los modelos framework-agnostic.
PatternPatrón Active Record
Envuelve una tabla o vista de base de datos en una clase donde una instancia está vinculada a una sola fila, y la clase provee métodos para operaciones CRUD directamente en el objeto.
PatternPatrón Unit of Work
Trackea cambios a objetos en memoria durante una transacción de negocio y commitea todas las actualizaciones atómicamente a la base de datos, asegurando consistencia.
PatternPatrón Aggregate
Encapsula un cluster de objetos de dominio tratado como una unidad única para cambios de datos. Un Aggregate Root controla el acceso a sus entidades internas y value objects.
PatternPatrón Repository
Abstrae la lógica de acceso a datos detrás de una interfaz limpia. Patrón de diseño arquitectural para capas de datos testeables y mantenibles.
PatternPatrón Identity Map
Asegura que cada objeto sea cargado solo una vez por transacción cacheando instancias por su primary key, previniendo representaciones duplicadas en memoria del mismo row de base de datos.