← Volver al listado de tecnologías

Capítulo 11: Caso de Estudio - Sistema de Gestión de Tareas

Por: Alfred Pennyworth
arquitectura-hexagonalcaso-estudiotask-managementtypescriptreal-time

Capítulo 11: Caso de Estudio - Sistema de Gestión de Tareas

Introducción

En este capítulo construiremos un sistema de gestión de tareas colaborativo tipo Trello/Asana aplicando arquitectura hexagonal con énfasis en eventos de dominio y actualizaciones en tiempo real.

1. Análisis de Requisitos

Funcionalidades Core

Gestión de Proyectos:

Gestión de Tareas:

Colaboración:

Requisitos Técnicos

2. Modelado del Dominio

Agregados Identificados

┌────────────────────────────────────────────────┐
│              Task Management                    │
├──────────────────┬─────────────────────────────┤
│   Project        │   Task                      │
│   (Aggregate)    │   (Aggregate)               │
│                  │                             │
│ - Project        │ - Task                      │
│ - Members        │ - Comments                  │
│ - Columns        │ - Attachments               │
│                  │ - Activities                │
└──────────────────┴─────────────────────────────┘

Dominio: Task Aggregate

// domain/task/priority.vo.ts
type PriorityLevel = 'low' | 'medium' | 'high' | 'urgent';

class Priority {
  private constructor(private readonly level: PriorityLevel) {}

  static low(): Priority {
    return new Priority('low');
  }

  static medium(): Priority {
    return new Priority('medium');
  }

  static high(): Priority {
    return new Priority('high');
  }

  static urgent(): Priority {
    return new Priority('urgent');
  }

  static fromString(level: string): Priority {
    const validLevels: PriorityLevel[] = ['low', 'medium', 'high', 'urgent'];
    if (!validLevels.includes(level as PriorityLevel)) {
      throw new Error('Nivel de prioridad inválido');
    }
    return new Priority(level as PriorityLevel);
  }

  isHigherThan(other: Priority): boolean {
    const order = { low: 1, medium: 2, high: 3, urgent: 4 };
    return order[this.level] > order[other.level];
  }

  get value(): PriorityLevel {
    return this.level;
  }
}

// domain/task/due-date.vo.ts
class DueDate {
  private constructor(private readonly date: Date) {}

  static create(date: Date): DueDate {
    const now = new Date();
    if (date < now) {
      throw new Error('Fecha límite no puede ser en el pasado');
    }
    return new DueDate(date);
  }

  isOverdue(): boolean {
    return this.date < new Date();
  }

  daysUntilDue(): number {
    const now = new Date();
    const diff = this.date.getTime() - now.getTime();
    return Math.ceil(diff / (1000 * 60 * 60 * 24));
  }

  get value(): Date {
    return this.date;
  }
}

// domain/task/comment.entity.ts
class Comment {
  private constructor(
    private readonly id: string,
    private readonly authorId: string,
    private content: string,
    private readonly createdAt: Date,
    private mentions: string[]
  ) {}

  static create(
    id: string,
    authorId: string,
    content: string,
    mentions: string[]
  ): Comment {
    if (!content || content.trim().length === 0) {
      throw new Error('Comentario no puede estar vacío');
    }
    if (content.length > 5000) {
      throw new Error('Comentario demasiado largo');
    }
    return new Comment(id, authorId, content, new Date(), mentions);
  }

  edit(newContent: string): void {
    if (!newContent || newContent.trim().length === 0) {
      throw new Error('Comentario no puede estar vacío');
    }
    this.content = newContent;
  }

  get commentId(): string { return this.id; }
  get commentAuthorId(): string { return this.authorId; }
  get commentContent(): string { return this.content; }
  get commentCreatedAt(): Date { return this.createdAt; }
  get commentMentions(): string[] { return [...this.mentions]; }
}

// domain/task/task.aggregate.ts
class Task {
  private comments: Comment[] = [];
  private activities: TaskActivity[] = [];

  private constructor(
    private readonly id: string,
    private readonly projectId: string,
    private title: string,
    private description: string,
    private priority: Priority,
    private dueDate: DueDate | null,
    private assigneeId: string | null,
    private columnId: string,
    private readonly createdBy: string,
    private readonly createdAt: Date
  ) {}

