← Volver al listado de tecnologías

Capítulo 7: Application - Casos de Uso

Por: SiempreListo
rustwasmaxumleptos

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:

  1. Recibir datos primitivos o de dominio.
  2. Invocar métodos de entidades si es necesario.
  3. Delegar persistencia al repositorio.
  4. 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:

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

  1. Card::new(title, description): La entidad se encarga de asignar ID, lane por defecto (Todo), timestamps, etc. El use case no toma esas decisiones.
  2. repo.save(&card).await?: Persiste la tarjeta. El ? propaga cualquier DomainError::Persistence al caller.
  3. 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:

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:

  1. repo.find_by_id(card_id).await?: Busca la tarjeta. Si no existe, propaga DomainError::CardNotFound.
  2. card.move_to(lane, position): Invoca la lógica de negocio de la entidad. Aquí se actualiza el lane, la posición, el flag completed y el timestamp.
  3. 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)
PerformanceSin overhead (inline)Indirección vtable
Tamaño binarioMayor (una copia por tipo)Menor
FlexibilidadUn tipo por call siteMú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 CaseResponsabilidad
create_cardCrear y persistir una tarjeta nueva
list_cardsConsultar todas las tarjetas
move_cardBuscar, mover y actualizar una tarjeta
delete_cardEliminar 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


← Capítulo 6: Dominio - Ports y Errores | Capítulo 8: Persistencia SQLite con SQLx →