StackPractices
beginner Por Mathias Paulenko

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.

Temas: design

Descripción General

El Patrón Data Access Object (DAO) separa las operaciones de acceso a datos de bajo nivel de la lógica de negocio de alto nivel. Un DAO ofrece al resto de la aplicación una interfaz limpia para crear, leer, actualizar y eliminar entidades, mientras él se encarga del SQL, el manejo de conexiones y el mapeo de filas a objetos.

Una llamada como userDao.findById(42) no dice de dónde sale la fila. PostgreSQL, MongoDB, un fake en memoria durante un test: el negocio nunca se entera, y esconder justo eso es el trabajo del patrón.

El Problema

La mayoría de las bases de código empiezan bien: una consulta aquí, algo de mapeo de filas allá. Quita el DAO —o no lo escribas nunca— y ese código de base de datos se esparce hasta que cada módulo conoce SQL. El fallo típico se ve así: el servicio de pedidos ejecuta SELECT * FROM orders WHERE status = 'pending' en línea, el módulo de reportes duplica la misma consulta con un filtro apenas distinto, y un tercer lugar incrusta lógica de JOIN que solo tiene sentido en MySQL. De ahí salen tres consecuencias:

  • Las pruebas se vuelven dolorosas. No puedes probar una regla de negocio sin levantar una base de datos real, porque la consulta está pegada a la regla.
  • Los cambios se dispersan. Renombra una columna y terminarás buscando en cada archivo que alguna vez tocó la tabla.
  • Cambiar de almacenamiento es una reescritura. Mover una entidad a Redis o MongoDB obliga a tocar código de negocio que nunca debió conocer SQL.

El patrón DAO arregla esto trazando una línea: todo lo que tiene forma de base de datos queda de un lado, todo lo que tiene forma de negocio del otro.

Cuándo Usar

Usa el Patrón DAO cuando:

  • Tus tests necesitan una costura de persistencia que puedan mockear o stubear
  • La fuente de datos puede cambiar: SQL hoy, MongoDB o una capa de cache mañana
  • Las mismas operaciones CRUD sobre las mismas tablas aparecen en varios sitios
  • Prefieres centralizar la construcción de consultas y el manejo de conexiones en un solo archivo

Cuándo Evitar

  • Un script desechable o un prototipo donde tres líneas de SQL resuelven
  • Un proyecto que ya usa un ORM completo (SQLAlchemy, Hibernate, modelos de Django) y te da la misma abstracción gratis. Añadir un DAO encima suele ser una segunda capa, más delgada, haciendo el mismo trabajo
  • Equipos para los que el boilerplate extra de interfaz + implementación cuesta más de lo que ahorra

Solución

La forma es siempre la misma: una interfaz orientada al dominio, una implementación por tecnología de almacenamiento, y código de negocio que solo ve la interfaz. Los tres ejemplos de abajo reciben su conexión a base de datos por el constructor. Es deliberado, y es el detalle que hace al patrón testeable.

Python

from dataclasses import dataclass
from typing import Optional, List
import sqlite3


@dataclass
class User:
    id: int
    name: str
    email: str


class UserDao:
    """Recibe una sqlite3.Connection abierta; el caller controla su ciclo de vida."""

    def __init__(self, conn: sqlite3.Connection):
        self.conn = conn

    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()
        return User(*row) if row else None

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

    def save(self, user: User) -> User:
        cursor = self.conn.execute(
            "INSERT INTO users (name, email) VALUES (?, ?)",
            (user.name, user.email)
        )
        user.id = cursor.lastrowid
        self.conn.commit()
        return user

    def delete(self, user_id: int) -> None:
        self.conn.execute("DELETE FROM users WHERE id = ?", (user_id,))
        self.conn.commit()


# Uso — el mismo DAO funciona contra un archivo o una DB en memoria para tests
conn = sqlite3.connect("app.db")
dao = UserDao(conn)
user = dao.save(User(id=0, name="Alice", email="alice@example.com"))
found = dao.find_by_id(user.id)
print(found)

Java

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

// Un solo archivo por comodidad de lectura. En un proyecto real van en
// User.java, UserDao.java y SqlUserDao.java como tipos public separados.
record User(int id, String name, String email) {}

interface UserDao {
    Optional<User> findById(int id);
    List<User> findAll();
    User save(User user);
    void delete(int id);
}

