← Volver al listado de tecnologías

Capítulo 4: Arquitectura Hexagonal y Screaming en Workers

Por: Tu Nombre
cloudflareworkershonodrizzle-ormarchitecturehexagonalports-adaptersscreaming-architectureclean-architecturetestingrefactoring

Capítulo 4: Arquitectura Hexagonal y Screaming Architecture en Workers

A medida que nuestra aplicación crece, la estructura inicial donde Hono llama directamente a Drizzle puede volverse rígida y difícil de probar. En este capítulo, exploraremos cómo aplicar los principios de la Arquitectura Hexagonal (también conocida como Puertos y Adaptadores) y la Screaming Architecture para desacoplar nuestro núcleo de lógica de negocio de los detalles de infraestructura (como el framework web o el ORM) y organizar nuestro código por funcionalidades de negocio.

< Volver al Índice --- < Capítulo 3: D1 y Drizzle --- Capítulo 5: Seguridad >

Principios de Arquitectura Hexagonal (Puertos y Adaptadores)

La idea central es proteger el núcleo de la aplicación (la lógica de negocio pura, independiente de frameworks o bases de datos) de las influencias externas.

  • El Hexágono (Núcleo de la Aplicación): Contiene la lógica de negocio, casos de uso, entidades y reglas. No sabe nada sobre HTTP, bases de datos, o frameworks específicos.
  • Puertos: Son interfaces definidas por el núcleo. Hay dos tipos:
    • Puertos Primarios/Entrantes (Driving Ports): Definen cómo el mundo exterior interactúa con el núcleo (ej., UserServicePort con métodos como createUser, getUserById). Son implementados por los casos de uso dentro del núcleo.
    • Puertos Secundarios/Salientes (Driven Ports): Definen contratos que el núcleo necesita del mundo exterior para funcionar (ej., UserRepositoryPort con métodos como save, findById). Son implementados por los adaptadores de infraestructura.
  • Adaptadores: Son la “cola” que conecta los puertos con la tecnología específica.
    • Adaptadores Primarios/Entrantes (Driving Adapters): Convierten las interacciones externas (ej., una petición HTTP de Hono) en llamadas a los puertos primarios del núcleo. (Ej: HonoUserController que recibe una petición y llama a userService.createUser).
    • Adaptadores Secundarios/Salientes (Driven Adapters): Implementan los puertos secundarios usando tecnología concreta. (Ej: DrizzleUserRepository que implementa UserRepositoryPort usando Drizzle y D1).

Beneficios:

  • Desacoplamiento: El núcleo no depende de la infraestructura. Podemos cambiar Hono por otro framework o Drizzle/D1 por otra base de datos modificando solo los adaptadores.
  • Testeabilidad: El núcleo se puede probar de forma aislada, sin necesidad de levantar un servidor web o una base de datos real (usando mocks o dobles de prueba para los puertos secundarios).
  • Mantenibilidad: La lógica de negocio está claramente separada y protegida.

Principios de Screaming Architecture

Complementaria a la Hexagonal, la Screaming Architecture se enfoca en la organización del código. En lugar de organizar por capas técnicas (controllers, services, repositories), se organiza por features o dominios de negocio.\n\n* Organización por Feature/Dominio: La estructura de directorios “grita” lo que hace la aplicación, no la tecnología que usa. Por ejemplo, en lugar de src/controllers, src/services, tendríamos src/tasks (o src/features/tasks), src/users, etc.\n* Co-ubicación: Dentro de cada directorio de feature (src/tasks), encontraríamos todo lo relacionado con esa feature:\n * Su lógica de núcleo (casos de uso, entidades si aplica).\n * Sus puertos (interfaces).\n * Sus adaptadores primarios (ej., tasks.controller.ts o tasks.routes.ts para Hono).\n * Sus adaptadores secundarios (ej., tasks.repository.ts para Drizzle).\n * Sus pruebas específicas.\n

Beneficios:

  • Claridad: Es fácil encontrar todo lo relacionado con una funcionalidad específica.
  • Cohesión: El código que cambia junto, vive junto.
  • Escalabilidad Organizacional: Facilita el trabajo en equipo, ya que diferentes equipos pueden enfocarse en features distintas.

