Tabla de Contenidos

05 - Capa persistencia

En una arquitectura limpia, los repositorios tienen la responsabilidad de gestionar el acceso a los datos desde la perspectiva del dominio. Esto significa que trabajan exclusivamente con modelos de dominio, abstrayendo por completo los detalles de cómo y dónde se almacenan los datos. Su objetivo principal es proporcionar una interfaz coherente que permita a la capa de dominio interactuar con los datos sin preocuparse por las particularidades de la persistencia.

Para lograr este aislamiento, los repositorios no deberían implementar directamente las operaciones específicas de almacenamiento o recuperación. Aquí es donde entra en juego la introducción de los DAOs, que encapsulan los detalles de persistencia. Por ejemplo, en un sistema que combine una base de datos y un sistema de caché, los repositorios podrían delegar en un DAO el acceso a la caché para recuperar datos rápidamente o almacenar temporalmente resultados, manteniendo así su enfoque en los modelos de dominio.

DAO (Data Access Object)

El patrón DAO (Data Access Object) se utiliza para separar y encapsular el acceso a los datos, proporcionando una capa de abstracción que aísla la lógica de acceso a datos del resto de la aplicación. Esto permite que el resto de la aplicación, y en particular la capa de dominio, interactúe con los datos sin preocuparse por los detalles específicos de persistencia. Los DAOs facilitan la delegación de las operaciones de acceso a los datos, de modo que los repositorios se puedan enfocar únicamente en trabajar con los modelos de dominio.

Por ejemplo, supongamos que queremos implementar en nuestra aplicación un sistema de caché para recuperar libros por su ISBN. En este caso, podemos hacer que el repositorio BookRepositoryImpl utilice dos DAOs: BookDaoDb, que maneja el acceso a la base de datos, y BookDaoCache, que gestiona el almacenamiento en caché. De esta manera, el método findByIsbn intenta primero recuperar el libro de la caché a través de BookDaoCache. Si no se encuentra en la caché, BookDaoDb realiza la consulta en la base de datos, y si el libro es encontrado, se guarda en la caché para futuras solicitudes.

Comenzaremos creando dos interfaces genéricas que servirán como base para nuestros DAOs: GenericDaoDb y GenericDaoCache. Estas interfaces definen los métodos básicos para interactuar con una base de datos y un sistema de caché, respectivamente.

Ambas interfaces las crearemos dentro del package persitence.dao.db y persitence.dao.cache respectivamente.

Clases Genéricas

En Java, las clases genéricas nos permiten escribir código flexible y reutilizable. Definir una clase o interfaz genérica significa que podemos trabajar con distintos tipos de datos sin necesidad de escribir código duplicado para cada tipo de entidad. En el caso de los DAOs, esto es útil porque muchas operaciones, como obtener todos los registros o buscar por ID, son comunes para todas las entidades.

Por ejemplo, consideremos el método getAll en la interfaz GenericDaoDb. Este método devuelve una lista de entidades, pero al ser genérico, no podemos saber, a priori, de qué tipo. En lugar de definir un método que solo funcione con, por ejemplo, libros (List<Book>), usamos un parámetro genérico (T) para que pueda adaptarse a cualquier tipo de entidad, ya sea Book, Author, Genre…:

public interface GenericDaoDb<T> {

    List<T> getAll();
    List<T> getAll(int page, int size);
    Optional<T> findById(long id);
    long insert(T t);
    void update(T t);
    void delete(long id);
    int count();
    T save(T t);

}

Además de los métodos propios de CRUD, hemos creado el método save para guardar una entidad y sus entidades relacionadas (como los libros con sus autores y géneros). Ésto nos será útil, además, cuando cambiemos a JPA, ya que los repositorios JPA (que utilizaremos como DAOs) también tienen ese método.

Al utilizar T, le estamos indicando al compilador que el tipo de la entidad que se va a utilizar será determinado cuando implementemos o instanciemos la interfaz. Esto hace que la interfaz sea flexible y reutilizable para cualquier entidad.

Cuando necesitamos operaciones específicas para ciertas entidades, podemos crear interfaces que extienden GenericDaoDb y agregar los métodos adicionales que correspondan a esas entidades. Por ejemplo, PublisherDaoDb puede extender GenericDaoDb<Publisher>, sin necesidad de agregar métodos adicionales si solo necesitamos las operaciones comunes.

public interface PublisherDaoDb extends GenericDaoDb<Publisher> {
}

Cuando extendemos GenericDaoDb, necesitamos indicar el tipo de entidad con el que trabajará la interfaz. Esto se hace especificando el tipo entre los corchetes angulares (<>) al momento de extender la interfaz genérica.

Sin embargo, si necesitamos funcionalidades específicas, como buscar por ISBN o manejar relaciones con autores y géneros en el caso de los libros, podemos agregar esos métodos a BookDaoDb:

public interface BookDaoDb extends GenericDaoDb<Book> {

    Optional<Book> findByIsbn(String isbn);
    void deleteAuthors(long id);
    void insertAuthors(long id, List<Author> authors);
    void deleteGenres(long id);
    void insertGenres(long id, List<Genre> genres);
}

De esta forma, podemos crear el resto de interfaces:

public interface AuthorDaoDb extends GenericDaoDb<Author> {

    List<Author> getByIsbnBook(String isbn);
    List<Author> getByIdBook(long idBook);
    List<Author> findAllById(Long[] ids);
}

public interface CategoryDaoDb extends GenericDaoDb<Category> {
}

public interface GenreDaoDb extends GenericDaoDb<Genre> {

