Herramientas de usuario

Herramientas del sitio


clase:daw:arquitectura:domain

04 - Capa dominio

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.

Inyección de dependencias en la capa de dominio

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.

  • @Target(ElementType.TYPE): Esta anotación se utiliza para especificar el tipo de elemento al que puede aplicarse nuestra anotación. En este caso, hemos definido ElementType.TYPE, lo que significa que @DomainService puede ser utilizada en clases. Esto es apropiado ya que queremos que esta anotación se aplique a las clases que implementan la lógica de negocio, como nuestros servicios de dominio.
  • @Retention(RetentionPolicy.RUNTIME): Esta anotación define cuánto tiempo se debe conservar la anotación. Al establecer RetentionPolicy.RUNTIME, indicamos que la anotación estará disponible en tiempo de ejecución. Esto permite que frameworks como Spring puedan detectar y procesar la anotación cuando se inicializa la aplicación, lo que es esencial para la inyección de dependencias. Sin esta configuración, la anotación podría no estar accesible en tiempo de ejecución, impidiendo que funcione como se espera.

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 {

Inversión de dependencias

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.

controllerdomainpersistenceBookAdminControllergetAll()BookAdminServicegetAll()BookAdminServiceImplgetAll()BookAdminRepositorygetAll()BookAdminRepositoryJdbcgetAll()

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.

controllerdomainpersistenceBookAdminControllergetAll()BookAdminServicegetAll()BookAdminServiceImplgetAll()BookAdminRepositorygetAll()BookAdminRepositoryJdbcgetAll()

Inserción de datos

Vamos a implementar un par de métodos para insertar un listado de autores y géneros a un libro.

Recepción y mapeo de datos

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);
    }

    ...

En este caso, el cuerpo de nuestra respuesta está vacío. Si todo ha funcionado bien, devolveremos el código 201 (HttpStatus.CREATED), indicando que se ha creado el recurso. Podríamos devolver el libro con los autores nuevos incluidos, para que el cliente tenga el objeto completo.

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
    }
]

Podríamos añadir más información de cada autor, aunque en realidad, lo único que nos interesa es su id.

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.

Servicio y modelo

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.

En este punto, podríamos afinar más y comprobar cuál de los autores no han sido encontrado, para devolver más información al cliente. En nuestro caso, hemos optado por una solución más sencilla indicando sólamente que algunos autores no se han encontrado, aunque no dando información sobre quienes son.

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);
    }

Acuérdate de añadir el tratamiento de esa nueva excepción a nuestro controlador de excepciones:

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler({
            ResourceAlreadyExistsException.class
    })
    @ResponseBody
    public ErrorMessage resourceAlreadyExists(ResourceAlreadyExistsException exception) {
        log.error(exception.getMessage());
        return new ErrorMessage(exception);
    }

Repositorio

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.

Acuérdate de definir el atributo al principio de la clase para que Spring sea capaz de inyectarlo:

@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()));
    }

Podríamos haber creado sólo un método para insertar o actulizar usando INSERT...ON DUPLICATE KEY UPDATE. La mayoría de SGBD tienen mecanismos parecidos.

Casos de uso

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.

controllerdomainpersistenceBookAdminControllerinsert()BookInsertAdminUseCaseexecute()BookInsertAdminUseCaseImplexecute()BookAdminServicefindByIsbn()save()BookAdminServiceImplfindByIsbn()save()PublisherAdminServicefindById()PublisherAdminServiceImplfindById()CategoryAdminServicefindById()CategoryAdminServiceImplfindById()AuthorAdminServicefindAllById()AuthorAdminServiceImplfindAllById()GenreAdminServicefindAllById()GenreAdminServiceImplfindAllById()BookAdminRepositoryfindByIsbn()save()PublisherAdminRepositoryfindById()CategoryAdminRepositoryfindById()AuthorAdminRepositoryfindAllById()GenreAdminRepositoryfindAllById()BookAdminRepositoryJdbcfindByIsbn()save()PublisherAdminRepositoryJdbcfindById()CategoryAdminRepositoryJdbcfindById()AuthorAdminRepositoryJdbcfindAllById()GenreAdminRepositoryJdbcfindAllById()

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);
    }
}

Date cuenta que hemos utilizado otra etiqueta personalizada @DomainUseCase, la cual debemos crear de la misma forma que DomainService (podemos utilizar @Component en la definición de la etiqueta en lugar de @Service)

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);
    }

}

Mapeo de datos

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"))
        );
   

En este caso, estamos haciendo el mapeo de datos en los casos de uso, con lo agrupamos los servicios y repositorios. Tenemos todavía otras estrategias que podríamos utilizar. Por ejemplo, podríamos hacer que los casos de uso también fueran comunes (utilizaran el mismo modelo todos) y hacer el mapeo en la capa de controladores.
Como ves, cuando diseñamos la arquitectura de una aplicación hay diferentes estrategias con sus ventajas y desventajas. Dependiendo de la complejidad y el control que quieras sobre los diferentes componentes de tu aplicación, usarás la que más te convenga. En nuestro ejemplo, con un CRUD básico sobre la mayoría de tablas, igual no vale la pena aumentar la complejidad de la programación, aunque puede que en otra aplicación más compleja nos ofrezca más ventajas que desventajas.

Transacciones

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.

clase/daw/arquitectura/domain.txt · Última modificación: 2024/10/28 10:03 por cesguiro