====== 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), usamos un parámetro genérico (T) para que pueda adaptarse a cualquier tipo de entidad, ya sea Book, Author, Genre...: public interface GenericDaoDb { List getAll(); List getAll(int page, int size); Optional 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//, sin necesidad de agregar métodos adicionales si solo necesitamos las operaciones comunes. public interface PublisherDaoDb extends GenericDaoDb { } 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 { Optional findByIsbn(String isbn); void deleteAuthors(long id); void insertAuthors(long id, List authors); void deleteGenres(long id); void insertGenres(long id, List genres); } De esta forma, podemos crear el resto de interfaces: public interface AuthorDaoDb extends GenericDaoDb { List getByIsbnBook(String isbn); List getByIdBook(long idBook); List findAllById(Long[] ids); } public interface CategoryDaoDb extends GenericDaoDb { } public interface GenreDaoDb extends GenericDaoDb { List getByIdBook(long idBook); List findAllById(Long[] ids); List 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 getAll() { String sql = """ SELECT * FROM books """; return jdbcTemplate.query(sql, new BookRowMapper()); } @Override public List 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 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 { 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{ Optional 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 [[https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/concurrent/ConcurrentHashMap.html//example.com|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 cache = new ConcurrentHashMap<>(); private final Map expiration = new ConcurrentHashMap<>(); private static final long TTL = 600_000L; // 10 minutos en milisegundos @Override public Optional 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 findByIsbn(String isbn) { return bookDaoCache.findByIsbn(isbn).or( () -> { System.out.println("Retrieved from db: " + isbn); Optional 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 [[https://redis.io/es/|Redis]] o similares, que ofrecen características avanzadas como persistencia, manejo de expiración de datos y escalabilidad, entre otras.