Te piden añadir un filtro a la pantalla de tareas pendientes. El cambio parece pequeño: escribir una palabra y ver solo las tareas cuyo título la contiene. Abres controllers, después services, luego repositories. Para seguir una sola funcionalidad, vas pasando por las carpetas de toda la aplicación.

Los cortes verticales proponen reunir el código según la operación que permite realizar al usuario. En este ejemplo tendremos dos: completar una tarea y consultar las pendientes. La idea se entiende mejor siguiendo una petición completa: qué entra, qué trabajo se hace y qué respuesta sale.

Las mismas piezas, agrupadas con otro criterio

Primero situemos las piezas. Una entrada recibe los datos de una petición y prepara la respuesta. La operación decide qué hacer con ellos. El modelo representa las tareas y el repositorio permite encontrarlas.

Si el primer criterio son los tipos de responsabilidad, podríamos repartirlas así:

controllers/
    CompleteTaskEndpoint.java
    PendingTasksEndpoint.java
services/
    CompleteTask.java
    ListPendingTasks.java
models/
    Task.java
repositories/
    TaskRepository.java

Al organizar por funcionalidad, reunimos la entrada y la operación a la que sirve:

tasks/
    complete/
        CompleteTaskEndpoint.java
        CompleteTask.java
    pending/
        PendingTasksEndpoint.java
        ListPendingTasks.java
    shared/
        Task.java
        TaskRepository.java

No hemos cambiado lo que hacen las clases. Ha cambiado qué encontramos junto: el recorrido de completar está en complete, y el de consultar pendientes, en pending. El modelo y el almacenamiento que utilizan ambos quedan identificados como compartidos dentro de tareas.

Un caso de uso es una operación con un objetivo concreto, como completar una tarea. Un corte vertical agrupa las piezas que colaboran para atenderla: puede abarcar entrada, validación, lógica, acceso a datos y respuesta. Se llama vertical porque atraviesa responsabilidades que una organización por capas presenta separadas horizontalmente.

En arquitectura en capas agrupábamos principalmente por responsabilidad técnica. Aquí el primer criterio es la funcionalidad. Dentro de ella seguimos separando responsabilidades cuando ayuda a entender el trabajo.

Dos cortes: CompleteTaskEndpoint utiliza CompleteTask, y PendingTasksEndpoint utiliza ListPendingTasks. Ambas operaciones acceden a TaskRepository y Task, compartidos.

Cada columna reúne una funcionalidad. Las flechas significan «utiliza esta pieza del código». La zona inferior recoge los elementos compartidos; ninguna de las funcionalidades necesita llamar a la otra. Eso no las convierte en aplicaciones independientes: siguen compartiendo tareas y almacenamiento.

Los datos del ejemplo: dos tareas de la misma aplicación

Antes de seguir las peticiones necesitamos saber qué guardamos. Usaremos tareas con un identificador, un título y un estado inicial pendiente:

class Task {
    private int id;
    private String title;
    private boolean completed = false;

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

    public void complete() {
        completed = true;
    }

    public int getId() {
        return id;
    }

    public String getTitle() {
        return title;
    }

    public boolean isCompleted() {
        return completed;
    }
}

complete() marca la tarea como terminada. Volver a llamarlo la deja terminada. La regla es pequeña para que podamos centrarnos en dónde se encuentra cada recorrido.

El repositorio guarda una lista en memoria y busca de verdad dentro de ella. List<Task> representa esa lista; ArrayList es la colección concreta que utilizaremos:

class TaskRepository {
    private List<Task> tasks;

    public TaskRepository(List<Task> tasks) {
        this.tasks = tasks;
    }

    public Task findById(int taskId) {
        for (Task task : tasks) {
            if (task.getId() == taskId) {
                return task;
            }
        }
        throw new IllegalArgumentException("Task not found.");
    }

    public List<Task> findAll() {
        return new ArrayList<>(tasks);
    }
}

findById() devuelve la tarea cuyo identificador coincide; si no existe, informa del error. findAll() devuelve una nueva lista con las mismas tareas. No duplica los objetos: si completamos una de esas tareas, una consulta posterior ve el nuevo estado.

