StackPractices
intermediate Por Mathias Paulenko

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:

Mermaid flowchart LR diagram
  1. Llega una solicitud para User(id=5)
  2. El Identity Map comprueba si User:5 ya está en memoria
  3. Acierto: la instancia existente se devuelve sin ejecutar SQL
  4. 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/DbContext y 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 find resucitará 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ÁmbitoCaso de uso
Por transacciónVive durante una unidad de trabajoComportamiento por defecto de los ORM
Por sesiónVive durante una sesión de usuarioAplicaciones web con sesiones largas
Por procesoVive durante toda la vida de la appDatos de referencia de lectura intensiva
DistribuidoCompartido entre serviciosMicroservicios 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

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.