Estructura de Directorios Propuesta

Combinando ambos enfoques, una posible estructura para nuestro proyecto podría ser:

src/
├── core/                  # Lógica de negocio independiente (El Hexágono)
│   ├── tasks/             # Feature/Dominio: Tareas
│   │   ├── application/   # Casos de Uso / Servicios de Aplicación
│   │   │   └── task.service.ts
│   │   ├── domain/        # Entidades, Value Objects, Reglas de Dominio (si aplica)
│   │   │   └── task.entity.ts
│   │   └── ports/         # Interfaces definidas por el núcleo para esta feature
│   │       ├── in/        # Puertos Primarios (Entrantes)
│   │       │   └── task.service.port.ts
│   │       └── out/       # Puertos Secundarios (Salientes)
│   │           └── task.repository.port.ts
│   ├── users/             # Otra feature/dominio...
│   │   └── ...
│   └── shared/            # Lógica compartida entre features (con cuidado)
│       └── ...
├── infrastructure/        # Adaptadores y configuración de infraestructura
│   ├── web/               # Adaptadores Primarios (Hono)
│   │   ├── tasks.controller.ts # Adaptador que usa TaskServicePort
│   │   ├── users.controller.ts
│   │   └── middleware/      # Middlewares de Hono (CORS, Logging, etc.)
│   ├── persistence/       # Adaptadores Secundarios (Drizzle/D1)
│   │   ├── drizzle/         # Configuración específica de Drizzle
│   │   │   ├── schema.ts    # Esquema Drizzle (puede vivir aquí o en core/shared?)
│   │   │   └── index.ts     # Función getDb
│   │   ├── tasks.repository.ts # Implementación de TaskRepositoryPort con Drizzle
│   │   └── users.repository.ts
│   └── config/            # Configuración general (ej: inyección de dependencias)
│       └── dependencies.ts
├── index.ts               # Punto de entrada principal (configura Hono, monta rutas/controllers)
test/
├── core/
│   └── tasks/
│       └── application/
│           └── task.service.spec.ts # Prueba unitaria del servicio (mockea repo)
├── infrastructure/
│   ├── web/
│   │   └── tasks.controller.integration.spec.ts # Prueba de integración (usa app.fetch)
│   └── persistence/
│       └── tasks.repository.integration.spec.ts # Prueba de integración del repo (usa D1 real/test)
├── e2e/                   # Pruebas End-to-End
│   └── tasks.e2e.spec.ts
drizzle/                   # Migraciones generadas por Drizzle Kit
drizzle.config.ts
wrangler.toml
package.json
vitest.config.ts
...

Notas sobre la Estructura:

  • core: Totalmente independiente de infrastructure.
  • infrastructure: Depende de core (para implementar puertos y usar tipos), pero core no depende de infrastructure.
  • La organización dentro de core sigue la Screaming Architecture (por feature tasks, users).
  • La ubicación del esquema Drizzle (schema.ts) es debatible; algunos lo ponen en infrastructure/persistence, otros en core/shared si se considera parte del contrato de datos del dominio. Ponerlo en infrastructure refuerza la independencia del núcleo.
  • index.ts: Actúa como el compositor principal, instanciando adaptadores, inyectando dependencias y configurando Hono.

Refactorización: Separando el Núcleo de Hono y Drizzle

Ilustremos cómo refactorizar la lógica de tareas (que implementaremos conceptualmente aquí, y en detalle en el próximo capítulo si mantenemos el CRUD).

1. Definir Puertos (src/core/tasks/ports/):

// src/core/tasks/ports/out/task.repository.port.ts
import type { Task, NewTask } from '../../domain/task.entity'; // Asumiendo entidad definida

export interface TaskRepositoryPort {
  findAll(): Promise<Task[]>
  findById(id: number): Promise<Task | null>
  create(newTask: NewTask): Promise<Task>
  update(id: number, taskData: Partial<NewTask>): Promise<Task | null>
  delete(id: number): Promise<boolean>
}

// src/core/tasks/ports/in/task.service.port.ts
import type { Task, NewTask } from '../../domain/task.entity'