class DataAccessException extends RuntimeException {
    DataAccessException(Throwable cause) { super(cause); }
}

public class SqlUserDao implements UserDao {
    private final Connection conn;

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

    public Optional<User> findById(int id) {
        try (PreparedStatement ps = conn.prepareStatement(
            "SELECT id, name, email FROM users WHERE id = ?")) {
            ps.setInt(1, id);
            ResultSet rs = ps.executeQuery();
            if (rs.next()) {
                return Optional.of(new User(rs.getInt(1), rs.getString(2), rs.getString(3)));
            }
            return Optional.empty();
        } catch (SQLException e) {
            throw new DataAccessException(e);
        }
    }

    public List<User> findAll() {
        List<User> users = new ArrayList<>();
        try (Statement st = conn.createStatement();
             ResultSet rs = st.executeQuery("SELECT id, name, email FROM users")) {
            while (rs.next()) {
                users.add(new User(rs.getInt(1), rs.getString(2), rs.getString(3)));
            }
            return users;
        } catch (SQLException e) {
            throw new DataAccessException(e);
        }
    }

    public User save(User user) {
        try (PreparedStatement ps = conn.prepareStatement(
            "INSERT INTO users (name, email) VALUES (?, ?)", Statement.RETURN_GENERATED_KEYS)) {
            ps.setString(1, user.name());
            ps.setString(2, user.email());
            ps.executeUpdate();
            ResultSet keys = ps.getGeneratedKeys();
            keys.next();
            return new User(keys.getInt(1), user.name(), user.email());
        } catch (SQLException e) {
            throw new DataAccessException(e);
        }
    }

    public void delete(int id) {
        try (PreparedStatement ps = conn.prepareStatement(
            "DELETE FROM users WHERE id = ?")) {
            ps.setInt(1, id);
            ps.executeUpdate();
        } catch (SQLException e) {
            throw new DataAccessException(e);
        }
    }
}

// Uso — en producción pásale al DAO una conexión de un pool (HikariCP, DataSource)
UserDao dao = new SqlUserDao(conn);
User saved = dao.save(new User(0, "Alice", "alice@example.com"));
System.out.println(dao.findById(saved.id()).orElseThrow());

JavaScript

// Usa el paquete `sqlite`, el wrapper async sobre sqlite3 que expone
// .get()/.all()/.run(). Si prefieres API síncrona, usa `better-sqlite3`.
import sqlite3 from 'sqlite3';
import { open } from 'sqlite';

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

  async findById(userId) {
    const row = await this.db.get(
      'SELECT id, name, email FROM users WHERE id = ?', userId
    );
    return row || null;
  }

  async findAll() {
    return this.db.all('SELECT id, name, email FROM users');
  }

  async save(user) {
    const result = await this.db.run(
      'INSERT INTO users (name, email) VALUES (?, ?)',
      user.name, user.email
    );
    return { id: result.lastID, name: user.name, email: user.email };
  }

  async delete(userId) {
    await this.db.run('DELETE FROM users WHERE id = ?', userId);
  }
}

// Uso
const db = await open({ filename: 'app.db', driver: sqlite3.Database });
const dao = new UserDao(db);
const saved = await dao.save({ name: 'Alice', email: 'alice@example.com' });
console.log(await dao.findById(saved.id));

Explicación

Tres roles hacen funcionar al patrón:

  • La interfaz declara operaciones CRUD en términos de dominio (findById, save, delete) sin que tipos de SQL se filtren a la firma.
  • La implementación (SqlUserDao, MongoUserDao, FakeUserDao en tests) es dueña de las consultas, del mapeo de resultados y del manejo de errores específico del almacenamiento.
  • El consumidor es tu lógica de negocio. Solo ve la interfaz y nunca llega a saber qué base de datos responde.

Dos detalles importan más que la forma:

Inyecta la conexión, no la crees. Los tres ejemplos reciben una conexión lista en el constructor. Esa decisión hace dos cosas a la vez: los tests pueden darle al DAO un SQLite en memoria o un handle stub, y el código de producción puede pasar una conexión sacada de un pool. Abrir una conexión nueva dentro de cada método del DAO es justo el antipatrón de “conexión por consulta” listado en los errores de abajo. Si tu DAO necesita un pool de verdad (normalmente lo necesita), conéctalo mediante el pool de conexiones a base de datos y pásale al DAO una conexión del pool o un DataSource.

