====== 06 - Capa de 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. Para nuestra biblioteca online, vamos a crear un proyecto Maven que será nuestra capa de dominio. Las únicas dependencias que tendremos serán las de JUnit y Mockito para los tests: 4.0.0 es.cesguiro daw2-bookstore-domain 1.0-SNAPSHOT jar ${project.groupId}.${project.artifactId} Demo project for domain layer https://github.com/cesguiro/${project.artifactId} MIT License http://www.opensource.org/licenses/mit-license.php César Guijarro cesguiro cesguirol@gmail.com cesguiro Architect Developer +1 https://cesguiro.es 21 21 UTF-8 5.10.3 5.12.0 3.5.2 org.junit.jupiter junit-jupiter ${org.junit.version} test org.mockito mockito-junit-jupiter ${org.mockito.version} test org.apache.maven.plugins maven-surefire-plugin ${org.apache.maven.plugins.version} ===== Inyección de dependencias en la capa de dominio ===== Para mantener la independencia del framework en la capa de dominio, es importante 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. La mayoría de frameworks (como Spring) tiene mecanismos para configurar y utilizar DI sin necesidad de añadir elementos externos en la capa de dominio. Cuando hagamos la integración de las capas, veremos como podemos manejar ésto con Spring. Como hemos dicho, en esta capa (en teoría) no debería haber ningún //import// en ningún componente que no forme parte de la biblioteca básica de Java. En cualquier caso, existen librerías de terceros (como Lombock) que están muy extendidas y muchas veces se utilizan en esta capa. De ti depende decidir si vale la pena o no, teniendo siempre en cuenta que tú no tienes control sobre esas librerías. ===== Inversión de dependencias ===== En el tema anterior vimos las relaciones entre capas. Al final, la capa más importante era la de persistencia, ya que todas dependían de ella. Si hiciésemos algún cambio en esa capa, el resto es probable que se viera afectado. Por ejemplo, un escenario típico sería: @startuml package "controller" #A5D6A7{ class BookAdminController { +getAll() } } package "domain" #EF5350{ interface BookAdminService { {abstract} +getAll() } class BookAdminServiceImpl { +getAll() } } package "persistence" #90CAF9{ interface BookAdminRepository { {abstract} +getAll() } class BookAdminRepositoryJdbc { +getAll() } } BookAdminController -down-> BookAdminService BookAdminServiceImpl .up.|> BookAdminService BookAdminServiceImpl -[thickness=3]down-> BookAdminRepository BookAdminRepositoryJdbc .up.|> BookAdminRepository @enduml 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. @startuml package "controller" #A5D6A7{ class BookAdminController { +getAll() } } package "domain" #EF5350{ interface BookAdminService { {abstract} +getAll() } class BookAdminServiceImpl { +getAll() } interface BookAdminRepository { {abstract} +getAll() } } package "persistence" #90CAF9{ class BookAdminRepositoryJdbc { +getAll() } } BookAdminController -down-> BookAdminService BookAdminServiceImpl .up.|> BookAdminService BookAdminServiceImpl -down-> BookAdminRepository BookAdminRepositoryJdbc .[thickness=3]up.|> BookAdminRepository @enduml De esta forma, nuestra capa de dominio estará formada (por ahora) por los packages: @startuml package "domain" #EF5350{ package "service" { } package "model" { } package "repository" { } } @enduml ===== Modelos ====== Una de las primeras cosas que debemos hacer al crear una aplicación es definir los modelos. Por ejemplo, en nuestra biblioteca online podríamos definir los siguientes modelos: public class Book { private String isbn; private String titleEs; private String titleEn; private String synopsisEs; private String synopsisEn; private BigDecimal basePrice; private double discountPercentage; private BigDecimal price; private String cover; private LocalDate publicationDate; private Publisher publisher; private List authors; // Constructores, getters, setters public class Author { private String name; private String nationality; private String biographyEs; private String biographyEn; private int birthYear; private Integer deathYear; private String slug; // Constructores, getters, setters public class Publisher { private String name; private String slug; // Constructores, getters, setters Aquí hay varios detalles a tener en cuenta: * Fíjate que nuestros modelos no tienen //id//, aunque en la bbdd sí que los tienen. En nuestro caso, los libros se buscarán por isbn y los autores y editoriales por el campo slug (que será el nombre formateado para que sean legibles en la url). * Nuestros modelos (además del id), pueden tener campos que no existan en la bbdd, como **price** en //Book//, que será el precio final aplicando el descuento. ==== Transporte de datos entre capas ==== Otro punto importante es cómo transferimos información entre capas. Aquí surgen diferentes posibilidades, aunque podemos resumirlas en dos: usar modelos de negocio anémicos o ricos (o enriquecidos). === Modelos anémicos === Son objetos de dominio que no tienen lógica de negocio, sólo getters y setters. Están considerando un antipatrón por muchos autores.
El horror fundamental de este antipatrón es que contradice por completo la idea básica del diseño orientado a objetos, que consiste en combinar datos y procesos. El modelo de dominio anémico es en realidad un diseño de estilo procedimental, justo el tipo de cosa contra la que los fanáticos de los objetos como Eric y yo hemos luchado desde nuestros inicios en Smalltalk. Lo que es peor, mucha gente cree que los objetos anémicos son objetos reales y, por lo tanto, no comprenden en absoluto la esencia del diseño orientado a objetos. [[https://martinfowler.com/bliki/AnemicDomainModel.html|Martin Fowler]]
Con este tipo de modelos, metemos la lógica de negocio en los servicios. Incluso podemos usar casos de uso individuales, que haría uso de estos servicios: @startuml package "domain" #EF5350{ package "usecase" { interface FindAllBooksUseCase { {abstract} execute() } interface FindBookByIsbn { {abstract} execute(String isbn) } package "impl" { class FindAllBooksUseCaseImpl { execute() } class FindBookByIsbnImpl { execute(String isbn) } } } package "service" { interface BookService { {abstract} findAll(int page, int size) {abstract} findByIsbn(String isbn) } package "impl" { class BookServiceImpl { findAll(int page, int size) findByIsbn(String isbn) } } } package "model" { class Book } } FindAllBooksUseCaseImpl .up.|> FindAllBooksUseCase FindBookByIsbnImpl .up.|> FindBookByIsbn BookServiceImpl .up.|> BookService FindAllBooksUseCaseImpl -down--> BookService FindBookByIsbnImpl -down--> BookService BookServiceImpl -> Book @enduml La ventaja de este patrón es que compartimos el modelo de datos entre las capas, con lo que no tendremos que hacer mapeos entre ellas. Además, si queremos modificar el modelo, sólo debemos hacerlo en una clase. Entre las desventajas, además de las señaladas por Martin Fowler y demás autores, está que, al compartir el mismo modelo entre capas, no podemos tener atributos diferentes entre ellas. Estos modelos se convierten en simples DTOs. Un **Data Transfer Object** es un objeto plano (POJO) con atributos, getters, setters y ninguna lógica de negocio. Se usan para transportar datos entre capas. Además, deben ser serializables, ya que también se utilizan, por ejemplo, para enviar datos del servidor al cliente. Por ejemplo, en Java, campos como Date o Calendar no tienen una forma estándar para serializarse en REST, con lo que deberemos ocuparnos nosotros. === Modelos ricos o enriquecidos === Al contrario que en los anémicos, estos modelos contienen lógica de negocio, con lo que son clases propiamente dichas. El problema fundamental es que, al contener lógica de negocio, no queremos exponer estos modelos fuera de los servicios, que son los únicos que deberían tratar con ellos, por lo tanto, necesitamos crear DTOs para transportar los datos entre los diferentes componentes. Podríamos crear un DTO por modelo y usarlo para transportar datos entre dominio - presentación, dominio - persistencia y viceversa. Otra opción, sería crear DTOs diferentes para tranportar datos entre dominio - presentación y dominio - persistencia. De hecho, en nuestro caso es lo que vamos a hacer. Para ello, usaremos la clase [[https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/Record.html|Record]] de Java. La clase Record se introdujo de forma previa en JDK 14, y de forma definitiva en JDK 17. Básicamente son una implementación del patrón DTO. En general, son una forma de almacenar valores de forma inmutable y que, además, permiten la creación de objetos de forma sencilla, dado que sólo necesitamos especificar los atributos y el compilador se encarga de crear los gettes, constructor con todos los atributos, además de los métodos equals, hashCode y toString Como hemos dicho, en nuestro caso crearemos dos conjuntos de DTOs, uno para comunicarnos con la capa de presentación (al que llamaremos modeloDTO) y otro con la de persistencia (modeloEntity). @startuml package "controller" { } package "domain" #EF5350{ package "service" { interface BookService { {abstract} List findAll(int page, int size) {abstract} BookDto findByIsbn(String isbn) } package "impl" { class BookServiceImpl { List findAll(int page, int size) BookDto findByIsbn(String isbn) } } package "dto" { record BookDto } } package "model" { class Book } package "repository" { interface BookRepository { {abstract} List findAll(int page, int size) {abstract} BookEntity findByIsbn(String isbn) } package "entity" { record BookEntity } } } package "persistence" { } BookServiceImpl .up.|> BookService BookServiceImpl -> Book BookService -> BookDto BookServiceImpl -down-> BookRepository BookRepository -> BookEntity controller -down-> BookService: BookDto persistence .up.|> BookRepository: BookEntity @enduml Por ejemplo, en el caso de Book, tendríamos: public class Book { private String isbn; private String titleEs; private String titleEn; private String synopsisEs; private String synopsisEn; private BigDecimal basePrice; private double discountPercentage; private BigDecimal price; private String cover; private LocalDate publicationDate; private Publisher publisher; private List authors; public Book( String isbn, String titleEs, String titleEn, String synopsisEs, String synopsisEn, BigDecimal basePrice, double discountPercentage, String cover, LocalDate publicationDate, Publisher publisher, List authors ) { this.isbn = isbn; this.titleEs = titleEs; this.titleEn = titleEn; this.synopsisEs = synopsisEs; this.synopsisEn = synopsisEn; this.basePrice = basePrice; this.discountPercentage = discountPercentage; this.price = calculateFinalPrice(); this.cover = cover; this.publicationDate = publicationDate; this.publisher = publisher; this.authors = authors; } // getters y setters public BigDecimal calculateFinalPrice() { BigDecimal discount = basePrice .multiply(BigDecimal.valueOf(discountPercentage)) .divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP); return basePrice.subtract(discount).setScale(2, RoundingMode.HALF_UP); } public void addAuthor(Author author) { this.authors.add(author); } } Fíjate que hemos creado dos métodos, uno para calcular el precio final y otro para añadir un autor al libro public record BookDto( String isbn, String titleEs, String titleEn, String synopsisEs, String synopsisEn, BigDecimal basePrice, double discountPercentage, BigDecimal price, String cover, LocalDate publicationDate, PublisherDto publisher, List authors ) { } public record BookEntity( String isbn, String titleEs, String titleEn, String synopsisEs, String synopsisEn, BigDecimal basePrice, double discountPercentage, String cover, LocalDate publicationDate, PublisherEntity publisher, List authors ) { } Como puedes ver, los DTOs no son exactamente iguales. BookDto tiene el precio final (que calculamos cuando construimos el objeto Book) mientras que BookEntity no lo tiene. ==== Mapeadores ==== En nuestro caso, hemos optado por usar modelos enriquecidos y DTOs. Obviamente, tenemos que crear mecanismos para transformar los modelos entre ellos. Estos componentes son los **mapeadores**. Aunque existen librerías de terceros como [[https://mapstruct.org/|MapStruct]] que nos facilitan la tarea, recuerda que estamos en la capa de dominio, con lo que no queremos añadir dependencias externas. En cualquier caso, aunque pueden ser engorrosos, el código no es nada complicado. Por ejemplo, podemos crear el mapeador de Book de la siguiente manera: public class BookMapper { private static BookMapper INSTANCE; private BookMapper() { } public static BookMapper getInstance() { if (INSTANCE == null) { INSTANCE = new BookMapper(); } return INSTANCE; } public Book fromBookEntityToBook(BookEntity bookEntity) { if (bookEntity == null) { throw new BusinessException("BookEntity cannot be null"); } return new Book( bookEntity.isbn(), bookEntity.titleEs(), bookEntity.titleEn(), bookEntity.synopsisEs(), bookEntity.synopsisEn(), bookEntity.basePrice(), bookEntity.discountPercentage(), bookEntity.cover(), bookEntity.publicationDate(), PublisherMapper.getInstance().fromPublisherEntityToPublisher(bookEntity.publisher()), bookEntity.authors().stream().map(AuthorMapper.getInstance()::fromAuthorEntityToAuthor).toList() ); } public BookEntity fromBookToBookEntity(Book book) { if (book == null) { throw new BusinessException("Book cannot be null"); } return new BookEntity( book.getIsbn(), book.getTitleEs(), book.getTitleEn(), book.getSynopsisEs(), book.getSynopsisEn(), book.getBasePrice(), book.getDiscountPercentage(), book.getCover(), book.getPublicationDate(), PublisherMapper.getInstance().fromPublisherToPublisherEntity(book.getPublisher()), book.getAuthors().stream().map(AuthorMapper.getInstance()::fromAuthorToAuthorEntity).toList() ); } public BookDto fromBookToBookDto(Book book) { if (book == null) { throw new BusinessException("Book cannot be null"); } return new BookDto( book.getIsbn(), book.getTitleEs(), book.getTitleEn(), book.getSynopsisEs(), book.getSynopsisEn(), book.getBasePrice(), book.getDiscountPercentage(), book.calculateFinalPrice(), book.getCover(), book.getPublicationDate(), PublisherMapper.getInstance().fromPublisherToPublisherDto(book.getPublisher()), book.getAuthors().stream().map(AuthorMapper.getInstance()::fromAuthorToAuthorDto).toList() ); } public Book fromBookDtoToBook(BookDto bookDto) { if (bookDto == null) { throw new BusinessException("BookDto cannot be null"); } return new Book( bookDto.isbn(), bookDto.titleEs(), bookDto.titleEn(), bookDto.synopsisEs(), bookDto.synopsisEn(), bookDto.basePrice(), bookDto.discountPercentage(), bookDto.cover(), bookDto.publicationDate(), PublisherMapper.getInstance().fromPublisherDtoToPublisher(bookDto.publisher()), bookDto.authors().stream().map(AuthorMapper.getInstance()::fromAuthorDtoToAuthor).toList() ); } } La idea es crearlo con el patrón //Singleton// para no tener que instanciar la clase cada vez que queramos utilizarlo (se encarga el método getInstance() de hacerlo por nosotros). De esta forma, podemos mapear objetos de forma sencilla: public BookDto getByIsbn(String isbn) { return bookRepository .findByIsbn(isbn) .map(BookMapper.getInstance()::fromBookEntityToBook) .map(BookMapper.getInstance()::fromBookToBookDto) .orElseThrow(() -> new BusinessException("Book with isbn " + isbn + " not found")); } ===== Servicios ===== Como siempre, usaremos interfaces para definir nuestros servicios, que serán los componentes con los que trabajará la capa de presentación (recuerda que devolvemos DTOs a la capa de presentación): public interface BookService { List getAll(int page, int size); BookDto getByIsbn(String isbn); Optional findByIsbn(String isbn); BookDto create(BookDto bookDto); BookDto update(BookDto bookDto); void delete(String isbn); } Observa que tenemos dos métodos aparentemente iguales: //findByIsbn// y //getByIsbn//. Cuando lleguemos al punto de las excepciones, entenderás el porqué. La implementación usará el repositorio para leer/escribir datos. Usaremos inyección de dependencias para inyectarle la implementación concreta del repositorio en el momento de creación del servicio: public class BookServiceImpl implements BookService { private final BookRepository bookRepository; public BookServiceImpl(BookRepository bookRepository) { this.bookRepository = bookRepository; } @Override public List getAll(int page, int size) { return bookRepository .findAll(page, size) .stream() .map(BookMapper.getInstance()::fromBookEntityToBook) .map(BookMapper.getInstance()::fromBookToBookDto) .toList(); } @Override public BookDto getByIsbn(String isbn) { return bookRepository .findByIsbn(isbn) .map(BookMapper.getInstance()::fromBookEntityToBook) .map(BookMapper.getInstance()::fromBookToBookDto) .orElseThrow(() -> new ResourceNotFoundException("Book with isbn " + isbn + " not found")); } @Override public Optional findByIsbn(String isbn) { return bookRepository.findByIsbn(isbn) .map(BookMapper.getInstance()::fromBookEntityToBook) .map(BookMapper.getInstance()::fromBookToBookDto); } @Override public BookDto create(BookDto bookDto) { return null; } @Override public BookDto update(BookDto bookDto) { return null; } @Override public void delete(String isbn) { } } Puede que te estés preguntando cómo haremos para inyectar la implementación del repositorio, pero eso no es problema del servicio. Cuando montemos la aplicación completa verás como usamos Spring para tal fin. Fíjate que mapeamos de BookEntity - Book - BookDto. Podrías pensar que ahorraríamos trabajo si mapeáramos directamente BookEntity - BookDto. El problema es que habrá validaciones o campos calculados (como //price// en Book) que estarán en la lógica de Book. Si mapeáramos directamente BookEntity - BookDto perderíamos esas funcionalidades. ===== Repositorios ===== En este paquete, definiremos las interfaces de nuestros repositorios. Como en el caso de los servicios, no trabajaremos directamente con los modelos de dominio, sino con los Entity: public interface BookRepository { List findAll(int page, int size); Optional findByIsbn(String isbn); } ¿Y las implementaciones? Eso le corresponde a la capa de persistencia, con lo que no nos preocuparemos por ahora. ===== Excepciones ===== El último paso será añadir excepciones a nuestros métodos. Lo primero que tenemos que definir es qué es una excepción, dónde se lanzan y quién las trata. Una excepción es un mecanismo mediante el cual podemos controlar los errores producidos en tiempo de ejecución. Por ejemplo, volvamos a los dos métodos similares de nuestro servicio: @Override public BookDto getByIsbn(String isbn) { return bookRepository .findByIsbn(isbn) .map(BookMapper.getInstance()::fromBookEntityToBook) .map(BookMapper.getInstance()::fromBookToBookDto) .orElseThrow(() -> new ResourceNotFoundException("Book with isbn " + isbn + " not found")); } @Override public Optional findByIsbn(String isbn) { return bookRepository.findByIsbn(isbn) .map(BookMapper.getInstance()::fromBookEntityToBook) .map(BookMapper.getInstance()::fromBookToBookDto); } ¿Por qué el primero lanza una excepción y el segundo no? La razón es sencilla; el primero lo utilizaremos en el caso de uso de recuperar los datos de un libro que asumimos que existe. Por ejemplo, cuando recibimos la petición GET /api/books/123456789. En ese caso, nuestra aplicación devolverá un 404 indicando que ese libro no existe. Por el contrario, el segundo método lo podemos usar, por ejemplo, para hacer un buscador por diferentes campos, como el ISBN. Si no existe ningún libro con el ISBN pasado, no significa que es un error, simplemente no hay resultados. En nuestro caso, por ahora crearemos dos tipos de excepciones: public class BusinessException extends RuntimeException { public BusinessException(String message) { super(message); } } public class ResourceNotFoundException extends RuntimeException { public ResourceNotFoundException(String message) { super(message); } } Aunque podríamos crear más tipos de excepciones (y puede que creemos más a lo largo del curso), simplifcaremos la tarea y usaremos la primera para cualquier error de la capa de negocio y la segunda para cuando no encontremos un recurso. ===== Repositorio ===== https://github.com/cesguiro/daw2-bookstore-domain