export interface TaskServicePort {
  getAllTasks(): Promise<Task[]>
  getTaskById(id: number): Promise<Task | null>
  createTask(taskData: NewTask): Promise<Task>
  updateTask(id: number, taskData: Partial<NewTask>): Promise<Task | null>
  deleteTask(id: number): Promise<boolean>
}

2. Implementar Caso de Uso/Servicio (src/core/tasks/application/task.service.ts):

// src/core/tasks/application/task.service.ts
import type { Task, NewTask } from '../domain/task.entity'
import type { TaskRepositoryPort } from '../ports/out/task.repository.port'
import type { TaskServicePort } from '../ports/in/task.service.port'

// Implementa el puerto entrante, USA el puerto saliente (inyectado)
export class TaskService implements TaskServicePort {
  constructor(private taskRepository: TaskRepositoryPort) {} // Inyección de dependencia

  async getAllTasks(): Promise<Task[]> {
    console.log('[Core Service] Getting all tasks')
    return this.taskRepository.findAll()
  }

  async getTaskById(id: number): Promise<Task | null> {
    console.log(`[Core Service] Getting task by id: ${id}`)
    if (id <= 0) throw new Error('Invalid ID') // Lógica de negocio
    return this.taskRepository.findById(id)
  }

  async createTask(taskData: NewTask): Promise<Task> {
    console.log('[Core Service] Creating task:', taskData)
    // Aquí podría ir lógica de negocio adicional antes de guardar
    if (!taskData.title) throw new Error('Title is required')
    return this.taskRepository.create(taskData)
  }

  async updateTask(id: number, taskData: Partial<NewTask>): Promise<Task | null> {
    console.log(`[Core Service] Updating task ${id}:`, taskData)
    if (id <= 0) throw new Error('Invalid ID')
    // Podría validar que taskData no esté vacío, etc.
    return this.taskRepository.update(id, taskData)
  }

  async deleteTask(id: number): Promise<boolean> {
    console.log(`[Core Service] Deleting task ${id}`)
    if (id <= 0) throw new Error('Invalid ID')
    return this.taskRepository.delete(id)
  }
}

Este servicio contiene la lógica pura, sin saber nada de Drizzle o Hono. Depende de la interfaz TaskRepositoryPort.

3. Implementar Adaptador de Persistencia (src/infrastructure/persistence/tasks.repository.ts):

// src/infrastructure/persistence/tasks.repository.ts
import type { D1Database } from '@cloudflare/workers-types'
import { getDb, schema } from './drizzle' // Asume que getDb y schema están aquí
import type { Task, NewTask } from '../../core/tasks/domain/task.entity'
import type { TaskRepositoryPort } from '../../core/tasks/ports/out/task.repository.port'
import { eq } from 'drizzle-orm'

// Implementa el puerto saliente usando Drizzle y D1
export class DrizzleTaskRepository implements TaskRepositoryPort {
  private db

  constructor(d1: D1Database) {
    this.db = getDb(d1) // Obtiene la instancia Drizzle
  }

  async findAll(): Promise<Task[]> {
    console.log('[Drizzle Repo] Finding all tasks')
    return this.db.select().from(schema.tasks).all()
  }

  async findById(id: number): Promise<Task | null> {
    console.log(`[Drizzle Repo] Finding task by id: ${id}`)
    const result = await this.db.select().from(schema.tasks).where(eq(schema.tasks.id, id)).get()
    return result ?? null // Drizzle .get() devuelve undefined si no encuentra
  }

  async create(newTask: NewTask): Promise<Task> {
    console.log('[Drizzle Repo] Creating task:', newTask)
    // .returning() en D1 devuelve el objeto insertado
    const result = await this.db.insert(schema.tasks).values(newTask).returning().get()
    if (!result) throw new Error("Failed to create task and get returning value.") // Manejo de error
    return result
  }

  async update(id: number, taskData: Partial<NewTask>): Promise<Task | null> {
    console.log(`[Drizzle Repo] Updating task ${id}:`, taskData)
    const result = await this.db.update(schema.tasks)
      .set(taskData)
      .where(eq(schema.tasks.id, id))
      .returning()
      .get()
    return result ?? null
  }