Esconde las excepciones de infraestructura. SQLException, MongoError, timeouts del driver: nada de eso pertenece a los catch de tu capa de servicios. Envuélvelos en una sola excepción de dominio (DataAccessException arriba) para que los consumidores manejen “falló el guardado” en lugar de “JDBC falló con SQLState 23505”.

flowchart diagram: Lógica de negocio

Consecuencias y Trade-offs

El patrón te compra testeabilidad (mockea la interfaz, prueba la lógica de negocio sin base de datos), portabilidad (cambia la implementación, conserva el código que la usa) y un único lugar para auditar toda la persistencia: cada consulta contra users vive en un solo archivo.

El costo también es real:

  • Boilerplate. Cada entidad recibe una interfaz, una implementación y código de mapeo, unas 40–80 líneas antes de escribir la primera consulta interesante. En una app de cinco tablas es una capa que mantendrás para siempre.
  • El desfase de impedancia no desaparece. Sigues escribiendo SQL, sigues peleando con problemas N+1, sigues pensando en filas. El DAO contiene el desorden; no lo elimina.
  • Costo de indirección. Leer una cadena de llamadas gana un salto más: servicio → interfaz → implementación → driver. Los devs nuevos pasan sus primeros días trazándola.

Por eso el encuadre honesto es “DAO cuando necesitas la costura”, no “DAO en todas partes”.

Variantes

VarianteNivel de AbstracciónCaso de Uso
Table DAOUn DAO por tablaAplicaciones CRUD simples
Generic DAOBaseDao<T> con CRUD compartidoMuchas entidades, reduce boilerplate
RepositoryConsultas de dominio como findActiveSinceDiseño centrado en agregados, DDD
Active RecordLa entidad carga su propia persistenciaFrameworks (Rails, Django, Eloquent)
Data MapperUn mapper separado mueve datos en ambas direccionesEsquemas complejos, bases de datos legacy

DAO y Repository se solapan lo bastante como para que los equipos discutan sobre ellos sin fin. La división práctica es que un DAO refleja el esquema (uno por tabla, con forma de CRUD) mientras un Repository refleja el dominio (uno por agregado, con forma de consulta). Si tus métodos son insert, update, delete, tienes un DAO; si son findOverdueInvoices, tienes un Repository.

Lo que Funciona

  • Devuelve objetos de dominio, no result sets. Mapea filas a entidades en el borde del DAO. ResultSet, cursores y filas del driver nunca lo cruzan.
  • Programa contra una interfaz. Habilita un FakeUserDao para tests y una segunda implementación cuando cambie el almacenamiento. En lenguajes dinámicos, conserva las mismas firmas de método y documéntalas.
  • Envuelve las excepciones de almacenamiento una vez. Una DataAccessException por implementación mantiene legibles los catch.
  • Usa batch cuando el volumen lo justifique. INSERT ... ON CONFLICT con un executemany() multi-fila o addBatch() de JDBC puede bajar una importación de 10k filas de minutos a segundos, pero mide antes de batchear cualquier cosa de menos de unos cientos de filas.
  • Prefiere query builders para consultas complejas. Los strings SQL crudos se vuelven inmanejables cuando añades joins, subconsultas y filtros opcionales. SQLAlchemy (Python), jOOQ (Java) o Knex.js (JavaScript) componen consultas programáticamente:
# SQLAlchemy — los filtros se componen sin concatenar strings
from sqlalchemy import select, and_

def find_active_users_since(self, since: datetime) -> List[User]:
    stmt = select(User).where(
        and_(User.created_at >= since, User.is_active == True)
    )
    return self.session.execute(stmt).scalars().all()
  • Pagina los result sets grandes. find_all() está bien con 200 filas y es un problema de memoria con 2 millones. Añade LIMIT/OFFSET o paginación por clave temprano, ya que adaptarlo después significa cambiar cada caller:
def find_paginated(self, page: int, page_size: int = 50) -> List[User]:
    offset = (page - 1) * page_size
    rows = self.conn.execute(
        "SELECT id, name, email FROM users LIMIT ? OFFSET ?",
        (page_size, offset)
    ).fetchall()
    return [User(*row) for row in rows]