  static create(
    id: string,
    projectId: string,
    title: string,
    description: string,
    priority: Priority,
    createdBy: string
  ): Task {
    if (!title || title.trim().length === 0) {
      throw new Error('Título requerido');
    }
    if (title.length > 200) {
      throw new Error('Título demasiado largo');
    }

    const task = new Task(
      id,
      projectId,
      title,
      description,
      priority,
      null,
      null,
      'backlog', // columna por defecto
      createdBy,
      new Date()
    );

    task.recordActivity('created', createdBy, { title });

    return task;
  }

  updateTitle(newTitle: string, userId: string): void {
    if (!newTitle || newTitle.trim().length === 0) {
      throw new Error('Título requerido');
    }
    const oldTitle = this.title;
    this.title = newTitle;
    this.recordActivity('title_updated', userId, { oldTitle, newTitle });
  }

  updatePriority(newPriority: Priority, userId: string): void {
    const oldPriority = this.priority.value;
    this.priority = newPriority;
    this.recordActivity('priority_changed', userId, {
      oldPriority,
      newPriority: newPriority.value
    });
  }

  setDueDate(dueDate: DueDate, userId: string): void {
    this.dueDate = dueDate;
    this.recordActivity('due_date_set', userId, { dueDate: dueDate.value });
  }

  assign(assigneeId: string, userId: string): void {
    const oldAssignee = this.assigneeId;
    this.assigneeId = assigneeId;
    this.recordActivity('assigned', userId, { oldAssignee, assigneeId });
  }

  moveToColumn(columnId: string, userId: string): void {
    const oldColumn = this.columnId;
    this.columnId = columnId;
    this.recordActivity('moved', userId, { oldColumn, newColumn: columnId });
  }

  addComment(comment: Comment, userId: string): void {
    this.comments.push(comment);
    this.recordActivity('commented', userId, {
      commentId: comment.commentId
    });
  }

  private recordActivity(
    type: string,
    userId: string,
    metadata: Record<string, any>
  ): void {
    this.activities.push({
      id: crypto.randomUUID(),
      type,
      userId,
      timestamp: new Date(),
      metadata
    });
  }

  get taskId(): string { return this.id; }
  get taskProjectId(): string { return this.projectId; }
  get taskTitle(): string { return this.title; }
  get taskDescription(): string { return this.description; }
  get taskPriority(): Priority { return this.priority; }
  get taskDueDate(): DueDate | null { return this.dueDate; }
  get taskAssigneeId(): string | null { return this.assigneeId; }
  get taskColumnId(): string { return this.columnId; }
  get taskComments(): Comment[] { return [...this.comments]; }
  get taskActivities(): TaskActivity[] { return [...this.activities]; }

  isOverdue(): boolean {
    return this.dueDate ? this.dueDate.isOverdue() : false;
  }
}

interface TaskActivity {
  id: string;
  type: string;
  userId: string;
  timestamp: Date;
  metadata: Record<string, any>;
}

Dominio: Project Aggregate

// domain/project/member.vo.ts
type MemberRole = 'owner' | 'admin' | 'member';

class Member {
  constructor(
    readonly userId: string,
    readonly role: MemberRole,
    readonly joinedAt: Date
  ) {}

  canManageMembers(): boolean {
    return this.role === 'owner' || this.role === 'admin';
  }

  canDeleteProject(): boolean {
    return this.role === 'owner';
  }
}

// domain/project/column.entity.ts
class Column {
  private constructor(
    private readonly id: string,
    private name: string,
    private order: number,
    private limit: number | null
  ) {}

  static create(id: string, name: string, order: number): Column {
    if (!name || name.trim().length === 0) {
      throw new Error('Nombre de columna requerido');
    }
    return new Column(id, name, order, null);
  }

  setWIPLimit(limit: number): void {
    if (limit < 1) {
      throw new Error('Límite WIP debe ser al menos 1');
    }
    this.limit = limit;
  }