  async delete(id: number): Promise<boolean> {
    console.log(`[Drizzle Repo] Deleting task ${id}`)
    const result = await this.db.delete(schema.tasks).where(eq(schema.tasks.id, id)).run()
    return result.success && result.meta.changes > 0
  }
}

Este adaptador implementa la interfaz TaskRepositoryPort usando Drizzle.

4. Implementar Adaptador Web (src/infrastructure/web/tasks.controller.ts):

// src/infrastructure/web/tasks.controller.ts
import { Hono } from 'hono'
import type { Env } from '../../index' // Tipos de entorno (con DB binding)
import type { TaskServicePort } from '../../core/tasks/ports/in/task.service.port'
// Aquí necesitaríamos un mecanismo de Inyección de Dependencias o pasar el servicio
// import { taskService } from '../config/dependencies' // Ejemplo ID

// Esta función crea y devuelve la sub-app Hono para tareas
export const tasksController = (taskService: TaskServicePort): Hono<{ Bindings: Env }> => {
  const tasksApp = new Hono<{ Bindings: Env }>()

  // GET / (relativo a donde se monte, ej /tasks)
  tasksApp.get('/', async (c) => {
    try {
      console.log('[Hono Controller] GET /tasks')
      const tasks = await taskService.getAllTasks() // Llama al puerto del núcleo
      return c.json(tasks)
    } catch (error: any) {
      console.error('[Hono Controller] Error GET /tasks:', error)
      return c.json({ error: 'Failed to retrieve tasks', details: error.message }, 500)
    }
  })

  // GET /:id
  tasksApp.get('/:id', async (c) => {
    const id = parseInt(c.req.param('id'), 10)
    if (isNaN(id)) return c.json({ error: 'Invalid ID format' }, 400)
    try {
      console.log(`[Hono Controller] GET /tasks/${id}`)
      const task = await taskService.getTaskById(id)
      if (!task) return c.json({ error: 'Task not found' }, 404)
      return c.json(task)
    } catch (error: any) {
      console.error(`[Hono Controller] Error GET /tasks/${id}:`, error)
      // El servicio podría lanzar errores específicos que mapeamos a códigos HTTP
      if (error.message === 'Invalid ID') return c.json({ error: error.message }, 400)
      return c.json({ error: 'Failed to retrieve task', details: error.message }, 500)
    }
  })

  // POST /
  tasksApp.post('/', /* Aquí iría el zValidator si se usa */ async (c) => {
    try {
      // const taskData = c.req.valid('json') // Si usa zValidator
      const taskData = await c.req.json() // O parseo manual + validación
      console.log('[Hono Controller] POST /tasks', taskData)
      // Aquí faltaría validación robusta del taskData antes de pasarlo al servicio
      const newTask = await taskService.createTask(taskData)
      return c.json(newTask, 201) // 201 Created
    } catch (error: any) {
      console.error('[Hono Controller] Error POST /tasks:', error)
      if (error.message === 'Title is required') return c.json({ error: error.message }, 400)
      return c.json({ error: 'Failed to create task', details: error.message }, 500)
    }
  })

  // PUT /:id, DELETE /:id ... implementarían lógica similar llamando a taskService

  return tasksApp
}

Este adaptador recibe peticiones Hono, las traduce, llama al taskService (el puerto primario) y convierte la respuesta del núcleo en una respuesta HTTP. Nota la necesidad de inyectar taskService.

5. Composición en src/index.ts (Inyección Manual Simple):