Ese detalle permite mostrar el ejemplo sin una base de datos. Los cambios duran mientras conservamos estos objetos en memoria. Un almacenamiento persistente necesitaría guardar las modificaciones mediante su mecanismo correspondiente.

Primer corte: completar una tarea

La petición trae el identificador como texto, por ejemplo "1", como podría llegar desde un formulario. Utilizaremos Endpoint para nombrar la entrada de la funcionalidad. Aquí lo invocamos como un método: dejamos fuera el servidor HTTP y el framework.

complete/CompleteTaskEndpoint.java convierte la entrada, rechaza un identificador inválido y da forma al mensaje de respuesta:

class CompleteTaskEndpoint {
    private CompleteTask completeTask;

    public CompleteTaskEndpoint(CompleteTask completeTask) {
        this.completeTask = completeTask;
    }

    public String handle(String taskIdText) {
        int taskId;
        try {
            taskId = Integer.parseInt(taskIdText);
        } catch (NumberFormatException error) {
            return "Invalid task id.";
        }

        if (taskId <= 0) {
            return "Invalid task id.";
        }

        String title = completeTask.execute(taskId);
        return "Completed: " + title;
    }
}

Integer.parseInt() convierte "1" en el número 1. Si recibe "abc", no puede convertirlo y entra en el bloque catch: devuelve el error sin ejecutar la operación. Un número como -1 también se rechaza. Esta validación pertenece a la entrada de completar tareas.

La operación de complete/CompleteTask.java recibe ya el identificador numérico:

class CompleteTask {
    private TaskRepository tasks;

    public CompleteTask(TaskRepository tasks) {
        this.tasks = tasks;
    }

    public String execute(int taskId) {
        Task task = tasks.findById(taskId);
        task.complete();
        return task.getTitle();
    }
}

El recorrido queda completo: la entrada convierte "1", la operación busca la tarea 1, la marca como terminada y devuelve su título. La entrada construye entonces "Completed: Review the report".

Ambas clases viven juntas porque responden a completar una tarea, aunque sus responsabilidades sean distintas. Si cambiamos el mensaje de confirmación, buscamos la entrada de este corte. Si cambia la coordinación de completar, buscamos su operación. Una regla que protege el estado de la tarea pertenece al modelo que la contiene.

Segundo corte: consultar las pendientes

Ahora queremos consultar las pendientes y, opcionalmente, filtrar por una palabra del título. Esta petición tiene otra entrada, otra operación y otra respuesta.

pending/PendingTasksEndpoint.java recibe el filtro y construye el texto que devolveremos:

class PendingTasksEndpoint {
    private ListPendingTasks pendingTasks;

    public PendingTasksEndpoint(ListPendingTasks pendingTasks) {
        this.pendingTasks = pendingTasks;
    }

    public String handle(String titleFilter) {
        if (titleFilter == null) {
            titleFilter = "";
        }

        List<String> titles = pendingTasks.execute(titleFilter);
        if (titles.isEmpty()) {
            return "No pending tasks.";
        }

        String response = "Pending tasks:";
        for (String title : titles) {
            response = response + "\n- " + title;
        }
        return response;
    }
}

Si no llega un filtro, utiliza "", una cadena vacía que en este ejemplo permite mostrar todos los títulos. Después prepara una línea por resultado. \n introduce un salto de línea. Aquí no intentamos dibujar una página HTML: devolvemos texto para ver la respuesta sin añadir otra herramienta.

pending/ListPendingTasks.java decide qué tareas cumplen la consulta:

class ListPendingTasks {
    private TaskRepository tasks;

    public ListPendingTasks(TaskRepository tasks) {
        this.tasks = tasks;
    }

    public List<String> execute(String titleFilter) {
        List<String> titles = new ArrayList<>();

        for (Task task : tasks.findAll()) {
            if (!task.isCompleted()
                    && task.getTitle().contains(titleFilter)) {
                titles.add(task.getTitle());
            }
        }
        return titles;
    }
}

La condición exige dos cosas: que la tarea no esté completada y que su título contenga el filtro. ! significa «no» y &&, «y». contains() distingue mayúsculas y minúsculas: "invoice" aparece en "Send the invoice", pero "Invoice" no. El filtro vacío coincide con todos los títulos.

