En una biblioteca hay una regla fácil de entender: un ejemplar que ya está prestado no puede prestarse otra vez. Esa decisión debería seguir teniendo sentido aunque cambiemos la pantalla con la que trabaja el bibliotecario o el lugar donde guardamos los libros.

Clean Architecture propone organizar el código para que las reglas y los casos de uso puedan expresarse sin depender de esos detalles exteriores. Vamos a seguir un préstamo pequeño: encontrar un ejemplar, comprobar que está disponible, marcarlo como prestado y comunicar el resultado.

Qué hay en el centro y qué queda alrededor

La presentación de Robert C. Martin sobre Clean Architecture utiliza círculos para distinguir responsabilidades. Podemos leerlos con nuestro ejemplo:

  • Reglas del dominio: qué significa que un ejemplar esté disponible y cuándo puede prestarse.
  • Casos de uso: qué pasos necesita realizar la aplicación para prestar ese ejemplar.
  • Adaptadores: cómo interpretar una entrada o convertir los datos del almacenamiento a los objetos que utiliza la aplicación.
  • Herramientas externas: la pantalla, el servidor o la biblioteca que permite hablar con una base de datos.

Una regla del dominio expresa una condición del problema. Un caso de uso coordina una operación que alguien quiere realizar. El primero decide si un libro puede prestarse; el segundo organiza cómo encontrarlo y conservar el cambio.

Los círculos muestran qué conocimiento queremos proteger. No obligan a crear un proyecto separado por cada responsabilidad ni a añadir una clase entre cada dos llamadas.

Una entidad que protege una regla visible

Cada objeto Book representa un ejemplar físico. Dos ejemplares del mismo título tendrían identificadores diferentes y podrían estar en estados distintos.

class Book {
    private int id;
    private String title;
    private boolean borrowed = false;

    public Book(int id, String title) {
        this.id = id;
        this.title = title;
    }

    public void borrow() {
        if (borrowed) {
            throw new IllegalStateException("Book is already borrowed.");
        }

        borrowed = true;
    }

    public int getId() {
        return id;
    }

    public String getTitle() {
        return title;
    }

    public boolean isBorrowed() {
        return borrowed;
    }
}

Una entidad es un objeto cuya identidad nos importa aunque cambie su estado. El ejemplar 1 sigue siendo el ejemplar 1 después del préstamo. Lo que cambia es borrowed, que pasa de false a true.

borrow() contiene una decisión real: si el ejemplar ya está prestado, rechaza la operación mediante una excepción. Si está disponible, cambia su estado. La regla vive en el propio objeto y puede utilizarse desde cualquier entrada de la aplicación.

El modelo se concentra en la disponibilidad del ejemplar. Registrar quién se lo lleva, la fecha de devolución o una reserva añadiría otras reglas y datos. No los necesitamos para entender esta frontera, pero tampoco atribuimos al ejemplo la gestión completa de una biblioteca.

El caso de uso expresa qué necesita del almacenamiento

La operación necesita encontrar un libro y guardar su estado. Definimos ese contrato según las necesidades de la aplicación:

interface BookRepository {
    Book findById(int bookId);
    void save(Book book);
}

Una interfaz declara las operaciones que una implementación deberá ofrecer. Aquí no hemos decidido utilizar una base de datos concreta. Hemos dicho que queremos recuperar un ejemplar por su identificador y conservarlo después de modificarlo.

El contrato pertenece al interior de la aplicación. findById() debe devolver el libro solicitado o informar de que no existe; no debe inventar uno ni devolver otro por defecto. El caso de uso puede trabajar con esa promesa, como explicamos al hablar de dependencias y contratos.

class BorrowBook {
    private BookRepository books;

    public BorrowBook(BookRepository books) {
        this.books = books;
    }

    public String execute(int bookId) {
        Book book = books.findById(bookId);
        book.borrow();
        books.save(book);

        return book.getTitle();
    }
}

El recorrido tiene tres pasos: buscar, pedir el préstamo al libro y guardar. El resultado es el título del ejemplar prestado. Es un dato sencillo que otra parte podrá presentar como necesite.

Si la búsqueda falla, no hay libro sobre el que actuar. Si borrow() rechaza el préstamo, no se llega a guardar ni a devolver un resultado de éxito. La coordinación y la regla están separadas, pero podemos seguir claramente cómo trabajan juntas.

La regla de dependencias: qué nombres aparecen en cada pieza

La regla central dice que las dependencias del código deben dirigirse hacia las responsabilidades interiores. En nuestro ejemplo, BorrowBook conoce Book y BookRepository. Ninguno de esos nombres representa un controlador web o una conexión de base de datos.

Imagina que el contrato devolviese DatabaseRow y el caso de uso tuviera que leer una columna llamada is_borrowed. La operación ya necesitaría conocer cómo representa el almacenamiento a un libro. La dependencia exterior se habría colado en la conversación aunque existiera una interfaz.

El adaptador de almacenamiento debe hacer esa traducción. Si lee filas, construye los objetos que necesita la aplicación. Si recibe un libro para guardarlo, traduce su estado al formato de persistencia.

