← Volver al listado de tecnologías

Capítulo 5: Dominio - Entidades y Value Objects

Por: SiempreListo
rustwasmaxumleptos

Capítulo 5: Dominio - Entidades y Value Objects

En este capítulo construimos el corazón de la aplicación: la capa de dominio. Aquí viven las reglas de negocio puras, sin dependencias de frameworks ni bases de datos.

Entidad vs Value Object

Antes de ver código, aclaremos dos conceptos fundamentales de Domain-Driven Design:

ConceptoIdentidadMutabilidadEjemplo
EntidadTiene identidad única (ID)Puede cambiar de estadoCard
Value ObjectSe define por sus atributosInmutable, se reemplaza completoCardId, Lane

Una Card es una entidad porque dos tarjetas con el mismo título son tarjetas distintas (tienen IDs diferentes). En cambio, Lane::Todo siempre es Lane::Todo sin importar el contexto: es un valor.

Estructura de archivos

domain/
├── mod.rs
├── entities/
│   ├── mod.rs          # pub use card::Card;
│   └── card.rs
├── value_objects/
│   ├── mod.rs          # pub use card_id::CardId; pub use lane::Lane;
│   ├── card_id.rs
│   └── lane.rs
├── errors.rs
└── ports/

Value Object: Lane

Empezamos por Lane porque es la pieza más simple y la necesita Card.

use serde::{Deserialize, Serialize};
use std::fmt;

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Lane {
    Todo,
    Doing,
    Done,
}

impl Lane {
    pub fn is_done(&self) -> bool {
        matches!(self, Lane::Done)
    }
}

impl fmt::Display for Lane {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Lane::Todo => write!(f, "todo"),
            Lane::Doing => write!(f, "doing"),
            Lane::Done => write!(f, "done"),
        }
    }
}

impl TryFrom<&str> for Lane {
    type Error = String;

    fn try_from(value: &str) -> Result<Self, Self::Error> {
        match value {
            "todo" => Ok(Lane::Todo),
            "doing" => Ok(Lane::Doing),
            "done" => Ok(Lane::Done),
            _ => Err(format!("Lane invalido: {value}")),
        }
    }
}

Desglose línea por línea

Diagrama de estados del tablero

stateDiagram-v2
    Todo --> Doing : mover
    Doing --> Done : mover
    Doing --> Todo : mover
    Done --> Todo : mover

El diseño permite mover tarjetas en cualquier dirección. No hay restricciones de flujo: puedes mover de Done a Todo directamente.

Tests de Lane

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn try_from_valid_lanes() {
        assert_eq!(Lane::try_from("todo").unwrap(), Lane::Todo);
        assert_eq!(Lane::try_from("doing").unwrap(), Lane::Doing);
        assert_eq!(Lane::try_from("done").unwrap(), Lane::Done);
    }

    #[test]
    fn try_from_invalid_lane() {
        assert!(Lane::try_from("invalid").is_err());
    }

    #[test]
    fn is_done_only_for_done() {
        assert!(!Lane::Todo.is_done());
        assert!(!Lane::Doing.is_done());
        assert!(Lane::Done.is_done());
    }
}

Los tests cubren tres escenarios: conversión exitosa, conversión fallida y la regla de negocio is_done().

Value Object: CardId

use serde::{Deserialize, Serialize};
use std::fmt;
use uuid::Uuid;

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CardId(String);

impl Default for CardId {
    fn default() -> Self {
        Self::new()
    }
}

impl CardId {
    pub fn new() -> Self {
        Self(Uuid::new_v4().to_string())
    }

    pub fn from_string(id: String) -> Self {
        Self(id)
    }

    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl fmt::Display for CardId {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self.0)
    }
}

Por qué un wrapper en lugar de usar UUID directamente

Podrías pensar: “¿por qué no usar Uuid directamente como campo de Card?”. Razones:

  1. Desacoplamiento: Si mañana cambias de UUID v4 a ULID o nanoid, solo tocas CardId. El resto del dominio no se entera.
  2. Semántica: CardId comunica intención. Un String podría ser cualquier cosa; un CardId es explícitamente un identificador de tarjeta.
  3. Almacenamiento: Internamente guardamos String porque SQLite no tiene tipo UUID nativo. El wrapper oculta este detalle.