Errores Comunes

  • SQL esparcido por la lógica de negocio. En el momento en que un método de servicio escribe un string de consulta, el borde del DAO está roto. Toda construcción de consultas pertenece al DAO.
  • Devolver tipos del driver. Entregar un ResultSet o un cursor crudo hace que cada caller sea intestable y te amarra a una base de datos.
  • Sin interfaz (o una que filtra tipos SQL). Sin una costura real, los consumidores se acoplan a la clase concreta y la historia de cambiar-de-base-de-datos muere.
  • El DAO Dios. Cincuenta métodos en UserDao (findByEmail, findByRegion, findActive, findRecent, findByEmailAndRegion…) es un problema de modelado de dominio. Divide por agregado o mueve los métodos con forma de consulta a un Repository.
  • Una conexión por consulta. Abrir new SqlConnection() dentro de findById() mata el throughput bajo carga. Inyecta la conexión, deja que un pool controle su ciclo de vida.

Ejemplos del Mundo Real

JDBC (Java)

JDBC no es un DAO en sí; es la plomería que los DAO envuelven. PreparedStatement, ResultSet y Connection son la materia prima; frameworks como JdbcTemplate de Spring añaden una capa delgada amigable con DAO encima, y JPA/Hibernate reemplaza por completo los DAO escritos a mano para muchos equipos.

Django ORM (Python)

Los managers de modelos de Django tienen forma de DAO: User.objects.filter(email="alice@example.com") esconde el SQL detrás de una API de dominio. La diferencia es que Django genera el DAO por ti, así que obtienes los beneficios del patrón sin escribir el boilerplate.

Knex.js (Node.js)

Knex es un query builder, no un DAO completo, pero knex('users').where({ id: 42 }).first() ocupa el mismo lugar: el código de negocio habla con una API casi de dominio y el SQL queda fuera. La mayoría de capas DAO en Node son clases delgadas sobre Knex, Prisma o Drizzle.

Lecturas Adicionales

Las versiones ejecutables de estos ejemplos viven en el repositorio complementario: stack-practices-resources/resources/patterns/design/data-access-object-pattern.

Preguntas frecuentes

¿Cuál es la diferencia entre DAO y Repository?

DAO es centrado en persistencia: normalmente uno por tabla, métodos con forma de CRUD, delgado sobre el driver. Repository es centrado en dominio: uno por agregado, con métodos de consulta nombrados en términos de negocio como findOverdueInvoices. Muchos codebases terminan con interfaces Repository cuyas implementaciones son esencialmente DAOs; la distinción importa más por la intención que por la mecánica.

¿Cada tabla debería tener su propio DAO?

Si manejas un puñado de entidades, sí, ya que los DAOs enfocados le ganan a una clase Dios. Cuando llegas a 20+ tablas haciendo el mismo CRUD, un BaseDao<T> genérico (o Repository<T> en Spring Data) elimina la repetición conservando la costura.

¿Cómo manejo transacciones entre varios DAOs?

Cada DAO es dueño de sus sentencias; el borde transaccional vive por encima. Dos caminos viables: pasar una conexión/contexto de transacción compartido a cada DAO, o subir el borde con Unit of Work, que es el patrón construido justo para "confirma todo esto o nada".

¿Cómo manejo las migraciones de esquema con DAOs?

Los DAOs dependen del esquema, así que deben versionarse juntos. Usa herramientas de migración (Flyway o Liquibase para Java, Alembic para Python, migraciones de Knex para Node), ejecuta las migraciones al desplegar, y prueba los DAOs contra el esquema migrado, no contra el de ayer.

¿Los DAOs deberían validar la entrada?

No. La validación pertenece a la capa de dominio: la entidad o el servicio que llama al DAO. Un DAO que re-valida duplica chequeos y diverge de ellos. Confía en la entrada válida en el borde; valida antes de la llamada, no dentro de ella.

¿Cómo pruebo DAOs sin una base de datos real?

Dos enfoques estándar: ejecutar el DAO real contra almacenamiento en memoria (SQLite para Python, H2 para Java, better-sqlite3 para Node) para probar tu SQL de verdad, o mockear la interfaz para unit tests puros del caller. Usa el primero para tests del DAO, el segundo para tests de servicios. Para integración, testcontainers levanta el motor real en Docker.