StackPractices
intermediate Por Mathias Paulenko

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.

Descripción General

El Patrón Data Mapper separa los objetos de dominio en memoria de la base de datos delegando toda la lógica de persistencia a una capa de mappers dedicada. El modelo de dominio no sabe nada de la base de datos: ni SQL, ni decoradores ORM, ni métodos save(). Un objeto mapper aparte hace toda la traducción: objetos a filas al escribir, filas a objetos al leer.

Es el patrón detrás de Hibernate (JPA), del mapeo clásico de SQLAlchemy y del patrón Repository cuando se usa con ORMs. Te da la separación de responsabilidades más limpia para dominios complejos, a cambio de más código repetitivo que Active Record.

Cuándo Usar

Usa el Patrón Data Mapper cuando:

  • El modelo de dominio es rico y debería permanecer independiente de la base de datos
  • Necesitas mapear el mismo objeto de dominio a varios esquemas de base de datos
  • Probar la lógica de dominio sin tocar la base de datos es importante
  • El mecanismo de persistencia puede cambiar (SQL ahora, NoSQL después)

Cuándo Evitar

  • Aplicaciones CRUD simples donde Active Record es suficiente
  • Cuando el sobrecoste de una capa de mapeo extra no está justificado
  • Prototipos o herramientas internas donde la velocidad de desarrollo importa más que la pureza

Solución

Python

from dataclasses import dataclass
from typing import Optional, List

# Modelo de Dominio — no sabe nada de la base de datos
@dataclass
class User:
    id: Optional[int] = None
    name: str = ""
    email: str = ""

    def update_email(self, new_email: str):
        if "@" not in new_email:
            raise ValueError("Invalid email")
        self.email = new_email


# Data Mapper — maneja toda la lógica de persistencia
class UserMapper:
    def __init__(self, connection):
        self._conn = connection

    def find_by_id(self, user_id: int) -> Optional[User]:
        row = self._conn.execute(
            "SELECT id, name, email FROM users WHERE id = ?", (user_id,)
        ).fetchone()
        if row:
            return User(id=row["id"], name=row["name"], email=row["email"])
        return None

    def find_all(self) -> List[User]:
        rows = self._conn.execute("SELECT id, name, email FROM users").fetchall()
        return [User(id=r["id"], name=r["name"], email=r["email"]) for r in rows]

    def insert(self, user: User):
        cursor = self._conn.execute(
            "INSERT INTO users (name, email) VALUES (?, ?)",
            (user.name, user.email)
        )
        user.id = cursor.lastrowid

    def update(self, user: User):
        self._conn.execute(
            "UPDATE users SET name = ?, email = ? WHERE id = ?",
            (user.name, user.email, user.id)
        )

    def delete(self, user_id: int):
        self._conn.execute("DELETE FROM users WHERE id = ?", (user_id,))


# Uso
import sqlite3
conn = sqlite3.connect(":memory:")
conn.row_factory = sqlite3.Row  # permite acceder a las filas por nombre de columna
conn.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)")

mapper = UserMapper(conn)
user = User(name="Alice", email="alice@example.com")
mapper.insert(user)
print(f"Inserted user with ID: {user.id}")

loaded = mapper.find_by_id(user.id)
print(loaded.name, loaded.email)

Java

import java.sql.*;
import java.util.*;

public class User {
    private Integer id;
    private String name;
    private String email;

    public User() {}
    public User(String name, String email) {
        this.name = name;
        this.email = email;
    }

    public Integer getId() { return id; }
    public void setId(Integer id) { this.id = 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; }

    public void updateEmail(String newEmail) {
        if (!newEmail.contains("@"))
            throw new IllegalArgumentException("Invalid email");
        this.email = newEmail;
    }
}

class UserMapper {
    private final Connection conn;

    public UserMapper(Connection conn) { this.conn = conn; }