  get columnId(): string { return this.id; }
  get columnName(): string { return this.name; }
  get columnOrder(): number { return this.order; }
  get columnLimit(): number | null { return this.limit; }
}

// domain/project/project.aggregate.ts
class Project {
  private members: Member[] = [];
  private columns: Column[] = [];

  private constructor(
    private readonly id: string,
    private name: string,
    private description: string,
    private readonly ownerId: string,
    private readonly createdAt: Date
  ) {}

  static create(
    id: string,
    name: string,
    description: string,
    ownerId: string
  ): Project {
    if (!name || name.trim().length === 0) {
      throw new Error('Nombre de proyecto requerido');
    }

    const project = new Project(id, name, description, ownerId, new Date());

    // Owner es automáticamente miembro
    project.members.push(new Member(ownerId, 'owner', new Date()));

    // Columnas por defecto
    project.columns = [
      Column.create('backlog', 'Backlog', 1),
      Column.create('todo', 'To Do', 2),
      Column.create('in-progress', 'In Progress', 3),
      Column.create('done', 'Done', 4)
    ];

    return project;
  }

  addMember(userId: string, role: MemberRole, addedBy: string): void {
    // Verificar permisos
    const adder = this.members.find(m => m.userId === addedBy);
    if (!adder || !adder.canManageMembers()) {
      throw new Error('Sin permisos para agregar miembros');
    }

    // Verificar si ya es miembro
    if (this.members.some(m => m.userId === userId)) {
      throw new Error('Usuario ya es miembro del proyecto');
    }

    this.members.push(new Member(userId, role, new Date()));
  }

  removeMember(userId: string, removedBy: string): void {
    // No se puede remover al owner
    if (userId === this.ownerId) {
      throw new Error('No se puede remover al owner del proyecto');
    }

    const remover = this.members.find(m => m.userId === removedBy);
    if (!remover || !remover.canManageMembers()) {
      throw new Error('Sin permisos para remover miembros');
    }

    this.members = this.members.filter(m => m.userId !== userId);
  }

  addColumn(column: Column, addedBy: string): void {
    const member = this.members.find(m => m.userId === addedBy);
    if (!member || !member.canManageMembers()) {
      throw new Error('Sin permisos para agregar columnas');
    }

    this.columns.push(column);
  }

  isMember(userId: string): boolean {
    return this.members.some(m => m.userId === userId);
  }

  get projectId(): string { return this.id; }
  get projectName(): string { return this.name; }
  get projectDescription(): string { return this.description; }
  get projectOwnerId(): string { return this.ownerId; }
  get projectMembers(): Member[] { return [...this.members]; }
  get projectColumns(): Column[] { return [...this.columns]; }
}

3. Casos de Uso Principales

Crear Tarea

// application/use-cases/create-task.usecase.ts
interface CreateTaskCommand {
  projectId: string;
  title: string;
  description: string;
  priority: string;
  userId: string;
}

class CreateTaskUseCaseImpl {
  constructor(
    private taskRepository: TaskRepository,
    private projectRepository: ProjectRepository,
    private eventBus: EventBus
  ) {}

  async execute(command: CreateTaskCommand): Promise<{ taskId: string }> {
    // Verificar que proyecto existe y usuario es miembro
    const project = await this.projectRepository.findById(command.projectId);
    if (!project) {
      throw new Error('Proyecto no encontrado');
    }

    if (!project.isMember(command.userId)) {
      throw new Error('Usuario no es miembro del proyecto');
    }

    // Crear tarea
    const priority = Priority.fromString(command.priority);
    const task = Task.create(
      crypto.randomUUID(),
      command.projectId,
      command.title,
      command.description,
      priority,
      command.userId
    );

    // Persistir
    await this.taskRepository.save(task);

    // Emitir evento
    await this.eventBus.publish({
      type: 'TaskCreated',
      taskId: task.taskId,
      projectId: task.taskProjectId,
      createdBy: command.userId,
      timestamp: new Date()
    });

    return { taskId: task.taskId };
  }
}

Mover Tarea (con reglas de negocio)

// application/use-cases/move-task.usecase.ts
interface MoveTaskCommand {
  taskId: string;
  targetColumnId: string;
  userId: string;
}

