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:
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>es.cesguiro</groupId> <artifactId>daw2-bookstore-domain</artifactId> <version>1.0-SNAPSHOT</version> <packaging>jar</packaging> <name>${project.groupId}.${project.artifactId}</name> <description>Demo project for domain layer</description> <url>https://github.com/cesguiro/${project.artifactId}</url> <licenses> <license> <name>MIT License</name> <url>http://www.opensource.org/licenses/mit-license.php</url> </license> </licenses> <developers> <developer> <name>César Guijarro</name> <id>cesguiro</id> <email>cesguirol@gmail.com</email> <organization>cesguiro</organization> <roles> <role>Architect</role> <role>Developer</role> </roles> <timezone>+1</timezone> <url>https://cesguiro.es</url> </developer> </developers> <properties> <maven.compiler.source>21</maven.compiler.source> <maven.compiler.target>21</maven.compiler.target> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <org.junit.version>5.10.3</org.junit.version> <org.mockito.version>5.12.0</org.mockito.version> <org.apache.maven.plugins.version>3.5.2</org.apache.maven.plugins.version> </properties> <dependencies> <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter</artifactId> <version>${org.junit.version}</version> <scope>test</scope> </dependency> <dependency> <groupId>org.mockito</groupId> <artifactId>mockito-junit-jupiter</artifactId> <version>${org.mockito.version}</version> <scope>test</scope> </dependency> </dependencies> <build> <plugins> <!-- Plugin Surefire para ejecutar pruebas con JUnit 5 --> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-surefire-plugin</artifactId> <version>${org.apache.maven.plugins.version}</version> </plugin> </plugins> </build> </project>
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.
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:
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.
De esta forma, nuestra capa de dominio estará formada (por ahora) por los packages:
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<Author> 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:
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).
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. 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:
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.
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 Record de Java.
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).
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<Author> authors; public Book( String isbn, String titleEs, String titleEn, String synopsisEs, String synopsisEn, BigDecimal basePrice, double discountPercentage, String cover, LocalDate publicationDate, Publisher publisher, List<Author> 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); } }
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<AuthorDto> 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<AuthorEntity> authors ) { }
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 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")); }
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<BookDto> getAll(int page, int size); BookDto getByIsbn(String isbn); Optional<BookDto> findByIsbn(String isbn); BookDto create(BookDto bookDto); BookDto update(BookDto bookDto); void delete(String isbn); }
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<BookDto> 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<BookDto> 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) { } }
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<BookEntity> findAll(int page, int size); Optional<BookEntity> findByIsbn(String isbn); }
¿Y las implementaciones? Eso le corresponde a la capa de persistencia, con lo que no nos preocuparemos por ahora.
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<BookDto> 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.