    public User findById(int id) throws SQLException {
        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();
                    user.setId(rs.getInt("id"));
                    user.setName(rs.getString("name"));
                    user.setEmail(rs.getString("email"));
                    return user;
                }
            }
        }
        return null;
    }

    public List<User> findAll() throws SQLException {
        List<User> users = new ArrayList<>();
        try (Statement stmt = conn.createStatement();
             ResultSet rs = stmt.executeQuery(
                     "SELECT id, name, email FROM users")) {
            while (rs.next()) {
                User user = new User();
                user.setId(rs.getInt("id"));
                user.setName(rs.getString("name"));
                user.setEmail(rs.getString("email"));
                users.add(user);
            }
        }
        return users;
    }

    public void insert(User user) throws SQLException {
        try (PreparedStatement stmt = conn.prepareStatement(
                "INSERT INTO users (name, email) VALUES (?, ?)",
                Statement.RETURN_GENERATED_KEYS)) {
            stmt.setString(1, user.getName());
            stmt.setString(2, user.getEmail());
            stmt.executeUpdate();
            try (ResultSet keys = stmt.getGeneratedKeys()) {
                if (keys.next()) user.setId(keys.getInt(1));
            }
        }
    }

    public void update(User user) throws SQLException {
        try (PreparedStatement stmt = conn.prepareStatement(
                "UPDATE users SET name = ?, email = ? WHERE id = ?")) {
            stmt.setString(1, user.getName());
            stmt.setString(2, user.getEmail());
            stmt.setInt(3, user.getId());
            stmt.executeUpdate();
        }
    }

    public void delete(int id) throws SQLException {
        try (PreparedStatement stmt = conn.prepareStatement(
                "DELETE FROM users WHERE id = ?")) {
            stmt.setInt(1, id);
            stmt.executeUpdate();
        }
    }
}

// Uso
public class Main {
    public static void main(String[] args) throws SQLException {
        Connection conn = DriverManager.getConnection("jdbc:sqlite::memory:");
        conn.createStatement().execute(
                "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)");

        UserMapper mapper = new UserMapper(conn);
        User user = new User("Alice", "alice@example.com");
        mapper.insert(user);
        System.out.println("Inserted user with ID: " + user.getId());
    }
}

JavaScript

class User {
  constructor(name, email) {
    this.id = null;
    this.name = name;
    this.email = email;
  }

  updateEmail(newEmail) {
    if (!newEmail.includes('@')) throw new Error('Invalid email');
    this.email = newEmail;
  }
}

class UserMapper {
  constructor(db) {
    this.db = db;
  }

  findById(id) {
    const row = this.db
      .prepare('SELECT id, name, email FROM users WHERE id = ?')
      .get(id);
    if (!row) return null;
    const user = new User(row.name, row.email);
    user.id = row.id;
    return user;
  }

  findAll() {
    return this.db
      .prepare('SELECT id, name, email FROM users')
      .all()
      .map(row => {
        const user = new User(row.name, row.email);
        user.id = row.id;
        return user;
      });
  }

  insert(user) {
    const result = this.db
      .prepare('INSERT INTO users (name, email) VALUES (?, ?)')
      .run(user.name, user.email);
    user.id = result.lastInsertRowid;
  }

  update(user) {
    this.db
      .prepare('UPDATE users SET name = ?, email = ? WHERE id = ?')
      .run(user.name, user.email, user.id);
  }

  delete(id) {
    this.db.prepare('DELETE FROM users WHERE id = ?').run(id);
  }
}

// Uso — node:sqlite integrado en Node, sin dependencias
// (en Node 22.x, ejecuta con --experimental-sqlite)
const { DatabaseSync } = require('node:sqlite');
const db = new DatabaseSync(':memory:');
db.exec('CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)');

const mapper = new UserMapper(db);
const user = new User('Alice', 'alice@example.com');
mapper.insert(user);
console.log(`Inserted user with ID: ${user.id}`);

const loaded = mapper.findById(user.id);
console.log(loaded.name, loaded.email);

Explicación

Data Mapper divide la persistencia en tres capas que solo se hablan a través del mapper:

