Capítulo 7: Application - Casos de Uso
Capítulo 7: Application - Casos de Uso
La capa Application orquesta las operaciones del dominio. No contiene reglas de negocio (eso vive en las entidades), pero coordina el flujo: recibe datos, invoca al dominio y delega la persistencia.
Rol de la capa Application
flowchart TB
A["HTTP Request"] --> B["Handler Axum\n(infrastructure)"]
B --> C["Use Case\n(application)"]
C --> D["Card + Lane\n(domain - entidades)"]
D --> E["CardRepository\n(domain - port/trait)"]
E --> F["SqliteRepository\n(infrastructure - adapter)"]
El use case es el pegamento entre la petición externa y el dominio. Su trabajo es:
- Recibir datos primitivos o de dominio.
- Invocar métodos de entidades si es necesario.
- Delegar persistencia al repositorio.
- Retornar el resultado.
Estructura de archivos
application/
├── mod.rs
└── use_cases/
├── mod.rs
├── create_card.rs
├── list_cards.rs
├── move_card.rs
└── delete_card.rs
// application/use_cases/mod.rs
pub mod create_card;
pub mod delete_card;
pub mod list_cards;
pub mod move_card;
Decisión de diseño: funciones, no structs
En muchos proyectos Java o C# los use cases son clases con un método execute(). En este proyecto son funciones libres en módulos:
// Nuestro enfoque: función libre
pub async fn execute(repo: &impl CardRepository, ...) -> Result<...> { }
// Alternativa: struct con método
struct CreateCard { repo: Arc<dyn CardRepository> }
impl CreateCard { async fn execute(&self, ...) { } }
Razones para elegir funciones:
- Menos boilerplate: No necesitas struct, constructor, ni lifetime annotations.
- Composición simple: Importas el módulo y llamas la función.
- Inyección explícita: El repositorio se pasa como argumento, no se oculta en un campo.
- Idiomatic Rust: Las funciones libres son la opción natural cuando no hay estado que mantener.
Use Case: create_card
use crate::domain::entities::Card;
use crate::domain::errors::DomainError;
use crate::domain::ports::CardRepository;
pub async fn execute(
repo: &impl CardRepository,
title: String,
description: String,
) -> Result<Card, DomainError> {
let card = Card::new(title, description);
repo.save(&card).await?;
Ok(card)
}
Análisis
Card::new(title, description): La entidad se encarga de asignar ID, lane por defecto (Todo), timestamps, etc. El use case no toma esas decisiones.repo.save(&card).await?: Persiste la tarjeta. El?propaga cualquierDomainError::Persistenceal caller.Ok(card): Retorna la tarjeta creada (con su ID generado) para que el handler la envíe como respuesta JSON.
El use case son 3 líneas de lógica. Toda la inteligencia está en Card::new() (dominio) y repo.save() (infraestructura).
Use Case: list_cards
use crate::domain::entities::Card;
use crate::domain::errors::DomainError;
use crate::domain::ports::CardRepository;
pub async fn execute(repo: &impl CardRepository) -> Result<Vec<Card>, DomainError> {
repo.find_all().await
}
Análisis
Una sola línea. Podrías pensar “¿para qué un use case de una línea?”. Razones:
- Uniformidad: Todos los handlers llaman a use cases. No hay excepciones.
- Extensibilidad: Si mañana necesitas filtrar por lane o paginar, el cambio es aquí, no en el handler.
- Testing: Puedes testear la orquestación independiente del handler HTTP.
Use Case: move_card
use crate::domain::errors::DomainError;
use crate::domain::ports::CardRepository;
use crate::domain::value_objects::{CardId, Lane};
pub async fn execute(
repo: &impl CardRepository,
card_id: &CardId,
lane: Lane,
position: i32,
) -> Result<(), DomainError> {
let mut card = repo.find_by_id(card_id).await?;
card.move_to(lane, position);
repo.update(&card).await
}
Análisis
Este es el use case más interesante porque involucra tres pasos:
repo.find_by_id(card_id).await?: Busca la tarjeta. Si no existe, propagaDomainError::CardNotFound.card.move_to(lane, position): Invoca la lógica de negocio de la entidad. Aquí se actualiza el lane, la posición, el flagcompletedy el timestamp.repo.update(&card).await: Persiste el estado actualizado.
Nota que el use case no decide si completed debe ser true o false. Esa regla vive en Card::move_to(). El use case solo orquesta.
Use Case: delete_card
use crate::domain::errors::DomainError;
use crate::domain::ports::CardRepository;
use crate::domain::value_objects::CardId;
pub async fn execute(
repo: &impl CardRepository,
card_id: &CardId,
) -> Result<(), DomainError> {
repo.delete(card_id).await
}
Análisis
Delegación directa al repositorio. Similar a list_cards, existe como use case para mantener la consistencia arquitectónica.
Polimorfismo estático: &impl CardRepository
Todos los use cases reciben el repositorio como &impl CardRepository:
pub async fn execute(repo: &impl CardRepository, ...) -> ...
Esto es polimorfismo estático (monomorfización). El compilador genera una versión especializada de la función para cada tipo concreto que implemente CardRepository. La alternativa sería polimorfismo dinámico:
// Estático (nuestro enfoque)
pub async fn execute(repo: &impl CardRepository, ...) -> ...
// Dinámico (alternativa)
pub async fn execute(repo: &dyn CardRepository, ...) -> ...
| Aspecto | &impl (estático) | &dyn (dinámico) |
|---|---|---|
| Performance | Sin overhead (inline) | Indirección vtable |
| Tamaño binario | Mayor (una copia por tipo) | Menor |
| Flexibilidad | Un tipo por call site | Múltiples tipos en runtime |
Elegimos estático porque solo tenemos una implementación en producción (SqliteCardRepository) y una en tests (MockCardRepository). No necesitamos despacho dinámico.
Principio de Responsabilidad Única
Cada use case hace exactamente una cosa:
| Use Case | Responsabilidad |
|---|---|
create_card | Crear y persistir una tarjeta nueva |
list_cards | Consultar todas las tarjetas |
move_card | Buscar, mover y actualizar una tarjeta |
delete_card | Eliminar una tarjeta por ID |
Ninguno supera las 15 líneas. Si un use case crece, es señal de que la lógica debería estar en la entidad o en un servicio de dominio.
Flujo completo de una operación
Tomemos move_card como ejemplo del flujo end-to-end:
sequenceDiagram
participant Client
participant Handler
participant UseCase as move_card
participant Card
participant Repo as SqliteRepository
Client->>Handler: PUT /api/cards/:id/move
Handler->>Handler: Extrae card_id, lane, position
Handler->>UseCase: execute(repo, card_id, lane, position)
UseCase->>Repo: find_by_id(card_id)
Repo->>Repo: SELECT en SQLite
Repo-->>UseCase: Card
UseCase->>Card: move_to(lane, position)
Card->>Card: Actualiza lane, position, completed, updated_at
UseCase->>Repo: update(&card)
Repo->>Repo: UPDATE en SQLite
Repo-->>UseCase: Ok(())
UseCase-->>Handler: Ok(())
Handler-->>Client: 200 OK
Cada capa tiene su responsabilidad clara. Si cambias de SQLite a PostgreSQL, solo tocas el paso 5 y 9. Los pasos 3-8 no cambian.
Resumen
- La capa Application contiene funciones de orquestación, no reglas de negocio.
- Los use cases son funciones libres, no structs, por simplicidad e idiomatismo en Rust.
&impl CardRepositoryusa polimorfismo estático: zero-cost abstraction.- Cada use case sigue el Principio de Responsabilidad Única y no supera 15 líneas.
- El patrón permite testear con mocks sin cambiar la firma de los use cases.
← Capítulo 6: Dominio - Ports y Errores | Capítulo 8: Persistencia SQLite con SQLx →