StackPractices
beginner Por Mathias Paulenko

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.

Temas: design

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.
flowchart diagram: user.save()

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 RecordData MapperRepository
Dónde vive la persistenciaEn el objetoClase mapper separadaAbstracción tipo colección
El dominio conoce SQLSíNoNo
Test unitario sin BDDifícilFácilFácil
BoilerplateMínimoAltoMedio
Encaja enApps CRUD, prototiposDominios complejosDDD, aggregates
FrameworksRails, Django, EloquentHibernate, SQLAlchemy, PrismaSpring 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

VarianteDescripciónCaso de Uso
Active Record clásicoEl objeto posee su persistenciaRails, Django ORM
Data MapperUna clase mapper separada maneja la persistenciaHibernate, SQLAlchemy
RepositoryAbstracción tipo colección sobre la persistenciaAggregates DDD
Table Data GatewayMétodos estáticos en la clase, no en instanciasUtilidades 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_save y after_create crean 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.posts en 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 luego order.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

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.