Capítulo 5: Dominio - Entidades y Value Objects
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:
| Concepto | Identidad | Mutabilidad | Ejemplo |
|---|---|---|---|
| Entidad | Tiene identidad única (ID) | Puede cambiar de estado | Card |
| Value Object | Se define por sus atributos | Inmutable, se reemplaza completo | CardId, 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
#[serde(rename_all = "lowercase")]: SerializaTodocomo"todo"en JSON. Sin esto, serde usaría"Todo"con mayúscula, inconsistente con APIs REST.PartialEq, Eq: Permite comparar lanes con==. Necesario para tests y lógica de negocio.is_done(): Encapsula la regla de negocio “una tarjeta está completada si está en Done”. Usamatches!en lugar deif letpor concisión.Display: Convierte el enum a string para logs y mensajes de error.TryFrom<&str>: Conversión segura desde strings (por ejemplo, desde query params). RetornaErren lugar depanic!ante valores inválidos.
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:
- Desacoplamiento: Si mañana cambias de UUID v4 a ULID o nanoid, solo tocas
CardId. El resto del dominio no se entera. - Semántica:
CardIdcomunica intención. UnStringpodría ser cualquier cosa; unCardIdes explícitamente un identificador de tarjeta. - Almacenamiento: Internamente guardamos
Stringporque SQLite no tiene tipo UUID nativo. El wrapper oculta este detalle.
Métodos clave
new(): Genera un UUID v4 aleatorio y lo convierte a String.from_string(): Reconstruye unCardIddesde la base de datos. No valida formato porque confiamos en datos ya persistidos.as_str(): Acceso de solo lectura al String interno, sin clonar.Default: Delega anew(). Rust lo exige si implementasnew()sin argumentos (lintclippy::new_without_default).
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
| Campo | Tipo | Propósito |
|---|---|---|
id | CardId | Identificador único, generado automáticamente |
title | String | Título visible de la tarjeta |
description | String | Detalle opcional |
lane | Lane | Columna actual (Todo/Doing/Done) |
position | i32 | Orden dentro de la columna |
completed | bool | Derivado de lane == Done |
created_at | DateTime<Utc> | Timestamp de creación |
updated_at | DateTime<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:
- Valores por defecto: Una tarjeta nueva está en Todo, no completada, posición 0.
- Mover a Done: Marca
completed = trueautomáticamente. - 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
- Lane modela las tres columnas del Kanban como un enum con conversiones seguras.
- CardId encapsula la generación de UUIDs, desacoplando al dominio del crate
uuid. - Card contiene la única regla de negocio:
completedse deriva automáticamente del lane. - Cada tipo tiene tests que validan su comportamiento.
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 →