flowchart diagram: subgraph Domain[
  • Modelo de dominio — objetos planos con reglas de negocio (update_email valida la dirección). Nada en él importa un driver, lleva una anotación o sabe qué significa save().
  • Data mapper — la clase dueña del SQL. Convierte objetos en filas a la ida y filas en objetos a la vuelta.
  • Base de datos — el modelo de dominio no puede verla en absoluto.

Lecturas y escrituras siguen el mismo camino. En insert, el mapper lee los campos del objeto, ejecuta el SQL y escribe el ID generado de vuelta. En find_by_id, lanza la consulta y reconstruye un objeto de dominio a partir de la fila. El objeto de dominio nunca ve una conexión, un cursor ni un nombre de columna.

Toda esa indirección no es gratis. Cada consulta pega una vez a la base de datos por llamada al mapper, así que un bucle con find_by_id produce un problema N+1 que Active Record esconde detrás de sus helpers includes/joins. Las soluciones habituales están en las variantes de abajo: un Identity Map desduplica las filas cargadas, la carga perezosa difiere objetos relacionados y una Unit of Work agrupa inserts y updates en una sola transacción.

Las transacciones son el otro borde afilado. mapper.insert(user) hace commit por su cuenta salvo que tu conexión envuelva varias llamadas — por eso las implementaciones reales combinan el mapper con Unit of Work en vez de dejar que controle los límites de transacción.

El coste es el código repetitivo: cada nuevo campo de dominio implica tocar el INSERT, el UPDATE y el SELECT del mapper. Compensa cuando el esquema y el modelo de objetos evolucionan por separado. No compensa cuando son un espejo 1:1 — esa asimetría es justo lo que resuelve Active Record.

Variantes

VarianteEnfoqueCaso de Uso
Identity MapMantiene una sola instancia por filaEvita que la misma fila se convierta en dos objetos
Unit of WorkAcumula cambios y los confirma juntosConsistencia transaccional
Query ObjectEnvuelve una consulta en un objetoConsultas de base de datos componibles
RepositorySe interpone entre dominio y datosOculta los detalles del mapper a la aplicación

Lo que funciona

  • Mantén el modelo de dominio puro. Sin dependencias del framework, sin métodos de persistencia.
  • Un mapper por clase de dominio. Cuando un mapper empieza a cubrir una docena de tipos, nadie se orienta.
  • Ojo con la carga perezosa. Parece gratis hasta que aparecen las N+1 consultas.
  • Identity Map evita instancias duplicadas. Una vez que construye un objeto a partir de una fila, devuelve ese mismo objeto en vez de fabricar una segunda copia.
  • Devuelve copias inmutables para que quien llame no pueda mutar el estado del dominio a espaldas del mapper.

Errores Comunes

  • Filtrar SQL al modelo de dominio. Si el modelo conoce la base de datos, ya no es Data Mapper.
  • Mapper como clase gigante. Una clase que gestiona 20 tipos de dominio se vuelve inmantenible.
  • Ignorar las transacciones. Las operaciones individuales del mapper tienen que poder componerse en transacciones.
  • Grafos de objetos profundos sin carga perezosa. Cargar un árbol entero de golpe puede traerse toda la base de datos.
  • Confundir mappers con repositorios. Un mapper mueve objetos dentro y fuera del almacén; un repositorio finge que tus objetos de dominio son solo una colección.

Ejemplos del Mundo Real

Hibernate (JPA)

Hibernate mapea entidades a tablas mediante XML o anotaciones, así que las clases de entidad se quedan libres de código de persistencia. Su Session hace el papel de mapper: sigue el estado del objeto y lo convierte en SQL y viceversa.

Mapeo clásico de SQLAlchemy

SQLAlchemy soporta los estilos declarativo (tipo Active Record) y clásico (Data Mapper). Con el mapeo clásico llamas a mapper(User, user_table), así que la clase nunca toca la definición de la tabla.

Doctrine ORM

Doctrine, el ORM de PHP, es otra implementación de Data Mapper: las entidades se quedan simples y los mapeos XML/YAML/anotaciones cargan con todos los detalles de persistencia.

Ver también

Preguntas frecuentes

¿Cuál es la diferencia entre Data Mapper y Active Record?

Active Record junta datos y persistencia en la misma clase: un User sabe cómo guardarse a sí mismo con save(). Data Mapper mueve eso a un objeto mapper separado, así que la clase de dominio no sabe nada de la base de datos. El precio es el código repetitivo — mantienes el mapeo a mano — a cambio de una capa de dominio que puedes probar y portar sin base de datos.

¿Es Data Mapper más lento que Active Record?

La capa de mapeo en sí cuesta microsegundos: las copias extra de objetos son baratas frente a un round-trip de red. Donde sí puede volverse lento es en el N+1: cada llamada al mapper lanza su propio SQL y los bucles ingenuos multiplican los round-trips. Los Identity Maps, la carga perezosa y los joins lo resuelven; por algo los ORMs completos traen los tres.

¿Puedo usar Data Mapper con bases de datos NoSQL?

Sí — de hecho, es una de las principales razones por las que los equipos lo eligen. El mapper solo traduce entre objetos de dominio y lo que devuelva el almacén: un documento, un blob clave-valor, una fila. Como el modelo de dominio no depende de detalles de SQL, cambiar SQLite por MongoDB significa reescribir el mapper, no la lógica de negocio.

¿Cómo funcionan las transacciones con un Data Mapper?

El mapper no debería ser dueño de las transacciones: cada método que usa conn.execute hace commit de forma independiente. Envuelve varias llamadas del mapper en una transacción explícita sobre la conexión compartida, o combínalo con Unit of Work, que acumula los cambios y los confirma en un solo lote. Es la combinación que la Session de Hibernate implementa por dentro.