El punto de entrada src/index.ts es donde conectamos todas las piezas. Instanciaremos nuestros adaptadores y servicios y los conectaremos. Dada la naturaleza de los Workers, un patrón Singleton simple para los servicios suele ser eficiente.

  • Instanciación y Montaje:

    // src/index.ts
    import { Hono } from 'hono';
    import { DrizzleTaskRepository } from './infrastructure/persistence/tasks.repository'; // Adaptador Drizzle
    import { TaskService } from './core/tasks/application/task.service';           // Servicio del Núcleo
    import { tasksController } from './infrastructure/web/tasks.controller';        // Controlador Hono
    // Middlewares
    import { cors } from 'hono/cors'; // Ejemplo
    // Importar otros controladores si existen (ej: usersController)
    import type { TaskServicePort } from './core/tasks/ports/in/task.service.port'; // Importar tipo de servicio
    import type { D1Database } from '@cloudflare/workers-types'; // Importar tipo D1
    
    export interface Env {
      DB: D1Database;
      // ... otros bindings/secrets ...
    }
    
    // Ajustar tipo Hono para incluir Variables para DI en el contexto
    const app = new Hono<{ Bindings: Env, Variables: { taskService: TaskServicePort } }>();
    
    // --- Gestión de Dependencias Singleton Simple ---
    let taskServiceInstance: TaskService | null = null;
    
    function getTaskServiceInstance(env: Env): TaskService {
      if (!taskServiceInstance) {
        console.log("Instanciando dependencias de Tareas por primera vez...");
        const taskRepository = new DrizzleTaskRepository(env.DB); // Crear repo con el binding D1
        taskServiceInstance = new TaskService(taskRepository);    // Inyectar repo en el servicio
      }
      return taskServiceInstance;
    }
    // --- Fin Gestión de Dependencias ---
    
    
    // --- Middlewares Globales ---
    // Logging (Ejemplo simple)
    app.use('*', async (c, next) => {
      const start = Date.now();
      await next();
      const ms = Date.now() - start;
      console.log(`${c.req.method} ${c.req.url} - ${c.res.status} [${ms}ms]`);
      // Añadir Headers de Seguridad aquí si se desea
      c.res.headers.set('X-Content-Type-Options', 'nosniff');
    });
    
    // CORS (Ejemplo restrictivo para /api/*)
    app.use('/api/*', cors({
      origin: 'https://tu-frontend.com', // Cambiar por tu origen real
      allowMethods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
    }));
    
    // Middleware para adjuntar el servicio al contexto (Recomendado)
    app.use('/api/*', async (c, next) => {
        const service = getTaskServiceInstance(c.env);
        c.set('taskService', service); // Adjuntar al contexto
        await next();
    });
    // --- Fin Middlewares ---
    
    
    // --- Montaje de Rutas/Controllers ---
    // El controlador ahora usa el contexto Hono (c.get) para obtener el servicio.
    // Modificar tasks.controller.ts para que NO reciba taskService como argumento.
    app.route('/api/tasks', tasksController()); // tasksController ahora usa c.get('taskService')
    
    // app.route('/api/users', usersController()); // Para otros módulos...
    // --- Fin Montaje de Rutas ---
    
    
    app.notFound((c) => c.json({ error: 'Not Found' }, 404));
    
    // Exportar el manejador fetch de la app Hono principal
    export default {
      fetch: app.fetch,
    };

    En este enfoque simplificado:

    1. Creamos una función getTaskServiceInstance que actúa como un Singleton simple.
    2. Usamos un middleware Hono (app.use('/api/*', ...)).
    3. Dentro del middleware, llamamos a getTaskServiceInstance(c.env).
    4. Adjuntamos la instancia al contexto Hono usando c.set('taskService', service).
    5. Importante: El tasksController (y otros) debe modificarse para obtener taskService desde el contexto: const taskService = c.get('taskService') as TaskServicePort;.
    6. Montamos el controlador con app.route('/api/tasks', tasksController()).

Nota sobre Logging en Cloudflare Workers:

Te preguntas sobre librerías como Winston o Pino. En general, no se recomiendan ni funcionan directamente en el entorno estándar de Workers porque dependen de APIs específicas de Node.js (como acceso al sistema de archivos) que no están disponibles.

La forma recomendada y nativa de loguear en Workers es usar las funciones estándar de la consola:

  • console.log(), console.info(), console.warn(), console.error()

Estos logs se pueden ver con wrangler tail durante el desarrollo y se envían al sistema de logs de Cloudflare en producción (visibles en el Dashboard).

Para logs más estructurados (útil si envías logs a sistemas externos), usa JSON:

console.log(JSON.stringify({
  level: 'info',
  message: 'Tarea creada',
  taskId: 123,
  requestId: c.req.header('cf-request-id')
}));