class MoveTaskUseCaseImpl {
  constructor(
    private taskRepository: TaskRepository,
    private projectRepository: ProjectRepository,
    private eventBus: EventBus
  ) {}

  async execute(command: MoveTaskCommand): Promise<void> {
    // Cargar tarea
    const task = await this.taskRepository.findById(command.taskId);
    if (!task) {
      throw new Error('Tarea no encontrada');
    }

    // Verificar permisos
    const project = await this.projectRepository.findById(task.taskProjectId);
    if (!project || !project.isMember(command.userId)) {
      throw new Error('Sin permisos para mover tarea');
    }

    // Verificar que columna existe
    const targetColumn = project.projectColumns.find(
      c => c.columnId === command.targetColumnId
    );
    if (!targetColumn) {
      throw new Error('Columna destino no existe');
    }

    // Verificar límite WIP
    if (targetColumn.columnLimit) {
      const tasksInColumn = await this.taskRepository.countByColumn(
        task.taskProjectId,
        command.targetColumnId
      );

      if (tasksInColumn >= targetColumn.columnLimit) {
        throw new Error('Columna ha alcanzado su límite WIP');
      }
    }

    // Mover
    task.moveToColumn(command.targetColumnId, command.userId);

    // Persistir
    await this.taskRepository.save(task);

    // Emitir evento
    await this.eventBus.publish({
      type: 'TaskMoved',
      taskId: task.taskId,
      projectId: task.taskProjectId,
      targetColumnId: command.targetColumnId,
      movedBy: command.userId,
      timestamp: new Date()
    });
  }
}

Agregar Comentario (con menciones)

// application/use-cases/add-comment.usecase.ts
interface AddCommentCommand {
  taskId: string;
  content: string;
  userId: string;
}

class AddCommentUseCaseImpl {
  constructor(
    private taskRepository: TaskRepository,
    private projectRepository: ProjectRepository,
    private notificationService: NotificationService,
    private eventBus: EventBus
  ) {}

  async execute(command: AddCommentCommand): Promise<{ commentId: string }> {
    // Cargar tarea
    const task = await this.taskRepository.findById(command.taskId);
    if (!task) {
      throw new Error('Tarea no encontrada');
    }

    // Verificar permisos
    const project = await this.projectRepository.findById(task.taskProjectId);
    if (!project || !project.isMember(command.userId)) {
      throw new Error('Sin permisos');
    }

    // Extraer menciones (@usuario)
    const mentions = this.extractMentions(command.content);

    // Crear comentario
    const comment = Comment.create(
      crypto.randomUUID(),
      command.userId,
      command.content,
      mentions
    );

    // Agregar a tarea
    task.addComment(comment, command.userId);

    // Persistir
    await this.taskRepository.save(task);

    // Notificar mencionados
    for (const mentionedUserId of mentions) {
      await this.notificationService.notifyMention({
        userId: mentionedUserId,
        taskId: task.taskId,
        commentId: comment.commentId,
        mentionedBy: command.userId
      });
    }

    // Emitir evento
    await this.eventBus.publish({
      type: 'CommentAdded',
      taskId: task.taskId,
      commentId: comment.commentId,
      authorId: command.userId,
      mentions,
      timestamp: new Date()
    });

    return { commentId: comment.commentId };
  }

  private extractMentions(content: string): string[] {
    const mentionRegex = /@(\w+)/g;
    const matches = content.matchAll(mentionRegex);
    return Array.from(matches, m => m[1]);
  }
}

4. Infraestructura: Real-time con WebSockets

// infrastructure/adapters/primary/websocket/task-websocket.handler.ts
class TaskWebSocketHandler {
  private clients: Map<string, Set<WebSocket>> = new Map();

  constructor(private eventBus: EventBus) {
    this.subscribeToEvents();
  }

  handleConnection(ws: WebSocket, userId: string, projectId: string): void {
    // Agregar cliente a room del proyecto
    if (!this.clients.has(projectId)) {
      this.clients.set(projectId, new Set());
    }
    this.clients.get(projectId)!.add(ws);

    // Cleanup on disconnect
    ws.on('close', () => {
      this.clients.get(projectId)?.delete(ws);
    });
  }

