En una arquitectura limpia, la capa de dominio es el corazón de la aplicación. Aquí es donde se encuentran las reglas de negocio y la lógica fundamental que define el comportamiento del sistema. Esta capa debe estar completamente aislada de cualquier detalle de implementación o infraestructura, como bases de datos, frameworks o herramientas de persistencia. Esto permite que los cambios en estos detalles no afecten la lógica central del negocio.
Para mantener la independencia del framework en la capa de dominio, es crucial evitar depender directamente de herramientas o bibliotecas como Spring, incluso cuando este facilita mucho la inyección de dependencias con anotaciones como @Service. Si deseamos respetar los principios de una arquitectura limpia, debemos asegurarnos de que la capa de dominio no esté acoplada a ninguna tecnología externa.
Para solucionar este problema de inyección de dependencias sin acoplarse a Spring, podemos explorar varias alternativas. La idea es mantener la independencia del dominio, pero sin perder la comodidad que ofrece un framework como Spring para gestionar las dependencias. Algunas opciones incluyen desde hacer la inyección de dependencias manualmente hasta crear un contenedor propio, o incluso usar una abstracción que nos permita cambiar de framework en el futuro sin modificar el código del dominio.
Una de las soluciones más simples es hacer la inyección manual. En este caso, crearíamos las instancias de las clases y pasaríamos las dependencias necesarias a través de los constructores. Esta opción asegura que el dominio no depende de ningún framework, pero tiene la desventaja de volverse engorrosa en aplicaciones grandes, ya que debemos gestionar manualmente todas las dependencias.
Otra opción es implementar nuestro propio contenedor de inyección de dependencias. Esto nos daría control total sobre cómo se crean y gestionan los objetos en nuestra aplicación. Sin embargo, crear un sistema de DI desde cero puede ser una tarea innecesariamente compleja, especialmente cuando ya existen frameworks que lo hacen eficientemente.
Una solución más práctica y directa es usar un alias o una abstracción sobre el framework. De este modo, evitamos que nuestras clases de dominio dependan directamente de las anotaciones de Spring, pero seguimos aprovechando las funcionalidades del framework. Esta es la opción que vamos a desarrollar, ya que ofrece flexibilidad sin añadir complejidad innecesaria.
En lugar de usar directamente @Service de Spring, crearemos nuestra propia anotación @DomainService en el package common/annotation, que será simplemente un alias. Esto significa que nuestras clases de dominio no estarán acopladas a Spring de forma directa. Si en el futuro cambiamos de framework, sólo tendremos que modificar la implementación de esta anotación sin tocar el resto del código.
@Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) @Service public @interface DomainService { }
En este caso, utilizamos dos anotaciones adicionales: @Target y @Retention. Estas son fundamentales para definir el comportamiento y el alcance de nuestra anotación personalizada.
Con esta anotación personalizada, nuestras clases de dominio seguirán recibiendo las ventajas de la inyección de dependencias de Spring, pero sin depender explícitamente de su anotación @Service.
¿Y si cambiamos de framework? Supongamos que, en lugar de Spring, decidimos cambiar a Micronaut, que utiliza la anotación @Singleton para gestionar los servicios. En este caso, simplemente modificamos la implementación de @DomainService para que funcione con Micronaut, manteniendo el mismo principio de abstracción:
@Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) @Singleton public @interface DomainService { }
Una vez creada nuestra anotación, podemos sustituir @Service por @DomainService en nuestros servicios:
@DomainService @RequiredArgsConstructor public class BookServiceImpl implements BookService {
En nuestro sistema, los repositorios se encuentran actualmente en la capa de persistencia. Sin embargo, esta configuración plantea un problema relacionado con las dependencias: existe una dependencia directa desde la capa de dominio hacia la capa de persistencia.
En una arquitectura bien estructurada, el flujo de dependencias debería ser inverso, es decir, la capa de persistencia debe depender de la capa de dominio, y no al revés.
Para abordar este problema, aplicamos el principio de inversión de dependencias. Esto implica mover las interfaces de los repositorios a la capa de dominio, permitiendo que la lógica de negocio dependa únicamente de estas interfaces. La implementación concreta de estas interfaces se mantendrá en la capa de persistencia. De esta manera, logramos desacoplar las capas y facilitar la adaptación a futuros cambios en la implementación de la persistencia sin afectar la lógica de dominio.
Vamos a implementar un par de métodos para insertar un listado de autores y géneros a un libro.
Comenzaremos con el controlador, que será responsable de recibir las peticiones HTTP con la información del libro y los autores/géneros a insertar:
public class BookAdminController { ... @PostMapping("/{id}/authors") public ResponseEntity<Void> insertAuthors(@PathVariable Integer id, @RequestBody List<Author> authors) { bookAdminService.insertAuthors(id, authors); return new ResponseEntity<>(HttpStatus.CREATED); } @PostMapping("/{id}/genres") public ResponseEntity<Void> insertGenres(@PathVariable Integer id, @RequestBody List<Genre> genres) { bookAdminService.insertGenres(id, genres); return new ResponseEntity<>(HttpStatus.CREATED); } ...
Ambos métodos reciben el id del libro por URL y en el cuerpo de la petición el listado correspondiente. Por ejemplo, al añadir autores, el formato del cuerpo de la petición será un JSON con un array de ids de autores.
[ { "id": 3 }, { "id": 5 } ]
La anotación @RequestBody se utiliza para indicar que el parámetro authors debe ser rellenado automáticamente con los datos del cuerpo de la solicitud HTTP. Cuando un cliente envía un JSON en el cuerpo de la solicitud, Spring convierte ese JSON en un objeto List<Author> gracias a la conversión automática (deserialización). Esto permite que el controlador reciba directamente un objeto de tipo List<Author>, facilitando así la manipulación de los datos que se han recibido.
En este caso, hemos optado por compartir el modelo de la capa de dominio Author por comodidad, ya que esto simplifica el proceso al permitir un mapeo automático de los datos entre las capas. Es importante destacar que, en una aplicación, podemos adoptar diferentes estrategias de transporte de datos entre capas según el caso de uso. Esta flexibilidad nos permite decidir la mejor manera de estructurar la comunicación entre la presentación y la lógica de negocio en función de las necesidades específicas de cada funcionalidad.
El siguiente paso es crear los métodos en el servicio. Ambos métodos serán muy parecidos. Por ejemplo, para añadir los autores comprobaremos que el libro exista (si no, lanzaremos una excepción), recuperaremos y comprobaremos los autores que tenemos que insertar, añadiremos los autores nuevos al libro y guardaremos los cambios:
@Override public void insertAuthors(int idBook, List<Author> authors) { //recuperar libro Book book = bookAdminRepository.findById(idBook).orElseThrow(() -> new ResourceNotFoundException("Book " + idBook + " not found")); //recuperar autores a insertar List<Author> authorList = authorAdminRepository.findAllById( authors .stream() .map(Author::getId) .toArray(Long[]::new) ); //comprobar que todos los autores pasados existen if(authorList.size() != authors.size()) { throw new ResourceNotFoundException("Some authors were not found"); } //añadir autores al libro authorList.forEach(book::addAuthor); //guardar book bookAdminRepository.save(book); }
el método findAllById del repositorio recibirá un array de Long con los ids buscados y devolverá el listado de autores. A continuación, comprobamos que el número de elementos encontrados es el mismo que el que nos han pasado. En caso contrario, lanzaremos una excepción indicando que algunos autores no se han encontrado.
El método del módelo Book que añade un autor a un libro será bastante sencillo. Primero comprobaremos que existe algún autor, si no, crearemos un ArrayList vacío. Comprobaremos que el recurso que queremos añadir no existe ya en el listado. Si ya existe, lanzaremos una excepción ResourceAlreadyExistsException que habremos creado previamente. Por último, si todo ha funcionado bien, añadiremos el autor al libro:
public void addAuthor(Author author) { if (authors == null) { authors = new ArrayList<>(); } if (authors.contains(author)) { throw new ResourceAlreadyExistsException("Author " + author.getName() + "already exists"); } authors.add(author); }
@ResponseStatus(HttpStatus.BAD_REQUEST) @ExceptionHandler({ ResourceAlreadyExistsException.class }) @ResponseBody public ErrorMessage resourceAlreadyExists(ResourceAlreadyExistsException exception) { log.error(exception.getMessage()); return new ErrorMessage(exception); }
En el repositorio de libros, lo primero será crear el método findById. En este caso, hemos optado por trabajar con id en lugar de isbn para facilitar más adelante las sentencias SQL:
@Override public Optional<Book> findById(int id) { 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.id = ? """; try { Book book = jdbcTemplate.queryForObject(sql, new BookRowMapper(), id); book.setAuthors(authorAdminRepository.getByIdBook(id)); book.setGenres(genreAdminRepository.getByIdBook(id)); return Optional.of(book); } catch (Exception e) { return Optional.empty(); } }
El siguiente método que tenemos que implementar es findAllById, en el repositorio de autores. Éste método recibirá un array de ids de autores y devolverá un listado con los autores encontrados:
@Override public List<Author> findAllById(Long[] ids) { String sql = """ SELECT authors.* FROM authors WHERE id IN (:ids) """; Map<String, List<Long>> params = Map.of("ids", Arrays.asList(ids)); return namedParameterJdbcTemplate.query(sql, params, new AuthorRowMapper()); }
Fíjate que en este caso utilizamos la clase NamedParameterJdbcTemplate, la cuál nos permite pasar los parámetros con :parameter_name en lugar de con ?. De esta forma, no hace falta construir la cadena de parámetros, simplificando el proceso.
@Repository @RequiredArgsConstructor public class AuthorAdminRepositoryImpl implements AuthorAdminRepository { private final JdbcTemplate jdbcTemplate; private final NamedParameterJdbcTemplate namedParameterJdbcTemplate;
Por último, nos queda crear el método save. Este método se encargará de insertar o actualizar un libro (incluyendo editorial, categoría, géneros y autores):
@Override public void save(Book book) { //Si el id existe, actualizar, si no, instalar if(book.getId() != null) { update(book); } else { long id = insert(book); book.setId(id); } this.deleteAuthors(book.getId()); this.insertAuthors(book.getId(), book.getAuthors()); this.deleteGenres(book.getId()); this.insertGenres(book.getId(), book.getGenres()); }
¿Por qué crear este método en lugar de crear cada método individualmente? En realidad, por ahora este método es poco eficiente. Si queremos, como en nuestro caso, añadir una serie de autores, siempre actualizaremos los datos del libro (aunque no haya cambiado nada), borraremos y volveremos a insertar los autores y lo mismo con los géneros.
En nuestro caso, lo utilizamos por simplicidad, aunque si quisiéramos hacerlo bien, deberíamos crear algún gestor de entidades y comprobar los cambios para actualizar sólo lo necesario. Por ejemplo, sprig-jpa (basado en Hibernate) utiliza EntityManager como gestor de entidades y dirty checking como mecanismo para marcar los cambios de éstas. La idea básica es que cuando se crea por primera vez las entidades, jpa pasa a gestionarlas y, si hacemos cualquier cambio, a la hora de guardarla sólo ejecutará las acciones correspondientes.
De todas formas, como hemos dicho antes, en nuestro caso simplificaremos el proceso (cuando usemos jpa ya se encargará él de gestionarlo). Si el modelo pasado tiene id, quiere decir que es una actualización. Si no tiene id, significa que es una nueva entidad y debe insertarla en la bbdd.
En cuanto a los autores y géneros, también simplificamos el proceso borrándolos y volviéndolos a insertar.
Sólo nos queda construir los métodos para insertar, actualizar, borrar e insertar autores y géneros:
private void update(Book book) { String sql = """ UPDATE books SET isbn = ?, title_es = ?, title_en = ?, synopsis_es = ?, synopsis_en = ?, price = ?, discount = ?, cover = ?, publisher_id = ?, category_id = ? WHERE id = ? """; jdbcTemplate.update( sql, book.getIsbn(), book.getTitleEs(), book.getTitleEn(), book.getSynopsisEs(), book.getSynopsisEn(), book.getPrice(), book.getDiscount(), book.getCover(), book.getPublisher().getId(), book.getCategory().getId(), book.getId() ); } private long insert(Book book) { String sql = """ INSERT INTO books( isbn, title_es, title_en, synopsis_es, synopsis_en, price, discount, cover, publisher_id, category_id) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """; KeyHolder keyHolder = new GeneratedKeyHolder(); jdbcTemplate.update(connection -> { PreparedStatement ps = connection.prepareStatement(sql, new String[] {"id"}); ps.setString(1, book.getIsbn()); ps.setString(2, book.getTitleEs()); ps.setString(3, book.getTitleEn()); ps.setString(4, book.getSynopsisEs()); ps.setString(5, book.getSynopsisEn()); ps.setBigDecimal(6, book.getPrice()); ps.setFloat(7, book.getDiscount()); ps.setString(8, book.getCover()); ps.setLong(9, book.getPublisher().getId()); ps.setLong(10, book.getCategory().getId()); return ps; }, keyHolder); return keyHolder.getKey().longValue(); // Devuelve el id generado } private void deleteAuthors(long id) { String sql = """ DELETE FROM books_authors WHERE book_id = ? """; jdbcTemplate.update(sql, id); } private void insertAuthors(long id, List<Author> authors) { String sql = """ INSERT INTO books_authors(book_id, author_id) VALUES (?, ?) """; authors.stream().forEach(a -> jdbcTemplate.update(sql, id, a.getId())); } private void deleteGenres(long id) { String sql = """ DELETE FROM books_genres WHERE book_id = ? """; jdbcTemplate.update(sql, id); } private void insertGenres(long id, List<Genre> genres) { String sql = """ INSERT INTO books_genres(book_id, genre_id) VALUES(?, ?) """; genres.stream().forEach(g -> jdbcTemplate.update(sql, id, g.getId())); }
Vamos ahora a implementar la inserción de un libro. Creamos el método en el controlador:
@PostMapping public ResponseEntity<Void> insert(@RequestBody Book book) { bookAdminService.insert(book); return new ResponseEntity<>(HttpStatus.CREATED); }
El JSON que recibimos será algo parecido a ésto:
{ "isbn": "12345", "titleEs": "Nuevo libro", "titleEn": "New book", "synopsisEs": "Sinopsis nuevo libro", "synopsisEn": "Synopsis new book", "price": 12.30, "discount": 0.5, "cover": "nuevo_libro.jpg", "publisher": { "id": 2 }, "category": { "id": 4 }, "genres": [ { "id": 5 }, { "id": 6 } ], "authors": [ { "id": 8 } ] }
De esta forma, el mapeador de Spring será capaz de convertirlo a un objeto del modelo de nuestro dominio.
En nuestro servicio, el método insert tendría que hacer varias acciones:
@Override public void insert(Book book) { if(bookAdminRepository.findByIsbn(book.getIsbn()).isPresent()) { throw new ResourceAlreadyExistsException("Isbn " + book.getIsbn() + " already exists"); } //comprobar el publisher if(publisherAdminRepository.findById(book.getPublisher().getId()).isEmpty()) { throw new ResourceNotFoundException("Publisher " + book.getPublisher().getId() + " not found"); } //comprobar la categoría if(categoryAdminRepository.findById(book.getCategory().getId()).isEmpty()) { throw new ResourceNotFoundException("Category " + book.getCategory().getId() + " not found"); } //recuperar autores a insertar List<Author> authorList = authorAdminRepository.findAllById( book.getAuthors() .stream() .map(Author::getId) .toArray(Long[]::new) ); //comprobar que todos los autores pasados existen if(authorList.size() != book.getAuthors().size()) { throw new ResourceNotFoundException("Some authors were not found"); } //añadir autores al libro book.setAuthors(new ArrayList<>()); authorList.forEach(book::addAuthor); //recuperar géneros a insertar List<Genre> genreList = genreAdminRepository.findAllById( book.getGenres() .stream() .map(Genre::getId) .toArray(Long[]::new) ); //comprobar que todos los géneros pasados existen if(genreList.size() != book.getGenres().size()) { throw new ResourceNotFoundException("Some genres were not found"); } //añadir géneros al libro book.setGenres(new ArrayList<>()); genreList.forEach(book::addGenre); bookAdminRepository.save(book); }
Si te das cuenta, hay mucho código compartido con los métodos insertAuthors y insertGenres. Podríamos refactorizar los métodos para agrupar ese código común, pero, en nuestro caso, vamos a adoptar otra solución que nos ayudará más adelante con las validaciones: crear casos de uso.
La idea es que los servicios hagan operaciones básicas sobre los recursos y los casos de uso sean los encargados de llamar a diferentes servicios para realizar las acciones correspondientes. Crearemos los diferentes casos de uso con un sólo método público execute(), que será el que llamará el controlador.
De esta manera, las acciones que se realizan en los servicios serán compartidas por todos los casos de uso que llamen a éste. Por ejemplo, nuestro caso de uso BookInsertAdminUseCaseImpl será:
@DomainUseCase @RequiredArgsConstructor public class BookInsertAdminUseCaseImpl implements BookInsertAdminUseCase { private final BookAdminService bookAdminService; private final AuthorAdminService authorAdminService; private final GenreAdminService genreAdminService; private final PublisherAdminService publisherAdminService; private final CategoryAdminService categoryAdminService; @Override public void execute(Book book) { if(bookAdminService.findByIsbn(book.getIsbn()).isPresent()) { throw new ResourceAlreadyExistsException("Book with ISBN " + book.getIsbn() + " already exists"); } book.setPublisher(publisherAdminService .findById(book.getPublisher().getId()) .orElseThrow(() -> new ResourceNotFoundException("Publisher " + book.getPublisher().getName() + " not found"))); book.setCategory(categoryAdminService .findById(book.getCategory().getId()) .orElseThrow(() -> new ResourceNotFoundException("Category " + book.getCategory().getId() + " not found"))); book.setAuthors(authorAdminService .findAllById(book.getAuthors())); book.setGenres(genreAdminService .findAllById(book.getGenres())); bookAdminService.save(book); } }
Y el servicio de autor, por ejemplo, quedaría:
@DomainService @RequiredArgsConstructor public class AuthorAdminServiceImpl implements AuthorAdminService { private final AuthorAdminRepository authorAdminRepository; @Override public List<Author> getByIdBook(long idBook) { return authorAdminRepository.getByIdBook(idBook); } @Override public List<Author> findAllById(List<Author> authors) { List<Author> foundAuthors = authorAdminRepository.findAllById( authors .stream() .map(Author::getId) .toArray(Long[]::new) ); if(foundAuthors.size() != authors.size()) { throw new ResourceNotFoundException("Some authors were not found"); } return foundAuthors; } }
De esta forma, el caso de uso para insertar autores a un libro llamará al mismo método del servicio, separando todavía más las responsabilidades de cada capa:
@DomainUseCase @RequiredArgsConstructor public class BookInsertAuthorsAdminUseCaseImpl implements BookInsertAuthorsAdminUseCase { private final BookAdminService bookAdminService; private final AuthorAdminService authorAdminService; @Override public void execute(int id, List<Author> authors) { Book book = bookAdminService.findById(id).orElseThrow(() -> new ResourceNotFoundException("Book " + id + " not found")); authorAdminService .findAllById(authors) .forEach(author -> bookAdminService.addAuthor(book, author)); bookAdminService.save(book); } }
Otra ventaja es que podemos llevar las validaciones de datos a los servicios. Aunque entraremos con más detalle cuando veamos validaciones, la idea es que sea el servicio el que valide los datos, en lugar del modelo, así, si añadimos validaciones nuevas cuando nuestra aplicación ya está en producción, a la hora de leer los datos no será necesario hacer esas comprobaciones, ya que podrían haber datos anteriores en la bbdd que no cumplan esa condición:
@DomainService @RequiredArgsConstructor public class BookAdminServiceImpl implements BookAdminService { private final BookAdminRepository bookAdminRepository; @Override public List<Book> getAll() { return bookAdminRepository.getAll(); } @Override public List<Book> getAll(int page, int size) { return bookAdminRepository.getAll(page, size); } @Override public int count() { return bookAdminRepository.count(); } @Override public Optional<Book> findByIsbn(String isbn) { return bookAdminRepository.findByIsbn(isbn); } @Override public Optional<Book> findById(long id) { return bookAdminRepository.findById(id); } @Override public void save(Book book) { bookAdminRepository.save(book); } @Override public void addAuthor(Book book, Author author) { if (book.getAuthors() == null) { book.setAuthors(new ArrayList<>()); } if (book.getAuthors().contains(author)) { throw new ResourceAlreadyExistsException("Author " + author.getName() + "already exists"); } book.addAuthor(author); } @Override public void addGenre(Book book, Genre genre) { if (book.getGenres() == null) { book.setGenres(new ArrayList<>()); } if (book.getGenres().contains(genre)) { throw new ResourceAlreadyExistsException("Genre " + genre.getId() + "already exists"); } book.addGenre(genre); } }
Hasta ahora, estamos dividiendo controladores, casos de uso, servicios y repositorios en admin y user. En este caso, cada rol tiene su propio modelo de datos, y el mapeo lo hacemos en el repositorio.
Este enfoque tiene la ventaja de tener todo separado, con lo que podemos realizar acciones diferentes según el rol del usuario. Por ejemplo, podríamos hacer que el método getAll() del servicio de libros del administrador fuera diferente del de usuario.
El problema es que nuestro código aumenta (con la complejidad asociada). Además, si la mayoría de servicios van a ser iguales, no vale la pena esta estrategia (piensa que tenemos un método Count() para admin y otro para user que hacen exactamente lo mismo).
Podríamos simplificar el proceso haciendo que los servicios y repositorios fueran comunes, y sean los casos de uso los que transformen los datos al modelo correspondiente (admin o user), con lo que reduciríamos significativamente el número de clases.
Incluso podríamos tener un modelo de datos común y crear los modelos necesarios según los roles. Es decir, en nuestra capa de dominio tendríamos, por ejemplo, el modelo Book:
@Data @AllArgsConstructor @NoArgsConstructor public class Book { private Long id; private String isbn; private String titleEs; private String titleEn; private String synopsisEs; private String synopsisEn; private BigDecimal price; private float discount; private String cover; private Publisher publisher; private Category category; private List<Genre> genres; private List<Author> authors; public String getTitle() { String language = LanguageUtils.getCurrentLanguage(); if ("en".equals(language)) { return titleEn; } return titleEs; } public String getSynopsis() { String language = LanguageUtils.getCurrentLanguage(); if ("en".equals(language)) { return synopsisEn; } return synopsisEs; } public void addAuthor(Author author) { authors.add(author); } public void addGenre(Genre genre) { genres.add(genre); } }
Y crearíamos otro modelo de datos de libro exclusivo para los casos de uso del usuario:
public BookUser(String isbn, String title, String synopsis, BigDecimal price, float discount, String cover, PublisherUser publisher, CategoryUser category, List<AuthorUser> authors, List<GenreUser> genres) { this.isbn = isbn; this.title = title; this.synopsis = synopsis; setPrice(price); this.discount = discount; this.cover = cover; this.publisher = publisher; this.category = category; this.authors = authors; this.genres = genres; } public void setPrice(BigDecimal price) { if (price == null) { price = new BigDecimal(0); } this.price = price.setScale(2, RoundingMode.HALF_UP); } }
Obviamente, esto nos obligaría a crear mapeadores en los casos de uso de user, para transformar lo que nos devuelve el servicio (modelo Book general) al modelo de user /BookUser):
@Mapper(uses = {PublisherMapper.class, GenreMapper.class, AuthorMapper.class, CategoryMapper.class}) public interface BookMapper { BookMapper INSTANCE = Mappers.getMapper(BookMapper.class); BookUser toBookUser(Book book); }
De esta forma, podríamos tener separados los casos de uso en los de admin, user y los comunes, los cuales llamarían a los servicios correspondientes (que serían los mismos para todos los casos de uso). Por ejemplo, tendríamos el caso de uso común BookCountUseCase:
@DomainUseCase @DomainTransactional @RequiredArgsConstructor public class BookCountUseCaseImpl implements BookCountUseCase { private final BookService bookService; @Override public int execute() { return bookService.count(); } }
Así, el caso de uso findByIsbn del admin sería:
@DomainUseCase @RequiredArgsConstructor public class BookFindByIsbnAdminUseCaseImpl implements BookFindByIsbnAdminUseCase { private final BookService bookService; @Override public Book execute(String isbn) { return bookService .findByIsbn(isbn) .orElseThrow(() -> new ResourceNotFoundException("Book isbn " + isbn + " not found")); } }
Mientras que el de usuario:
@DomainUseCase @RequiredArgsConstructor public class BookFindByIsbnUserUseCaseImpl implements BookFindByIsbnUserUseCase { private final BookService bookService; @Override public BookUser execute(String isbn) { return BookMapper.INSTANCE.toBookUser( bookService .findByIsbn(isbn) .orElseThrow(() -> new ResourceNotFoundException("Book isbn " + isbn + " not found")) );
Volvamos al método save() de nuestro repositorio de libros
@Override public void save(Book book) { //Si el id existe, actualizar, si no, instalar if(book.getId() != null) { update(book); } else { long id = insert(book); book.setId(id); } this.deleteAuthors(book.getId()); this.insertAuthors(book.getId(), book.getAuthors()); this.deleteGenres(book.getId()); this.insertGenres(book.getId(), book.getGenres()); }
En este caso, estamos insertando datos en diferentes tablas ¿Qué pasa si falla la inserción en la tabla books_authos? La bbdd se quedaría con datos incorrectos, ya que habríamos insertado un libro (consulta que no ha fallado) sin autores ni géneros. Para asegurarnos que las operaciones se hacen todas (o ninguna, si hay algún fallo), podemos recurrir a las transacciones.
Por suerte, Spring nos ofrece la anotación Transactional, que nos automatiza el proceso. Como en el caso de servicios y casos de uso, vamos a crear nuestra propia etiqueta que será un alias de esa anotación:
@Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) @Transactional public @interface DomainTransactional { }
De esta forma, podremos usar esa nueva etiqueta en nuestra capa de dominio sin añadir una dependencia a Spring.
La siguiente pregunta es ¿Dónde utilizamos las transacciones? Podemos hacerlo en los repositorios, servicios, casos de uso o controladores. Si lo hacemos en el repositorio, aseguramos que esa operación concreta se haga completamente (por ejemplo, la inserción de un libro con sus autores y géneros), pero ¿qué pasa si existe un caso de uso donde se añade un libro y alguna otra operación que implique la modificación de otra tabla? Obviamente, en ese caso añadiría el libro, pero podría fallar la siguiente operación, con lo que tendríamos el mismo problema. Pasa lo mismo en el caso de los servicios.
Otra opción sería en los controladores, aunque el problema sería que estaríamos modificando la capa de persistencia por algo que no le corresponde. La mejor opción, en este caso, sería utilizar la nueva etiqueta en los casos de uso:
@DomainUseCase @DomainTransactional @RequiredArgsConstructor public class BookInsertUseCaseImpl implements BookInsertUseCase {
De esta forma, nos aseguramos que todas las acciones del caso de uso se realicen o, si falla alguna, no se realice ninguna.