Capítulo 4: Arquitectura Hexagonal y Screaming en Workers
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 comocreateUser
,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 comosave
,findById
). Son implementados por los adaptadores de infraestructura.
- Puertos Primarios/Entrantes (Driving Ports): Definen cómo el mundo exterior interactúa con el núcleo (ej.,
- 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 auserService.createUser
). - Adaptadores Secundarios/Salientes (Driven Adapters): Implementan los puertos secundarios usando tecnología concreta. (Ej:
DrizzleUserRepository
que implementaUserRepositoryPort
usando Drizzle y D1).
- 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:
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 deinfrastructure
.infrastructure
: Depende decore
(para implementar puertos y usar tipos), perocore
no depende deinfrastructure
.- La organización dentro de
core
sigue la Screaming Architecture (por featuretasks
,users
). - La ubicación del esquema Drizzle (
schema.ts
) es debatible; algunos lo ponen eninfrastructure/persistence
, otros encore/shared
si se considera parte del contrato de datos del dominio. Ponerlo eninfrastructure
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:
- Creamos una función
getTaskServiceInstance
que actúa como un Singleton simple. - Usamos un middleware Hono (
app.use('/api/*', ...)
). - Dentro del middleware, llamamos a
getTaskServiceInstance(c.env)
. - Adjuntamos la instancia al contexto Hono usando
c.set('taskService', service)
. - Importante: El
tasksController
(y otros) debe modificarse para obtenertaskService
desde el contexto:const taskService = c.get('taskService') as TaskServicePort;
. - Montamos el controlador con
app.route('/api/tasks', tasksController())
.
- Creamos una función
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 >