Patrón Identity Map
Cachea objetos por clave primaria para que cada fila corresponda a una instancia en memoria por transacción. Sin duplicados ni actualizaciones perdidas.
Descripción General
Pide la misma fila dos veces dentro de una transacción y algo sutil falla: terminas con dos objetos que ambos dicen ser User(id=42). Editas uno, guardas el otro, y esa segunda escritura borra tu cambio sin avisar. La mayoría de los equipos conoce este bug como un reporte raro de «mi actualización desapareció» mucho antes de conocer el patrón que lo previene.
El Identity Map mantiene una tabla de búsqueda por unidad de trabajo, indexada por clave primaria. Cada carga pasa por él: si User:42 ya está en memoria recuperas esa instancia, así que find_by_id(42) llamado diez veces devuelve el mismo objeto diez veces. Es la maquinaria detrás del seguimiento de identidad de los ORM: la Session de SQLAlchemy, la caché de primer nivel de Hibernate y el DbContext de EF Core funcionan así.
Cuándo Usar
Usa el patrón Identity Map cuando:
- La misma fila puede pedirse más de una vez dentro de una transacción
- La igualdad de identidad (
is/==) importa para la lógica de negocio - El Unit of Work o el Data Mapper necesita saber qué objetos ya están cargados
- Dos partes de una transacción podrían actualizar la misma fila y pisarse entre sí
Si la entidad que cargas es en realidad un agregado repartido entre varias tablas o documentos, mira el Composite Entity Pattern en su lugar.
Cuándo Evitar
- Consultas de solo lectura donde las instancias duplicadas no importan
- APIs sin estado donde cada solicitud empieza de cero y no reutiliza objetos
- Cuando retener en memoria cada objeto cargado cuesta más de lo que puedes pagar
- Transacciones largas donde los objetos en caché quedan obsoletos mientras otros escritores avanzan
Solución
Python
import sqlite3
from typing import Dict, Optional, Type, Any
class User:
def __init__(self, user_id: int, name: str, email: str):
self.id = user_id
self.name = name
self.email = email
def __repr__(self):
return f"User(id={self.id}, name='{self.name}')"
class IdentityMap:
def __init__(self):
self._map: Dict[Type, Dict[Any, Any]] = {}
def add(self, entity):
entity_type = type(entity)
if entity_type not in self._map:
self._map[entity_type] = {}
key = self._extract_key(entity)
self._map[entity_type][key] = entity
def get(self, entity_type: Type, key: Any) -> Optional[Any]:
return self._map.get(entity_type, {}).get(key)
def has(self, entity_type: Type, key: Any) -> bool:
return key in self._map.get(entity_type, {})
def remove(self, entity_type: Type, key: Any):
type_map = self._map.get(entity_type)
if type_map:
type_map.pop(key, None)
def _extract_key(self, entity) -> Any:
return getattr(entity, 'id', None)
class UserMapper:
def __init__(self, connection, identity_map: IdentityMap):
self._conn = connection
self._identity_map = identity_map
def find_by_id(self, user_id: int) -> Optional[User]:
# Consultar el identity map primero
cached = self._identity_map.get(User, user_id)
if cached:
return cached
row = self._conn.execute(
"SELECT id, name, email FROM users WHERE id = ?", (user_id,)
).fetchone()
if row:
user = User(user_id=row["id"], name=row["name"], email=row["email"])
self._identity_map.add(user)
return user
return None
def find_all(self):
rows = self._conn.execute("SELECT id, name, email FROM users").fetchall()
users = []
for row in rows:
user = self._identity_map.get(User, row["id"])
if not user:
user = User(user_id=row["id"], name=row["name"], email=row["email"])
self._identity_map.add(user)
users.append(user)
return users
# Uso
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row # las filas llegan tipo dict: row["id"] funciona
conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)")
conn.execute("INSERT INTO users (name, email) VALUES ('Alice', 'alice@example.com')")
identity_map = IdentityMap()
mapper = UserMapper(conn, identity_map)
user1 = mapper.find_by_id(1)
user2 = mapper.find_by_id(1)
print(user1 is user2) # True — misma instancia de objeto
Java
import java.sql.*;
import java.util.*;
public class User {
private final int id;
private String name;
private String email;
public User(int id, String name, String email) {
this.id = id; this.name = name; this.email = email;
}
public int getId() { return id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
}
class IdentityMap {
private final Map<Class<?>, Map<Object, Object>> map = new HashMap<>();
@SuppressWarnings("unchecked")
public <T> T get(Class<T> type, Object key) {
return (T) map.getOrDefault(type, Collections.emptyMap()).get(key);
}
public <T> void add(Class<T> type, Object key, T entity) {
map.computeIfAbsent(type, k -> new HashMap<>()).put(key, entity);
}
public <T> boolean has(Class<T> type, Object key) {
return map.getOrDefault(type, Collections.emptyMap()).containsKey(key);
}
}
class UserMapper {
private final Connection conn;
private final IdentityMap identityMap;
public UserMapper(Connection conn, IdentityMap identityMap) {
this.conn = conn; this.identityMap = identityMap;
}
public User findById(int id) throws SQLException {
User cached = identityMap.get(User.class, id);
if (cached != null) return cached;
try (PreparedStatement stmt = conn.prepareStatement(
"SELECT id, name, email FROM users WHERE id = ?")) {
stmt.setInt(1, id);
try (ResultSet rs = stmt.executeQuery()) {
if (rs.next()) {
User user = new User(rs.getInt("id"), rs.getString("name"), rs.getString("email"));
identityMap.add(User.class, id, user);
return user;
}
}
}
return null;
}
}
// Uso (requiere un driver JDBC en el classpath, p. ej. sqlite-jdbc)
Connection conn = DriverManager.getConnection("jdbc:sqlite::memory:");
conn.createStatement().execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)");
conn.createStatement().execute("INSERT INTO users (name, email) VALUES ('Alice', 'alice@example.com')");
IdentityMap im = new IdentityMap();
UserMapper mapper = new UserMapper(conn, im);
User user1 = mapper.findById(1);
User user2 = mapper.findById(1);
System.out.println(user1 == user2); // true
JavaScript
class User {
constructor(id, name, email) {
this.id = id;
this.name = name;
this.email = email;
}
}
class IdentityMap {
constructor() {
this.map = new Map();
}
get(type, key) {
const typeMap = this.map.get(type);
return typeMap ? typeMap.get(key) : undefined;
}
add(type, key, entity) {
if (!this.map.has(type)) {
this.map.set(type, new Map());
}
this.map.get(type).set(key, entity);
}
has(type, key) {
const typeMap = this.map.get(type);
return typeMap ? typeMap.has(key) : false;
}
}
class UserMapper {
constructor(db, identityMap) {
this.db = db;
this.identityMap = identityMap;
}
async findById(id) {
const cached = this.identityMap.get(User, id);
if (cached) return cached;
const row = await this.db.get('SELECT id, name, email FROM users WHERE id = ?', id);
if (!row) return null;
const user = new User(row.id, row.name, row.email);
this.identityMap.add(User, id, user);
return user;
}
async findAll() {
const rows = await this.db.all('SELECT id, name, email FROM users');
const users = [];
for (const row of rows) {
let user = this.identityMap.get(User, row.id);
if (!user) {
user = new User(row.id, row.name, row.email);
this.identityMap.add(User, row.id, user);
}
users.push(user);
}
return users;
}
}
// Uso — un sustituto mínimo de db asíncrona compatible con node:sqlite / sqlite
const db = {
rows: [{ id: 1, name: 'Alice', email: 'alice@example.com' }],
async get(_sql, id) {
return this.rows.find((r) => r.id === id) ?? null;
},
async all() {
return this.rows;
},
};
(async () => {
const im = new IdentityMap();
const mapper = new UserMapper(db, im);
const u1 = await mapper.findById(1);
const u2 = await mapper.findById(1);
console.log(u1 === u2); // true
})();
Código complementario: el repo companion de identity-map-pattern tiene las tres implementaciones listas para ejecutar.
Explicación
Cada carga sigue el mismo camino:
- Llega una solicitud para
User(id=5) - El Identity Map comprueba si
User:5ya está en memoria - Acierto: la instancia existente se devuelve sin ejecutar SQL
- Fallo: se carga la fila, se guarda en el mapa y se devuelve
Unos pocos detalles deciden si el mapa ayuda o estorba:
- Elección de la clave. Las claves primarias de una columna son triviales; las compuestas necesitan una clave compuesta: una tupla en Python, un record inmutable en Java. Si eliges el campo equivocado, cada búsqueda falla y vuelve el bug de instancias duplicadas que el mapa existe para prevenir.
- Vida del mapa. El mapa debe morir con la transacción o sesión que lo posee. Si vive más, empieza a servir datos obsoletos: otro proceso actualiza la fila mientras tu mapa sigue entregando la instancia vieja. Por eso los ORM lo atan a la vida de
Session/DbContexty no a la del proceso. - Expulsión y rollback. Al hacer rollback, vacía el mapa: los objetos que nunca se confirmaron no deberían quedar en él. Al borrar, elimina la entrada o
findresucitará filas borradas. - Concurrencia. Un mapa compartido entre hilos necesita sincronización. La solución habitual no es un lock sino el ámbito: un mapa por unidad de trabajo significa que no hay nada que compartir.
Variantes
| Variante | Ámbito | Caso de uso |
|---|---|---|
| Por transacción | Vive durante una unidad de trabajo | Comportamiento por defecto de los ORM |
| Por sesión | Vive durante una sesión de usuario | Aplicaciones web con sesiones largas |
| Por proceso | Vive durante toda la vida de la app | Datos de referencia de lectura intensiva |
| Distribuido | Compartido entre servicios | Microservicios con cachés compartidas |
La variante distribuida deja de ser un Identity Map en sentido estricto y pasa a ser una caché compartida; el patrón Cache-Aside y el patrón Two-Level Cache cubren ese terreno.
Lo que funciona
- Acota el mapa a la transacción. Los mapas de larga vida sirven datos obsoletos.
- Combínalo con Unit of Work. El mapa sabe qué está cargado; la unidad de trabajo sabe qué está sucio.
- Consulta el mapa también en las consultas de colección.
find_all()debería reutilizar instancias, no crear duplicados de filas ya cargadas. - Vacíalo al hacer rollback. No dejes objetos confirmados a medias.
- Usa referencias débiles en mapas de larga vida. Si la memoria aprieta, deja que las entidades inalcanzables se recojan.
Errores Comunes
- El mapa sobrevive a su transacción. Los objetos obsoletos son el síntoma clásico: las actualizaciones confirmadas en otro sitio nunca aparecen.
- Las consultas de colección saltan el mapa. Un
find_all()que no comprueba la identidad anula el propósito. - Las entidades borradas quedan en el mapa. Un objeto eliminado no debería volver desde
find. - Compartir un mapa entre hilos. Sin sincronización o un ámbito por sesión obtienes carreras que parecen corrupción aleatoria.
- Campo de clave equivocado. Mapear por una columna no única o mutable produce colisiones o fallos permanentes.
Ejemplos del Mundo Real
Caché de primer nivel de Hibernate
La Session de Hibernate es un Identity Map: session.get(User.class, 5) devuelve el mismo objeto en cada llamada dentro de la misma sesión. También explica por qué existe merge(): reconcilia una instancia desconectada con la que ya está en el mapa.
Entity Framework Core
Un DbContext rastrea entidades por clave, así que consultar la misma clave dos veces devuelve el mismo objeto rastreado. AsNoTracking() es la salida deliberada para cuando quieres copias frescas sin el coste del rastreo.
Session de SQLAlchemy
SQLAlchemy documenta la Session directamente como un identity map. Cargar la misma fila dos veces produce objetos Python idénticos (user1 is user2 → True); expire_all() o una sesión nueva es cómo pides estado fresco.
Lectura Adicional
- Entrada del catálogo de Martin Fowler: la definición original de Patterns of Enterprise Application Architecture
- SQLAlchemy: «Is the Session a cache?»: la visión del propio ORM sobre por qué la sesión actúa como identity map
- Change tracking en EF Core: cómo
DbContextimplementa la misma idea
Preguntas frecuentes
¿Identity Map es lo mismo que la caché de primer nivel de Hibernate?
Sí. La caché de sesión de Hibernate es una implementación literal de Identity Map: un mapa por Session, indexado por tipo de entidad y clave primaria. Cuando oigas «first-level cache» en el mundo JPA, el patrón debajo es este.
¿En qué se diferencia un Identity Map de una caché normal?
En la intención. Una caché existe para saltarse trabajo costoso y puede expulsar entradas cuando quiera. Un Identity Map existe para preservar la identidad: expulsar a mitad de transacción trae de vuelta las instancias duplicadas, justo el bug que previene. La velocidad es un efecto secundario, no el objetivo.
¿Qué clave uso con claves primarias compuestas?
Una clave compuesta: una tupla como (order_id, line_no) en Python, o un record de clave inmutable en Java. Lo que elijas debe ser único por fila y estable durante toda la vida del objeto — nunca un campo mutable como un email.
¿Un Identity Map previene las lecturas obsoletas?
No — puede causarlas. El mapa responde desde memoria, así que los cambios confirmados en otro sitio no aparecerán hasta que el mapa se vacíe o la sesión termine. Mantener el mapa con ámbito de transacción es lo que hace eso aceptable.
¿Puedo usar Identity Map sin Unit of Work?
Sí. Los dos combinan de forma natural — el mapa sabe qué está cargado, la unidad de trabajo sabe qué está sucio — pero el mapa por sí solo ya te da la garantía central: misma fila, mismo objeto.
Recursos Relacionados
Patrón Data Mapper: dominio sin dependencia de la BD
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 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 Data Access Object (DAO)
Abstrae y encapsula todo acceso a una fuente de datos exponiendo una interfaz limpia mientras oculta los detalles de persistencia de la lógica de negocio.
PatternPatrón Eager Loading: resuelve el problema N+1
Carga datos relacionados en una sola consulta en lugar de múltiples llamadas a la base de datos, evitando el problema N+1 y mejorando el rendimiento de lectura.
RecipeEstrategias de Caché: Navegador, CDN, Redis y Base de Datos
Implementa caché efectiva en cada capa del stack: caché de navegador, edge de CDN, caché de aplicación con Redis y caché de consultas de base de datos — con estrategias de invalidación que mantienen los datos frescos.
RecipeCachear resultados de consultas con Redis y Python
Cachear resultados de consultas costosas en Redis con el patrón cache-aside, gestión de TTL e invalidación en escrituras para aplicaciones Python.