Tabla de Contenidos

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:

<?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>

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:

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()

De esta forma, nuestra capa de dominio estará formada (por ahora) por los packages:

domainservicemodelrepository

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<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:

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. 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:

domainusecaseimplserviceimplmodelFindAllBooksUseCaseexecute()FindBookByIsbnexecute(String isbn)FindAllBooksUseCaseImplexecute()FindBookByIsbnImplexecute(String isbn)BookServicefindAll(int page, int size)findByIsbn(String isbn)BookServiceImplfindAll(int page, int size)findByIsbn(String isbn)Book

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 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).

domainserviceimpldtomodelrepositoryentitycontrollerBookServiceList<BookDto> findAll(int page, int size)BookDto findByIsbn(String isbn)BookServiceImplList<BookDto> findAll(int page, int size)BookDto findByIsbn(String isbn)BookDtoBookBookRepositoryList<BookEntity> findAll(int page, int size)BookEntity findByIsbn(String isbn)BookEntitypersistenceBookDtoBookEntity

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

}

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

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

}

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<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) {

    }
}

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<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.

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<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.

Repositorio

https://github.com/cesguiro/daw2-bookstore-domain