    List<Genre> getByIdBook(long idBook);
    List<Genre> findAllById(Long[] ids);
    List<Genre> getByIsbnBook(String isbn);
}

Por último, creamos las implementaciones de las interfaces en persistence.dao.db.jdbc, por ejemplo:

@Component
@RequiredArgsConstructor
public class BookDaoJdbc implements BookDaoDb {
    private final JdbcTemplate jdbcTemplate;

    @Override
    public List<Book> getAll() {
        String sql = """
                        SELECT * FROM books
                     """;
        return jdbcTemplate.query(sql, new BookRowMapper());
    }

    @Override
    public List<Book> getAll(int page, int size) {
        String sql = """
                        SELECT * FROM books
                        LIMIT ? OFFSET ?
                     """;
        return jdbcTemplate.query(sql, new BookRowMapper(), size, page * size);
    }

    @Override
    public int count() {
        String sql = """
                        SELECT COUNT(*) FROM books
                     """;
        return jdbcTemplate.queryForObject(sql, Integer.class);
    }

    @Override
    public Optional<Book> findByIsbn(String isbn) {
        String sql = """
                SELECT * FROM books
                LEFT JOIN categories ON books.category_id = categories.id
                LEFT JOIN publishers ON books.publisher_id = publishers.id
                WHERE books.isbn = ?
           """;
        try {
            Book book = jdbcTemplate.queryForObject(sql, new BookRowMapper(), isbn);
            return Optional.of(book);
        } catch (EmptyResultDataAccessException e) {
            return Optional.empty();
        }
    }
...

Sistema de caché

Vamos ahora con el sistema de caché. En este caso, vamos a implementar un sistema muy básico (obviamente, no representaría una solución adecuada para un entorno de producción) como ejemplo. Para ello, crearemos, igual que hicimos con la base de datos, una interfaz con los métodos comunes, que en este caso serán guardar elementos en la caché y borrarla:

public interface GenericDaoCache<T> {
    void save(T t);
    void clearCache();
}

Por ahora, vamos a crear sólo BookDaoCache con un método para buscar libros por ISBN:

public interface BookDaoCache extends GenericDaoCache<Book>{

    Optional<Book> findByIsbn(String isbn);
}

La implementación será muy sencilla. Creamos dos Maps, uno para almacenar los libros en caché (cache) y otro para gestionar los tiempos de expiración (expiration). El primer método, findByIsbn(String isbn), busca un libro en el caché utilizando su ISBN. Compara el tiempo de expiración con el tiempo actual, y si el libro sigue siendo válido, lo devuelve desde el caché. Si ha expirado o no se encuentra, lo elimina de ambos mapas y devuelve un Optional.empty(). El método save(Book book) guarda un libro en el caché con su ISBN como clave y establece su tiempo de expiración sumando el TTL (10 minutos) al tiempo actual. Finalmente, el método clearCache() limpia ambos Maps, eliminando todos los libros y tiempos de expiración almacenados en el caché.

Utilizaremos ConcurrentHashMap en lugar de HashMap porque está diseñado para manejar múltiples hilos accediendo y modificando el caché al mismo tiempo. Esto es importante en un entorno concurrente, donde varios hilos pueden leer o escribir datos en el caché de forma simultánea:

@Component
public class BookDaoCacheImpl implements BookDaoCache {

    private final Map<String, Book> cache = new ConcurrentHashMap<>();
    private final Map<String, Long> expiration = new ConcurrentHashMap<>();
    private static final long TTL = 600_000L; // 10 minutos en milisegundos

    @Override
    public Optional<Book> findByIsbn(String isbn) {
        Long expirationTime = expiration.get(isbn);
        if(expirationTime != null && expirationTime >= System.currentTimeMillis()) {
            System.out.println("Retrieved from cache: " + isbn);
            return Optional.ofNullable(cache.get(isbn));
        }
        cache.remove(isbn);
        expiration.remove(isbn);
        return Optional.empty();
    }

    @Override
    public void save(Book book) {
        cache.put(book.getIsbn(), book);
        expiration.put(book.getIsbn(), System.currentTimeMillis() + TTL);
    }

    @Override
    public void clearCache() {
        cache.clear();
        expiration.clear();
    }
}

Una vez creados nuestros DAOs, podemos utilizarlos en el repositorio para delegar las responsabilidades de acceso a los datos. En el caso del método findByIsbn, primero intentamos obtener el libro del caché utilizando bookDaoCache.findByIsbn(isbn). Si no se encuentra, el repositorio se encarga de consultar la base de datos con bookDaoJdbc.findByIsbn(isbn). En cuanto obtenemos el libro desde la base de datos, lo guardamos en la caché a través de bookDaoCache.save(book) para optimizar futuras consultas:

Repository
@RequiredArgsConstructor
public class BookRepositoryImpl implements BookRepository {

    private final BookDaoDb bookDaoDb;
    private final BookDaoCache bookDaoCache;

    ...

    @Override
    public Optional<Book> findByIsbn(String isbn) {
        return bookDaoCache.findByIsbn(isbn).or(
                () -> {
                    System.out.println("Retrieved from db: " + isbn);
                    Optional<Book> book = bookDaoDb.findByIsbn(isbn);
                    book.ifPresent(bookDaoCache::save);
                    return book;
                }
        );
    }
    
    ...

Es importante destacar que este sistema de caché es muy rudimentario y se utiliza aquí con fines educativos para ilustrar cómo funciona el concepto de caché en un repositorio. En una aplicación real, sería más adecuado usar soluciones de caché más robustas y eficientes, como Redis o similares, que ofrecen características avanzadas como persistencia, manejo de expiración de datos y escalabilidad, entre otras.