Tabla de Contenidos

08 - Arquitectura de Software por Capas y Diseño de Componentes

1. El problema del código "Espagueti" y la necesidad de la arquitectura

Cuando comenzamos a programar, es habitual escribir todo el código en un único método o dentro de la misma clase: la lectura de datos, las validaciones, los cálculos, el acceso a la base de datos o ficheros y la presentación del resultado.

Este enfoque crea un sistema monolítico y acoplado, conocido popularmente como código espagueti.

Problemas del código sin estructurar

Arquitectura de Software

La Arquitectura de Software define cómo se dividen, organizan y comunican los componentes de una aplicación.

Aplicar una buena arquitectura nos permite obtener un sistema:

2. La Arquitectura por Capas (Layered Architecture)

La arquitectura por capas es un patrón de diseño que divide la aplicación en niveles jerárquicos. Cada capa tiene una responsabilidad única y ofrece servicios a la capa inmediatamente superior.

Principales Capas del Sistema

Reglas de Comunicación entre Capas

Capa de PresentaciónCapa de DominioCapa de PersistenciaBookControllerBookRequestBookResponseBookServiceBookServiceImplBookBookRepositoryBookRepositoryImplBookDaoBookDaoImplBookEntityBase de Datos / Ficheroconsumeutilizautilizaimplementautilizaconsumeimplementaconsumeimplementaleer/escribirutiliza

3. DTOs (Data Transfer Objects) y Java Records

Cuando la capa de presentación interactúa con el exterior (usuarios, clientes API, vistas), no es recomendable exponer directamente las entidades internas del dominio. Para gestionar esta entrada y salida de información de forma segura y eficiente se utilizan los DTO (Data Transfer Objects). Sus características principales son:

Java Records

Históricamente, crear un DTO en Java requería escribir mucho código repetitivo: atributos private final, constructor, getters, equals(), hashCode() y toString().

A partir de Java 14+, disponemos de los Records: clases inmutables de sintaxis compacta, diseñadas específicamente para transportar datos de forma inmutable.

Un Record de Java es una clase con las siguientes características:

import java.math.BigDecimal;

// Representa los datos necesarios para crear un nuevo libro (sin ID)
public record BookCreateRequest(
    String isbn,
    String title,
    BigDecimal basePrice
) {}

import java.math.BigDecimal;

// Representa los datos que enviamos al exterior (con el precio final calculado)
public record BookResponse(
    String isbn,
    String title,
    BigDecimal finalPrice
) {}

Conversión entre objetos: Mapeadores (Mappers)

La separación entre DTOs y Entidades del Dominio introduce una responsabilidad adicional: transformar o mapear objetos de un tipo a otro al cruzar la frontera entre la capa de presentación y la de dominio.

Un Mapper es un componente encargado de convertir instancias de un DTO a una Entidad de Dominio, y viceversa.

public class BookMapper {

    // Convierte el DTO de entrada en la Entidad del Dominio
    public static Book toDomain(BookCreateRequest dto) {
        if (dto == null) return null;
        return new Book(
            dto.isbn(),
            dto.title(),
            dto.basePrice()
        );
    }

    // Convierte la Entidad del Dominio en el DTO de salida
    public static BookResponse toResponse(Book domain) {
        if (domain == null) return null;
        return new BookResponse(
            domain.getIsbn(),
            domain.getTitleEs(),
            domain.getFinalPrice() // Aplica la regla/cálculo del dominio
        );
    }
}

4. Gestión de Dependencias: DI e IoC

Para conectar las distintas capas de nuestra aplicación de forma limpia, cada clase necesita interactuar con los servicios de la capa inferior. Sin embargo, la forma en que se instancian y gestionan esas colaboraciones marca la diferencia entre un código rígido y un sistema mantenible y desacoplado.

El error habitual al empezar a desarrollar aplicaciones en capas es que cada clase cree directamente sus propias dependencias utilizando el operador new.

public class BookRepositoryImpl implements BookRepository{
    private BookDao bookDao;

    public BookRepositoryImpl() {
        // ERROR: La clase decide internamente la implementación concreta
        this.bookDao = new BookDaoMySql(); // Implementación de BookDao usando una bbdd SQL
    }
}

public class BookDaoMySql implements BookDao {
...
}

public class BookDaoJson implements BookDao {
...
}

Inconvenientes de este enfoque:

Inyección de Dependencias (DI)

La Inyección de Dependencias (Dependency Injection) es un patrón de diseño en el que una clase recibe sus dependencias desde el exterior en lugar de instanciarlas internamente.

En lugar de que la clase cree el DAO, declara: “Necesito un BookDao para funcionar, entregádmelo al construirme”.

Inyección por constructor (Buena práctica)

La inyección a través del constructor es el enfoque recomendado en la industria porque garantiza que la clase se cree en un estado válido y con atributos inmutables.


// Apunta a una INTERFAZ y el atributo es inmutable
private final BookDao bookDao;

// La dependencia se inyecta desde fuera
public BookRepositoryImpl(BookDao bookDao) {
    this.bookDao = bookDao;
}

Inversión de Control (IoC)

La Inversión de Control (Inversion of Control) es un principio de diseño más amplio donde el control del flujo de ejecución y de la creación de objetos se delega a un componente externo (el ensamblador o contenedor).

Ensamblado manual del sistema

Al no usar de momento un framework como Spring (que automatiza esta tarea mediante un contenedor de IoC), el ensamblado de las distintas capas se puede realiza manualmente en el punto de entrada de la aplicación (el método main).

static void main() {
    // 1. Instanciamos el DAO
    BookDao bookDao = new BookDaoMySql();

    // 2. Inyectamos el DAO al repositorio
    BookRepository bookRepository = new BookRepositoryImpl(bookDao);

    // 3. Inyectamos el repositorio al servicio
    BookService bookService = new BookServiceImpl(bookRepository);

    // 4. Inyectamos el servicio en la capa de presentación
    BookController bookController = new BookController(bookService);

    // 5. Iniciamos la ejecución del sistema
    bookController.run();
}

El contenedor de IoC (IoC Container)

En aplicaciones reales con decenas o cientos de clases, instanciar y conectar todos los objetos manualmente en el método main se vuelve inmanejable.

Para solucionar esto existen los Contenedores de IoC (IoC Containers o Dependency Injection Containers): componentes encargados de instanciar, configurar y ensamblar todos los objetos del sistema (denominados habitualmente beans o componentes).

Para entender cómo funciona internamente un framework como Spring, podemos construir un contenedor de IoC básico a mano utilizando el patrón Factoría (Factory):


// Contenedor IoC casero que gestiona el ciclo de vida y las dependencias
public class IoCContainer {

    private final BookDao bookDao;
    private final BookRepository bookRepository;
    private final BookService bookService;
    private final BookController bookController;

    public IoCContainer() {
        // El contenedor asume la responsabilidad de crear e inyectar
        this.bookDao = new BookDaoMySql();
        this.bookRepository = new BookRepositoryImpl(this.bookDao);
        this.bookService = new BookServiceImpl(this.bookRepository);
        this.bookController = new BookController(this.bookService);
    }

    // Métodos para proporcionar los componentes instanciados
    public BookController getBookController() {
        return this.bookController;
    }
}

Con esta abstracción, la clase Main se simplifica al máximo. Ya no necesita saber cómo se construyen ni cómo se conectan las capas:

static void main() {
    IoCContainer container = new IoCContainer();

    BookController bookController = container.getBookController();
    
    bookController.run();
}

Ventajas de IoC / DI