Es una aplicación de la inversión de dependencias: el adaptador exterior cumple el contrato definido para el interior. No basta con recibir cualquier clase por constructor; importa de quién depende la definición de ese contrato.

Un adaptador que guarda libros en una colección

Para ver una implementación concreta utilizaremos un mapa. Map<Integer, Book> asocia el identificador de cada ejemplar con su objeto. put() guarda una asociación y get() busca por esa clave.

class InMemoryBookRepository implements BookRepository {
    private Map<Integer, Book> books = new HashMap<>();

    public Book findById(int bookId) {
        Book book = books.get(bookId);

        if (book == null) {
            throw new IllegalArgumentException("Book not found.");
        }

        return book;
    }

    public void save(Book book) {
        books.put(book.getId(), book);
    }
}

Ahora hay una búsqueda sobre datos almacenados. Si el mapa contiene los ejemplares 1 y 2, podemos recuperar cualquiera de ellos. Buscar el 99 informa de que no existe.

save() añade el libro o reemplaza la asociación de su identificador. Este adaptador mantiene los objetos en memoria: quien recupera uno recibe el mismo objeto que guarda la colección. No simula una transacción ni conserva datos al cerrar el proceso. El contrato permite que otra implementación guarde el estado en un almacenamiento duradero.

Una base de datos tampoco resolvería automáticamente dos préstamos simultáneos del mismo ejemplar. La protección del cambio frente a peticiones concurrentes necesita su propia decisión de persistencia. Aquí seguimos una operación a la vez para comprender la dirección de las dependencias.

La presentación da forma al resultado

La entrada recibe el identificador y llama al caso de uso. Después decide cómo mostrar el título devuelto:

class BorrowBookController {
    private BorrowBook borrowBook;

    public BorrowBookController(BorrowBook borrowBook) {
        this.borrowBook = borrowBook;
    }

    public String borrow(int bookId) {
        String title = borrowBook.execute(bookId);
        return "Borrowed: " + title;
    }
}

El texto Borrowed: ... pertenece a la presentación. Cambiarlo por otro mensaje o preparar una respuesta web no exige modificar la condición de disponibilidad del libro.

Un controlador real también interpretaría los datos de entrada y traduciría los errores al formato adecuado. Un ejemplar inexistente y uno ya prestado tienen significados distintos, aunque ninguno produzca un préstamo correcto.

Sigamos el préstamo de un ejemplar concreto

Primero guardamos dos libros en el adaptador y conectamos el caso de uso con la entrada:

InMemoryBookRepository books = new InMemoryBookRepository();
books.save(new Book(1, "The Hobbit"));
books.save(new Book(2, "Frankenstein"));

BorrowBook useCase = new BorrowBook(books);
BorrowBookController controller = new BorrowBookController(useCase);

controller.borrow(1); // "Borrowed: The Hobbit"
books.findById(1).isBorrowed(); // true
books.findById(2).isBorrowed(); // false

El controlador entrega el identificador 1. El caso de uso recupera ese ejemplar, borrow() lo marca como prestado y el repositorio conserva su asociación. El segundo ejemplar sigue disponible porque nadie ha cambiado su estado.

Una segunda llamada a controller.borrow(1) encuentra el primer libro, pero su regla rechaza otro préstamo. Una llamada a controller.borrow(99) falla durante la búsqueda. Son dos errores que se pueden explicar siguiendo el recorrido.

BorrowBookController utiliza BorrowBook. BorrowBook utiliza Book y BookRepository. InMemoryBookRepository implementa BookRepository y almacena Book.

Las flechas continuas representan uso de tipos; la discontinua, implementación del contrato. En ejecución, una llamada como books.save(book) llega a InMemoryBookRepository. En el código de BorrowBook solo aparece BookRepository.

Por eso una llamada puede llegar a un objeto exterior mientras el caso de uso conserva una dependencia hacia un contrato interior. El lugar donde construimos los objetos conoce las implementaciones concretas y las conecta.

Cómo encaja con las otras formas de organizar

La arquitectura hexagonal destaca los puertos y las conexiones con el exterior. Clean Architecture hace explícita la separación entre reglas, casos de uso y adaptadores, y la dirección de sus dependencias. Pueden producir organizaciones muy parecidas.

Las capas distinguen responsabilidades; también pueden utilizar contratos para invertir la dependencia hacia la persistencia. Los cortes verticales agrupan por funcionalidades: una operación de préstamo puede reunir sus piezas y respetar a la vez estos límites.

Cada separación debe aportar algo comprensible. Para una operación pequeña quizá baste con menos estructura. Cuando hay reglas compartidas por varias entradas, conviene poder encontrarlas sin arrastrar los detalles de cada pantalla o almacenamiento.

Si partimos de código mezclado, podemos separar una responsabilidad conservando su comportamiento. La pregunta práctica es concreta: al cambiar el texto de la respuesta o el mecanismo de almacenamiento, ¿tenemos que volver a escribir cuándo un libro puede prestarse? La frontera sirve cuando esa decisión sigue teniendo un dueño claro.