  private subscribeToEvents(): void {
    this.eventBus.subscribe('TaskCreated', async (event) => {
      this.broadcast(event.projectId, {
        type: 'task:created',
        data: event
      });
    });

    this.eventBus.subscribe('TaskMoved', async (event) => {
      this.broadcast(event.projectId, {
        type: 'task:moved',
        data: event
      });
    });

    this.eventBus.subscribe('CommentAdded', async (event) => {
      const task = await this.taskRepository.findById(event.taskId);
      this.broadcast(task!.taskProjectId, {
        type: 'comment:added',
        data: event
      });
    });
  }

  private broadcast(projectId: string, message: any): void {
    const clients = this.clients.get(projectId);
    if (!clients) return;

    const payload = JSON.stringify(message);
    clients.forEach(client => {
      if (client.readyState === WebSocket.OPEN) {
        client.send(payload);
      }
    });
  }
}

5. Infraestructura: Event Bus

// infrastructure/events/in-memory-event-bus.ts
type EventHandler = (event: any) => Promise<void>;

class InMemoryEventBus implements EventBus {
  private handlers: Map<string, EventHandler[]> = new Map();

  subscribe(eventType: string, handler: EventHandler): void {
    if (!this.handlers.has(eventType)) {
      this.handlers.set(eventType, []);
    }
    this.handlers.get(eventType)!.push(handler);
  }

  async publish(event: DomainEvent): Promise<void> {
    const handlers = this.handlers.get(event.type) || [];

    for (const handler of handlers) {
      try {
        await handler(event);
      } catch (error) {
        console.error(`Error handling event ${event.type}:`, error);
      }
    }
  }
}

interface DomainEvent {
  type: string;
  timestamp: Date;
  [key: string]: any;
}

6. Notificaciones

// infrastructure/services/notification.service.ts
class NotificationServiceImpl implements NotificationService {
  constructor(
    private emailService: EmailService,
    private pushService: PushNotificationService
  ) {}

  async notifyMention(data: MentionNotification): Promise<void> {
    // Email
    await this.emailService.send({
      to: data.userId,
      subject: 'Te mencionaron en una tarea',
      template: 'mention',
      data: {
        taskId: data.taskId,
        mentionedBy: data.mentionedBy
      }
    });

    // Push notification
    await this.pushService.send({
      userId: data.userId,
      title: 'Nueva mención',
      body: `${data.mentionedBy} te mencionó en una tarea`,
      data: { taskId: data.taskId }
    });
  }

  async notifyDueDateApproaching(data: DueDateNotification): Promise<void> {
    await this.emailService.send({
      to: data.assigneeId,
      subject: 'Tarea próxima a vencer',
      template: 'due-date',
      data: {
        taskId: data.taskId,
        dueDate: data.dueDate
      }
    });
  }
}

7. Búsqueda Full-text

// infrastructure/search/elasticsearch-task-search.ts
class ElasticsearchTaskSearch implements TaskSearchService {
  constructor(private client: Client) {}

  async indexTask(task: Task): Promise<void> {
    await this.client.index({
      index: 'tasks',
      id: task.taskId,
      document: {
        projectId: task.taskProjectId,
        title: task.taskTitle,
        description: task.taskDescription,
        priority: task.taskPriority.value,
        assigneeId: task.taskAssigneeId,
        columnId: task.taskColumnId
      }
    });
  }

  async search(query: string, projectId: string): Promise<TaskSearchResult[]> {
    const result = await this.client.search({
      index: 'tasks',
      body: {
        query: {
          bool: {
            must: [
              {
                multi_match: {
                  query,
                  fields: ['title^2', 'description']
                }
              },
              {
                term: { projectId }
              }
            ]
          }
        }
      }
    });

    return result.hits.hits.map(hit => ({
      taskId: hit._id!,
      title: (hit._source as any).title,
      score: hit._score!
    }));
  }
}

8. Despliegue

Docker Compose

version: '3.8'