Esta consulta no completa tareas ni llama a CompleteTask. Tiene su recorrido y devuelve los títulos que la entrada necesita para preparar la respuesta.

Sigamos las peticiones con datos concretos

Preparamos dos tareas y entregamos el mismo repositorio a las dos operaciones. Los constructores conectan cada entrada con su operación:

List<Task> initialTasks = new ArrayList<>();
initialTasks.add(new Task(1, "Review the report"));
initialTasks.add(new Task(2, "Send the invoice"));

TaskRepository tasks = new TaskRepository(initialTasks);

CompleteTaskEndpoint complete =
        new CompleteTaskEndpoint(new CompleteTask(tasks));
PendingTasksEndpoint pending =
        new PendingTasksEndpoint(new ListPendingTasks(tasks));

Con esos objetos, realizamos las llamadas en orden.

Primero, pending.handle("") consulta sin filtro. Ambas tareas están pendientes, así que devuelve:

Pending tasks:
- Review the report
- Send the invoice

Después, pending.handle("invoice") selecciona solo el título que contiene esa palabra. No modifica ninguna tarea:

Pending tasks:
- Send the invoice

Ahora complete.handle("1") devuelve "Completed: Review the report". Esa petición sí modifica datos: la tarea 1 queda terminada.

Al consultar otra vez con pending.handle(""), la primera tarea ya no aparece. No hemos puesto ningún filtro; es su nuevo estado lo que la deja fuera:

Pending tasks:
- Send the invoice

Por último, complete.handle("abc") devuelve "Invalid task id.". La entrada rechaza el texto antes de buscar o modificar una tarea.

La entrada también puede recibir "99": es un número positivo válido, pero el repositorio no encuentra esa tarea y lanza IllegalArgumentException. El ejemplo deja visible ese error. Una entrada HTTP real tendría que traducirlo a la respuesta acordada, por ejemplo una respuesta de recurso no encontrado.

Qué cambio queda dentro de cada corte

Volvamos a la petición inicial: añadir el filtro de pendientes. Su entrada debe recibirlo y su consulta debe aplicarlo. En la organización por capas, buscaríamos esas dos clases en controllers y services. Con cortes verticales, las encontramos juntas en pending.

Para ese cambio no necesitamos modificar CompleteTaskEndpoint, CompleteTask, Task ni TaskRepository: el repositorio ya ofrece los datos necesarios. Lo que hemos mostrado es precisamente la versión que incorpora el filtro.

Otro cambio, como añadir un estado de tarea archivada, sí podría afectar al modelo compartido y a las dos operaciones. Agrupar por funcionalidad ayuda a localizar el trabajo; no garantiza que cualquier requisito toque una sola carpeta.

Compartir con un motivo y mantener límites comprensibles

Compartimos Task porque las dos operaciones necesitan el mismo estado de cada tarea. El repositorio permite acceder a esos mismos objetos. Es una elección pequeña para este ejemplo, no una obligación de que todos los cortes utilicen un repositorio común.

La propuesta de Jimmy Bogard sobre cortes verticales centra la organización en las peticiones y sus cambios. Cada corte puede adoptar la estructura que necesite: una consulta sencilla no tiene por qué atravesar las mismas piezas que una operación con reglas complejas. Separar entradas y operaciones aquí nos ha permitido ver qué reúne un corte; no fija una plantilla de dos clases para todas las funcionalidades.

Al compartir, la pregunta de DRY sigue siendo qué conocimiento debe evolucionar junto. Si un cambio habitual obliga a editar un gran servicio utilizado por todos los cortes, conviene revisar cuánto detalle hemos concentrado en él. Si necesitamos aislar una conexión externa, los puertos y adaptadores pueden ayudar dentro de esa funcionalidad.

Mover archivos es un comienzo. Para mantener los límites también hay que vigilar qué detalles importa cada funcionalidad de las demás. Podemos reorganizar un recorrido conservando su comportamiento y comprobar después si el siguiente cambio se encuentra donde esperábamos. El filtro de pendientes nos deja una pregunta concreta para valorar la mejora: ¿podemos seguir desde lo que recibe hasta lo que devuelve sin salir a buscar cada pieza por toda la aplicación?