Métodos clave

Entidad: Card

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};

use crate::domain::value_objects::{CardId, Lane};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Card {
    pub id: CardId,
    pub title: String,
    pub description: String,
    pub lane: Lane,
    pub position: i32,
    pub completed: bool,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

impl Card {
    pub fn new(title: String, description: String) -> Self {
        let now = Utc::now();
        Self {
            id: CardId::new(),
            title,
            description,
            lane: Lane::Todo,
            position: 0,
            completed: false,
            created_at: now,
            updated_at: now,
        }
    }

    pub fn move_to(&mut self, lane: Lane, position: i32) {
        self.lane = lane;
        self.position = position;
        self.completed = self.lane.is_done();
        self.updated_at = Utc::now();
    }
}

Campos de Card

CampoTipoPropósito
idCardIdIdentificador único, generado automáticamente
titleStringTítulo visible de la tarjeta
descriptionStringDetalle opcional
laneLaneColumna actual (Todo/Doing/Done)
positioni32Orden dentro de la columna
completedboolDerivado de lane == Done
created_atDateTime<Utc>Timestamp de creación
updated_atDateTime<Utc>Timestamp de última modificación

Constructor new()

El constructor establece valores por defecto razonables: toda tarjeta nueva empieza en Todo, posición 0, sin completar. No expone id ni timestamps como parámetros porque son responsabilidad interna de la entidad.

Método move_to()

Este es el único método con lógica de negocio real:

pub fn move_to(&mut self, lane: Lane, position: i32) {
    self.lane = lane;
    self.position = position;
    self.completed = self.lane.is_done();  // regla de negocio
    self.updated_at = Utc::now();
}

La línea self.completed = self.lane.is_done() es la regla clave: completed se sincroniza automáticamente con el lane. Si mueves a Done, se marca completada. Si mueves de Done a Todo, se desmarca. El consumidor nunca necesita setear completed manualmente.

Tests de Card

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn new_card_defaults_to_todo() {
        let card = Card::new("Test".into(), "Desc".into());
        assert_eq!(card.lane, Lane::Todo);
        assert!(!card.completed);
        assert_eq!(card.position, 0);
    }

    #[test]
    fn move_to_done_marks_completed() {
        let mut card = Card::new("Test".into(), String::new());
        card.move_to(Lane::Done, 1);
        assert!(card.completed);
        assert_eq!(card.lane, Lane::Done);
        assert_eq!(card.position, 1);
    }

    #[test]
    fn move_from_done_to_todo_unmarks_completed() {
        let mut card = Card::new("Test".into(), String::new());
        card.move_to(Lane::Done, 0);
        card.move_to(Lane::Todo, 0);
        assert!(!card.completed);
    }
}

Tres tests que validan el ciclo completo:

  1. Valores por defecto: Una tarjeta nueva está en Todo, no completada, posición 0.
  2. Mover a Done: Marca completed = true automáticamente.
  3. Revertir desde Done: Desmarca completed = false. Este test es importante porque verifica que la regla funciona en ambas direcciones.

Re-exportaciones con mod.rs

Cada subdirectorio tiene un mod.rs que declara los módulos y re-exporta los tipos públicos:

// domain/entities/mod.rs
pub mod card;
pub use card::Card;

// domain/value_objects/mod.rs
pub mod card_id;
pub mod lane;
pub use card_id::CardId;
pub use lane::Lane;

Esto permite importar use crate::domain::entities::Card en lugar de use crate::domain::entities::card::Card. Es un patrón estándar en Rust para mantener imports limpios.

Resumen

La capa de dominio no tiene dependencias externas salvo crates de utilidad (chrono, uuid, serde). No sabe nada de HTTP, bases de datos ni frameworks.


← Capítulo 4: Setup del Workspace | Capítulo 6: Dominio - Ports y Errores →