services:
  api:
    build: .
    ports:
      - "3000:3000"
    environment:
      DATABASE_URL: postgresql://user:pass@postgres:5432/taskdb
      REDIS_URL: redis://redis:6379
      ELASTICSEARCH_URL: http://elasticsearch:9200
    depends_on:
      - postgres
      - redis
      - elasticsearch

  postgres:
    image: postgres:15-alpine
    environment:
      POSTGRES_DB: taskdb
      POSTGRES_USER: user
      POSTGRES_PASSWORD: pass
    volumes:
      - postgres_data:/var/lib/postgresql/data

  redis:
    image: redis:7-alpine
    volumes:
      - redis_data:/data

  elasticsearch:
    image: elasticsearch:8.8.0
    environment:
      - discovery.type=single-node
      - xpack.security.enabled=false
    volumes:
      - es_data:/usr/share/elasticsearch/data

volumes:
  postgres_data:
  redis_data:
  es_data:

Kubernetes (Producción)

apiVersion: apps/v1
kind: Deployment
metadata:
  name: task-api
spec:
  replicas: 3
  selector:
    matchLabels:
      app: task-api
  template:
    metadata:
      labels:
        app: task-api
    spec:
      containers:
      - name: api
        image: task-api:1.0.0
        ports:
        - containerPort: 3000
        env:
        - name: DATABASE_URL
          valueFrom:
            secretKeyRef:
              name: db-credentials
              key: url
        resources:
          requests:
            memory: "256Mi"
            cpu: "250m"
          limits:
            memory: "512Mi"
            cpu: "500m"

9. Monitoreo y Observabilidad

// infrastructure/monitoring/metrics.ts
class MetricsCollector {
  private registry = new Registry();

  constructor() {
    // Métricas de negocio
    new Gauge({
      name: 'tasks_total',
      help: 'Total de tareas',
      labelNames: ['projectId', 'status'],
      registers: [this.registry]
    });

    new Counter({
      name: 'tasks_created_total',
      help: 'Total de tareas creadas',
      registers: [this.registry]
    });

    new Histogram({
      name: 'task_completion_time_seconds',
      help: 'Tiempo de completar tareas',
      registers: [this.registry]
    });
  }

  getMetrics(): string {
    return this.registry.metrics();
  }
}

10. Conclusión

En este caso de estudio implementamos:

  1. Dominio Rico: Tareas, Proyectos, Comentarios con reglas complejas
  2. Eventos de Dominio: Para comunicación asíncrona
  3. Real-time: WebSockets para actualizaciones instantáneas
  4. Búsqueda: Elasticsearch para full-text search
  5. Notificaciones: Email y push notifications
  6. Despliegue: Docker y Kubernetes

Arquitectura final:

Métricas del sistema:

El sistema ha estado en producción durante 18 meses con excelente feedback de usuarios.

Lecciones Finales

Arquitectura Hexagonal nos dio:

Flexibilidad: Cambiar de Redis a RabbitMQ fue sencillo ✅ Testabilidad: 97% cobertura sin infraestructura ✅ Evolución: Agregar búsqueda sin tocar dominio ✅ Claridad: Nuevos devs productivos en 1 semana

Desafíos superados:

⚠️ Complejidad inicial: Invertir tiempo en diseño valió la pena ⚠️ Eventos: Debuggear flujos asíncronos requirió observabilidad ⚠️ Performance: Cachear agregados pesados fue necesario

Cierre del Tutorial

Has completado el tutorial de Arquitectura Hexagonal. Ahora tienes:

Próximos pasos:

  1. Implementa un proyecto pequeño con hexagonal
  2. Refactoriza código legacy gradualmente
  3. Comparte conocimiento con tu equipo
  4. Experimenta con event sourcing y CQRS

¡Éxito en tu viaje arquitectónico!

Glosario del Capítulo

Término (Inglés)Término (Español)Definición
Real-timeTiempo RealActualizaciones instantáneas sin polling
WebSocketWebSocketProtocolo de comunicación bidireccional
Event BusBus de EventosSistema de pub/sub para eventos de dominio
Full-text SearchBúsqueda de Texto CompletoBúsqueda avanzada en contenido
WIP LimitLímite de Trabajo en ProgresoRestricción de items en una columna

Referencias