Puedes configurar Cloudflare Logpush para enviar estos logs automáticamente a servicios de almacenamiento o análisis compatibles. Algunos servicios de terceros (ej: Sentry, Logflare) ofrecen SDKs ligeros específicos para Workers, pero console.log (idealmente estructurado) es el punto de partida fundamental.

Testing: Probando el Núcleo de Forma Aislada

La gran ventaja ahora es que podemos probar TaskService sin Hono ni Drizzle.

// test/core/tasks/application/task.service.spec.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { TaskService } from '../../../../src/core/tasks/application/task.service'
import type { TaskRepositoryPort } from '../../../../src/core/tasks/ports/out/task.repository.port'
import type { Task, NewTask } from '../../../../src/core/tasks/domain/task.entity'

// Crear un Mock del Repositorio usando vi.fn()
const mockTaskRepository: TaskRepositoryPort = {
  findAll: vi.fn(),
  findById: vi.fn(),
  create: vi.fn(),
  update: vi.fn(),
  delete: vi.fn(),
}

// Instancia del servicio bajo prueba, inyectando el mock
const taskService = new TaskService(mockTaskRepository)

describe('TaskService (Core Logic)', () => {

  // Resetear mocks antes de cada prueba
  beforeEach(() => {
    vi.resetAllMocks()
  })

  it('getAllTasks debería llamar a repository.findAll', async () => {
    const mockTasks: Task[] = [{ id: 1, title: 'Test Task', completed: false, createdAt: Date.now(), updatedAt: null }]
    mockTaskRepository.findAll.mockResolvedValue(mockTasks) // Configurar respuesta del mock

    const result = await taskService.getAllTasks()

    expect(mockTaskRepository.findAll).toHaveBeenCalledTimes(1) // Verificar llamada al mock
    expect(result).toEqual(mockTasks) // Verificar resultado
  })

  it('getTaskById debería llamar a repository.findById con el id correcto', async () => {
    const mockTask: Task = { id: 5, title: 'Specific Task', completed: false, createdAt: Date.now(), updatedAt: null }
    mockTaskRepository.findById.mockResolvedValue(mockTask)

    const result = await taskService.getTaskById(5)

    expect(mockTaskRepository.findById).toHaveBeenCalledTimes(1)
    expect(mockTaskRepository.findById).toHaveBeenCalledWith(5) // Verificar argumento
    expect(result).toEqual(mockTask)
  })

  it('getTaskById debería lanzar error si el ID es inválido', async () => {
    await expect(taskService.getTaskById(0)).rejects.toThrow('Invalid ID')
    expect(mockTaskRepository.findById).not.toHaveBeenCalled() // No debería llamar al repo
  })

  it('createTask debería llamar a repository.create con los datos correctos', async () => {
    const newTaskData: NewTask = { title: 'New Core Task', description: 'Desc' }
    const createdTask: Task = { id: 10, completed: false, createdAt: Date.now(), updatedAt: null, ...newTaskData }
    mockTaskRepository.create.mockResolvedValue(createdTask)

    const result = await taskService.createTask(newTaskData)

    expect(mockTaskRepository.create).toHaveBeenCalledTimes(1)
    expect(mockTaskRepository.create).toHaveBeenCalledWith(newTaskData)
    expect(result).toEqual(createdTask)
  })

  it('createTask debería lanzar error si el título falta', async () => {
    const invalidData: NewTask = { description: 'No title' }
    await expect(taskService.createTask(invalidData)).rejects.toThrow('Title is required')
    expect(mockTaskRepository.create).not.toHaveBeenCalled()
  })

  // ... más pruebas para update, delete, casos de borde, etc. ...

})

Estas pruebas son rápidas y se centran puramente en la lógica de negocio dentro de TaskService, usando un repositorio mockeado.


Hemos explorado cómo aplicar la Arquitectura Hexagonal y Screaming a nuestro proyecto Worker. Esto establece una base sólida y desacoplada para el crecimiento futuro. Separar el núcleo de la infraestructura mejora drásticamente la testeabilidad y la mantenibilidad. En el próximo capítulo, nos enfocaremos en implementar la seguridad, como la autenticación y autorización, aprovechando esta nueva estructura.

< Volver al Índice --- < Capítulo 3: D1 y Drizzle --- Capítulo 5: Seguridad >