Patró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.
Resumen
El patrón Active Record envuelve una tabla de base de datos en una clase donde cada instancia representa una fila. El objeto carga tanto los datos como los métodos de persistencia — llamar user.save() lo escribe directamente en la base de datos sin una capa de acceso a datos intermedia.
Martin Fowler lo catalogó en Patterns of Enterprise Application Architecture y se convirtió en el estilo de mapeo por defecto de una generación de frameworks: Ruby on Rails, Django ORM, Laravel Eloquent y Sequelize están construidos sobre él. El atractivo es obvio — el mínimo código posible entre “tengo un usuario” y “está en la base de datos”. El coste es que tu modelo de dominio ahora sabe que existe SQL, y ese acoplamiento condiciona todo lo que viene después: cómo testeas, cómo migras y cuánto puede crecer el modelo.
Cuándo Usarlo
- El modelo de dominio mapea casi directo a las tablas — más o menos una clase por tabla.
- Quieres CRUD con el mínimo boilerplate posible: prototipos, MVPs, paneles de administración.
- El equipo ya usa un framework donde es la convención (Rails, Django, Laravel) — pelear contra la convención cuesta más que el patrón.
- La lógica de negocio es fina y vive naturalmente en el propio registro: validación, formato, transiciones de estado simples.
Cuándo NO Usarlo
- Lógica de dominio rica que no debería saber que existe la persistencia — usa Data Mapper o Repository para mantener el modelo puro.
- La misma entidad persiste en varias fuentes de datos o mapea a un esquema que no controlas.
- Necesitas tests unitarios pesados sin base de datos — los objetos Active Record arrastran una conexión a donde van.
- Las transacciones abarcan varias entidades con frecuencia — Active Record no tiene un hogar natural para unidades de trabajo multi-objeto.
Solución
Python
import sqlite3
from typing import Optional, List
class User:
_db_path = "app.db"
def __init__(self, id: int = None, name: str = "", email: str = ""):
self.id = id
self.name = name
self.email = email
@classmethod
def _connect(cls):
return sqlite3.connect(cls._db_path)
def save(self):
with self._connect() as conn:
if self.id is None:
cursor = conn.execute(
"INSERT INTO users (name, email) VALUES (?, ?)",
(self.name, self.email)
)
self.id = cursor.lastrowid
else:
conn.execute(
"UPDATE users SET name = ?, email = ? WHERE id = ?",
(self.name, self.email, self.id)
)
conn.commit()
return self
def delete(self):
with self._connect() as conn:
conn.execute("DELETE FROM users WHERE id = ?", (self.id,))
conn.commit()
@classmethod
def find_by_id(cls, user_id: int) -> Optional["User"]:
with cls._connect() as conn:
row = conn.execute(
"SELECT id, name, email FROM users WHERE id = ?", (user_id,)
).fetchone()
return cls(*row) if row else None
@classmethod
def find_all(cls) -> List["User"]:
with cls._connect() as conn:
rows = conn.execute("SELECT id, name, email FROM users").fetchall()
return [cls(*row) for row in rows]
# Uso
user = User(name="Alice", email="alice@example.com")
user.save()
print(User.find_by_id(user.id))
Java
import java.sql.*;
import java.util.*;
public class User {
private static String dbUrl = "jdbc:sqlite:app.db";
private int id;
private String name;
private String email;
public User() {}
public User(int id, String name, String email) {
this.id = id;
this.name = name;
this.email = email;
}
public void save() {
try (Connection conn = DriverManager.getConnection(dbUrl)) {
if (id == 0) {
PreparedStatement ps = conn.prepareStatement(
"INSERT INTO users (name, email) VALUES (?, ?)", Statement.RETURN_GENERATED_KEYS);
ps.setString(1, name);
ps.setString(2, email);
ps.executeUpdate();
ResultSet keys = ps.getGeneratedKeys();
keys.next();
id = keys.getInt(1);
} else {
PreparedStatement ps = conn.prepareStatement(
"UPDATE users SET name = ?, email = ? WHERE id = ?");
ps.setString(1, name);
ps.setString(2, email);
ps.setInt(3, id);
ps.executeUpdate();
}
} catch (SQLException e) { throw new RuntimeException(e); }
}
public void delete() {
try (Connection conn = DriverManager.getConnection(dbUrl)) {
PreparedStatement ps = conn.prepareStatement("DELETE FROM users WHERE id = ?");
ps.setInt(1, id);
ps.executeUpdate();
} catch (SQLException e) { throw new RuntimeException(e); }
}
public static User findById(int id) {
try (Connection conn = DriverManager.getConnection(dbUrl)) {
PreparedStatement ps = conn.prepareStatement("SELECT id, name, email FROM users WHERE id = ?");
ps.setInt(1, id);
ResultSet rs = ps.executeQuery();
if (rs.next()) return new User(rs.getInt(1), rs.getString(2), rs.getString(3));
} catch (SQLException e) { throw new RuntimeException(e); }
return null;
}
public int getId() { return id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
@Override
public String toString() {
return "User{id=" + id + ", name='" + name + "', email='" + email + "'}";
}
}
// Uso
User user = new User();
user.setName("Alice");
user.save();
System.out.println(User.findById(user.getId()));
JavaScript
// Requiere contexto ESM (await de primer nivel) o envuelve el uso en una función async.
class User {
static db = null; // Conexión de base de datos inyectada
constructor({ id = null, name = '', email = '' } = {}) {
this.id = id;
this.name = name;
this.email = email;
}
async save() {
if (this.id === null) {
const result = await User.db.run(
'INSERT INTO users (name, email) VALUES (?, ?)',
this.name, this.email
);
this.id = result.lastID;
} else {
await User.db.run(
'UPDATE users SET name = ?, email = ? WHERE id = ?',
this.name, this.email, this.id
);
}
return this;
}
async delete() {
await User.db.run('DELETE FROM users WHERE id = ?', this.id);
}
static async findById(userId) {
const row = await User.db.get('SELECT id, name, email FROM users WHERE id = ?', userId);
return row ? new User(row) : null;
}
static async findAll() {
const rows = await User.db.all('SELECT id, name, email FROM users');
return rows.map(row => new User(row));
}
}
// Uso (ESM)
const user = new User({ name: 'Alice', email: 'alice@example.com' });
await user.save();
const found = await User.findById(user.id);
console.log(found);
Cómo Funciona
Un objeto Active Record es una fila con responsabilidades. Tres cosas viven en el mismo lugar:
- Datos de dominio: campos que mapean uno a uno con las columnas.
- Métodos de persistencia:
save(),delete(),find()— generan y ejecutan el SQL. - Reglas de dominio: validación y comportamiento pequeño que cabe en un solo registro.
La consecuencia que nadie te advierte: en cuanto existe user.save(), cualquier capa de la app puede tocar la base de datos. No hay una frontera que diga dónde termina la persistencia y empieza la lógica de negocio. Ese es el trato a cambio de cero boilerplate — escala muy bien en apps CRUD y se convierte en lastre cuando el dominio se complica.
Active Record vs Data Mapper vs Repository — los tres patrones que resuelven el mismo problema:
| Active Record | Data Mapper | Repository | |
|---|---|---|---|
| Dónde vive la persistencia | En el objeto | Clase mapper separada | Abstracción tipo colección |
| El dominio conoce SQL | Sí | No | No |
| Test unitario sin BD | Difícil | Fácil | Fácil |
| Boilerplate | Mínimo | Alto | Medio |
| Encaja en | Apps CRUD, prototipos | Dominios complejos | DDD, aggregates |
| Frameworks | Rails, Django, Eloquent | Hibernate, SQLAlchemy, Prisma | Spring Data, Doctrine |
El contraste en dos minutos — la misma operación en cada estilo:
# Active Record: el objeto sabe persistirse a sí mismo
user = User(name="Alice")
user.save()
# Data Mapper: la persistencia es un servicio al que le entregas el objeto
user = User(name="Alice")
mapper.save(user)
# Repository: piensas en colecciones, no en filas
users.add(user)
users.commit()
Variantes
| Variante | Descripción | Caso de Uso |
|---|---|---|
| Active Record clásico | El objeto posee su persistencia | Rails, Django ORM |
| Data Mapper | Una clase mapper separada maneja la persistencia | Hibernate, SQLAlchemy |
| Repository | Abstracción tipo colección sobre la persistencia | Aggregates DDD |
| Table Data Gateway | Métodos estáticos en la clase, no en instancias | Utilidades CRUD simples |
Lo que funciona
- Mantén las validaciones en el modelo. Chequea las restricciones antes de guardar y lanza errores con significado.
- Usa callbacks con moderación. Los hooks
before_saveyafter_createcrean flujo de control invisible. - Acota las queries.
User.where(active=True)es más seguro que SQL crudo en la lógica de negocio. - Carga perezosa de asociaciones. Carga los registros relacionados solo cuando se accede a ellos, no en cada fetch.
- Nada de lógica de negocio en la base de datos. Los stored procedures acoplan tu código al vendor de BD.
Errores Comunes
- Fat models con 500 líneas de código. Separa la lógica de negocio en service objects.
- N+1 queries al iterar sobre asociaciones. Usa eager loading (
select_related,includes). - Acceso a base de datos en tests unitarios. Active Record lo hace difícil. Usa SQLite in-memory o mocks.
- Validación en controllers en lugar del modelo. El modelo es el lugar autorizado para las reglas.
- Mutar durante la iteración. Modificar una colección mientras iteras causa comportamiento indefinido.
Ejemplos del Mundo Real
Ruby on Rails
User.create(name: "Alice") crea un registro, lo valida y lo persiste en una llamada. Asociaciones como user.posts se cargan perezosamente — el framework que hizo famoso el patrón y sigue siendo su implementación más completa.
Django ORM
user.save() y User.objects.get(id=1) son operaciones Active Record. Django añade managers (objects) para las queries de colección — medio paso hacia Repository manteniendo el registro a cargo de las escrituras.
Laravel Eloquent
User::find(1) y $user->save() siguen Active Record. Eloquent también soporta relaciones, scopes y query builders.
Sequelize y afines (JavaScript)
Los modelos de Sequelize se acercan al Active Record clásico (user.save(), User.findAll()). TypeORM ofrece ambos modos — clases base ActiveRecord y DataMapper — y Prisma se inclina hacia Data Mapper. Conviene saber de qué lado está tu ORM antes de diseñar alrededor.
Solución de Problemas
- Tormentas de N+1 en producción: el código itera
user.postsen un bucle y cada acceso dispara una query — loguea el número de queries por request y carga la asociación por adelantado (includes,select_related,preload). - Tests unitarios que necesitan una base de datos real: el modelo no se puede instanciar sin conexión — corre la suite contra SQLite in-memory, o saca la lógica del registro y llévala a un service object plano.
- Fat model que nadie quiere tocar: cientos de líneas de callbacks, scopes y reglas de negocio en una clase — extrae el comportamiento de dominio a service objects y deja el registro para persistencia y validación.
save()que omite campos en silencio: se añadió una columna a la tabla pero nunca a los atributos del modelo — revisa la lista de campos del modelo o la definición del esquema antes de asumir que el ORM recoge columnas nuevas automáticamente.- Transacción que abarca varios registros:
user.save()y luegoorder.save(), y el segundo falla — envuelve las operaciones multi-registro en un bloque de transacción explícito; si ese patrón se repite, es señal de que el dominio pide Unit of Work. - Objetos que se escapan del ciclo de vida de la conexión: un registro creado dentro de un request sigue vivo (y consultando) después de que el request termine — acota el uso de registros al request o a la frontera de la unidad de trabajo.
Lectura Adicional
- Martin Fowler — Active Record (catálogo PoEAA) — la definición original del patrón.
- Rails Guides: Active Record Basics — la implementación de referencia.
- Django docs: Models — cómo un segundo framework dio forma al mismo patrón.
- Patrones: Data Mapper y Repository — las salidas cuando Active Record deja de encajar.
Las versiones ejecutables de estos ejemplos están en el repositorio companion: stack-practices-resources/resources/patterns/design/active-record-pattern.
Preguntas frecuentes
¿Cuál es la diferencia entre Active Record y Data Mapper?
Active Record pone los métodos de persistencia en el propio objeto de dominio — el objeto sabe que es una fila. Data Mapper mantiene el objeto puro y delega la persistencia a una clase mapper separada. Elige Active Record para dominios con forma de CRUD y Data Mapper cuando la lógica es lo bastante compleja para merecer aislamiento.
¿Active Record es un anti-pattern?
No — es un trade-off con alcance. En apps con mucho CRUD es el estilo de acceso a datos más productivo que existe. Solo se convierte en problema cuando el dominio se le queda grande: invariantes complejos, transacciones multi-entidad o necesidad fuerte de tests unitarios. La mayoría de las críticas vienen de aplicarlo fuera de ese alcance.
¿Cómo testeo objetos Active Record sin base de datos?
Dos rutas pragmáticas: correr los tests contra SQLite in-memory (rápido, SQL real, sin fixtures), o extraer la lógica a service objects planos a los que el registro delega. Mockear save() directamente suele producir tests que no afirman nada.
¿Cómo manejo las queries N+1 con Active Record?
Carga la asociación por adelantado antes de iterar: User.includes(:posts) en Rails, select_related/prefetch_related en Django, User::with('posts') en Eloquent. Un log de conteo de queries en desarrollo atrapa la mayoría de N+1 antes de que lleguen a producción.
¿Por qué Ruby on Rails usa Active Record?
Porque Rails apostó por convención sobre configuración: si tu tabla es users y tu clase es User, hace falta cero código de mapeo. Esa apuesta es lo que hizo productivo rails new + scaffold en 2005, y el patrón está en la identidad del framework.
¿Qué pasa con las transacciones entre varios registros?
Active Record no tiene un dueño natural para transacciones multi-entidad — cada objeto gestiona su propia persistencia. Envuélvelas explícitamente (ActiveRecord::Base.transaction en Rails, transaction.atomic en Django). Si te ves haciéndolo constantemente, Unit of Work es el patrón hecho para eso.
¿Puedo usar Active Record con una base de datos legacy o compartida?
Con cuidado. El patrón asume que el objeto mapea limpio a una tabla que controlas. Con un esquema ajeno — nombres de columna raros, claves compuestas, sin id subrogado — un Data Mapper absorbe mejor el desajuste que forzar la forma del registro sobre tablas externas.
¿Cuándo debería salir de Active Record?
Cuando las señales se acumulan: service objects superando en número a los métodos del modelo, tests que pasan más tiempo stubbeando la BD que afirmando comportamiento, o reglas de negocio que necesitan datos de tres tablas para decidir. Ese es el punto donde Data Mapper o Repository devuelven su boilerplate.
Recursos Relacionados
Patró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 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 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.
PatternRepository Pattern con Generics de TypeScript
Implementa el Repository pattern con generics de TypeScript para desacoplar la lógica de acceso a datos de los servicios de dominio usando interfaces.
PatternPatró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.
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.