← Volver al listado de tecnologías
Capítulo 9: Refactoring hacia Hexagonal
Capítulo 9: Refactoring hacia Hexagonal
Introducción
Migrar código legacy hacia arquitectura hexagonal es un proceso incremental. En este capítulo aprenderás estrategias prácticas para refactorizar código existente sin romper funcionalidad.
1. Identificar Código Legacy
Señales de Código que Necesita Refactoring
// ❌ Código Legacy típico
class UserController {
async createUser(req: Request, res: Response) {
try {
// 1. Validación mezclada con lógica
if (!req.body.email || !req.body.email.includes('@')) {
return res.status(400).json({ error: 'Email inválido' });
}
// 2. Acceso directo a BD (sin abstracción)
const existingUser = await db.query(
'SELECT * FROM users WHERE email = $1',
[req.body.email]
);
if (existingUser.rows.length > 0) {
return res.status(400).json({ error: 'Email ya existe' });
}
// 3. Lógica de negocio en el controlador
const hashedPassword = await bcrypt.hash(req.body.password, 10);
// 4. SQL directo
const result = await db.query(
'INSERT INTO users (id, email, name, password) VALUES ($1, $2, $3, $4) RETURNING *',
[crypto.randomUUID(), req.body.email, req.body.name, hashedPassword]
);
// 5. Envío de email mezclado
await sendEmail({
to: req.body.email,
subject: 'Bienvenido',
body: `Hola ${req.body.name}`
});
return res.status(201).json(result.rows[0]);
} catch (error) {
return res.status(500).json({ error: 'Error interno' });
}
}
}
Problemas identificados:
- ❌ Controlador con múltiples responsabilidades
- ❌ Lógica de negocio acoplada a infraestructura
- ❌ Imposible testear sin BD real
- ❌ Dependencias ocultas
- ❌ Sin separación de capas
2. Estrategia de Migración Incremental
Principio: Strangler Fig Pattern
No reescribir todo desde cero. Refactorizar incrementalmente:
Legacy Code → [Capa de Abstracción] → Nueva Arquitectura
↓ ↑
(Viejo) (Nuevo)
↓ ↑
└──────── Migrar poco a poco ────────────┘
Fases de Migración
- Extraer Repositorio (Semana 1-2)
- Crear Casos de Uso (Semana 2-3)
- Modelar Dominio (Semana 3-4)
- Adaptar Controladores (Semana 4)
- Tests y Limpieza (Semana 5)
3. Paso 1: Extraer Repositorio
Antes: SQL Directo
class UserController {
async createUser(req: Request, res: Response) {
const result = await db.query(
'INSERT INTO users (id, email, name, password) VALUES ($1, $2, $3, $4) RETURNING *',
[crypto.randomUUID(), req.body.email, req.body.name, hashedPassword]
);
}
}
Después: Con Repositorio
// 1. Crear interface de puerto
interface UserRepository {
save(user: UserData): Promise<void>;
findByEmail(email: string): Promise<UserData | null>;
}
interface UserData {
id: string;
email: string;
name: string;
password: string;
}
// 2. Implementar adaptador
class PostgresUserRepository implements UserRepository {
constructor(private db: any) {}
async save(user: UserData): Promise<void> {
await this.db.query(
'INSERT INTO users (id, email, name, password) VALUES ($1, $2, $3, $4)',
[user.id, user.email, user.name, user.password]
);
}
async findByEmail(email: string): Promise<UserData | null> {
const result = await this.db.query(
'SELECT * FROM users WHERE email = $1',
[email]
);
return result.rows[0] || null;
}
}
// 3. Inyectar en controlador
class UserController {
constructor(private userRepository: UserRepository) {}
async createUser(req: Request, res: Response) {
// Ahora usa el repositorio
const existing = await this.userRepository.findByEmail(req.body.email);
if (existing) {
return res.status(400).json({ error: 'Email ya existe' });
}
await this.userRepository.save({
id: crypto.randomUUID(),
email: req.body.email,
name: req.body.name,
password: hashedPassword
});
}
}
Beneficios inmediatos:
- ✅ SQL encapsulado
- ✅ Fácil de mockear
- ✅ Testeable sin BD
4. Paso 2: Crear Casos de Uso
Antes: Lógica en Controlador
class UserController {
constructor(private userRepository: UserRepository) {}
async createUser(req: Request, res: Response) {
// Validación
if (!req.body.email.includes('@')) {
return res.status(400).json({ error: 'Email inválido' });
}
// Lógica de negocio
const existing = await this.userRepository.findByEmail(req.body.email);
if (existing) {
return res.status(400).json({ error: 'Email ya existe' });
}
const hashedPassword = await bcrypt.hash(req.body.password, 10);
await this.userRepository.save({
id: crypto.randomUUID(),
email: req.body.email,
name: req.body.name,
password: hashedPassword
});
await sendEmail(req.body.email, 'Bienvenido');
return res.status(201).json({ message: 'Usuario creado' });
}
}
Después: Con Caso de Uso
// 1. Definir puerto primario
interface CreateUserCommand {
email: string;
name: string;
password: string;
}
interface CreateUserUseCase {
execute(command: CreateUserCommand): Promise<{ userId: string }>;
}
// 2. Implementar caso de uso
class CreateUserUseCaseImpl implements CreateUserUseCase {
constructor(
private userRepository: UserRepository,
private passwordHasher: PasswordHasher,
private emailService: EmailService
) {}
async execute(command: CreateUserCommand): Promise<{ userId: string }> {
// Validación
if (!command.email.includes('@')) {
throw new Error('Email inválido');
}
// Verificar duplicado
const existing = await this.userRepository.findByEmail(command.email);
if (existing) {
throw new Error('Email ya existe');
}
// Hash password
const hashedPassword = await this.passwordHasher.hash(command.password);
// Guardar
const userId = crypto.randomUUID();
await this.userRepository.save({
id: userId,
email: command.email,
name: command.name,
password: hashedPassword
});
// Email bienvenida
await this.emailService.sendWelcome(command.email, command.name);
return { userId };
}
}
// 3. Simplificar controlador
class UserController {
constructor(private createUser: CreateUserUseCase) {}
async createUser(req: Request, res: Response) {
try {
const result = await this.createUser.execute({
email: req.body.email,
name: req.body.name,
password: req.body.password
});
return res.status(201).json(result);
} catch (error) {
if (error instanceof Error) {
return res.status(400).json({ error: error.message });
}
return res.status(500).json({ error: 'Error interno' });
}
}
}
Beneficios:
- ✅ Lógica de negocio aislada
- ✅ Controlador delgado (thin controller)
- ✅ Testeable con mocks
5. Paso 3: Modelar Dominio
Antes: Datos Primitivos
interface UserData {
id: string;
email: string;
name: string;
password: string;
}
// Sin validación ni invariantes
await userRepository.save({
id: '123',
email: 'invalid', // ❌ No validado
name: '', // ❌ Puede estar vacío
password: 'weak' // ❌ Sin restricciones
});
Después: Con Entidad y VOs
// 1. Value Objects
class Email {
private constructor(private readonly value: string) {}
static create(email: string): Email {
if (!email.includes('@') || !email.includes('.')) {
throw new Error('Email inválido');
}
return new Email(email.toLowerCase());
}
get emailValue(): string {
return this.value;
}
}
class Name {
private constructor(private readonly value: string) {}
static create(name: string): Name {
if (!name || name.trim().length === 0) {
throw new Error('Nombre no puede estar vacío');
}
if (name.length > 100) {
throw new Error('Nombre muy largo');
}
return new Name(name.trim());
}
get nameValue(): string {
return this.value;
}
}
// 2. Entidad con invariantes
class User {
private constructor(
private readonly id: string,
private readonly email: Email,
private readonly name: Name,
private readonly hashedPassword: string,
private status: 'active' | 'suspended'
) {}
static create(
id: string,
email: Email,
name: Name,
hashedPassword: string
): User {
return new User(id, email, name, hashedPassword, 'active');
}
static reconstitute(
id: string,
email: Email,
name: Name,
hashedPassword: string,
status: 'active' | 'suspended'
): User {
return new User(id, email, name, hashedPassword, status);
}
suspend(): void {
if (this.status === 'suspended') {
throw new Error('Usuario ya suspendido');
}
this.status = 'suspended';
}
get userId(): string { return this.id; }
get userEmail(): string { return this.email.emailValue; }
get userName(): string { return this.name.nameValue; }
get userStatus(): string { return this.status; }
get password(): string { return this.hashedPassword; }
}
// 3. Actualizar repositorio
interface UserRepository {
save(user: User): Promise<void>;
findByEmail(email: string): Promise<User | null>;
}
class PostgresUserRepository implements UserRepository {
constructor(private db: any) {}
async save(user: User): Promise<void> {
await this.db.query(
'INSERT INTO users (id, email, name, password, status) VALUES ($1, $2, $3, $4, $5)',
[user.userId, user.userEmail, user.userName, user.password, user.userStatus]
);
}
async findByEmail(email: string): Promise<User | null> {
const result = await this.db.query(
'SELECT * FROM users WHERE email = $1',
[email]
);
if (result.rows.length === 0) return null;
const row = result.rows[0];
return User.reconstitute(
row.id,
Email.create(row.email),
Name.create(row.name),
row.password,
row.status
);
}
}
// 4. Actualizar caso de uso
class CreateUserUseCaseImpl implements CreateUserUseCase {
constructor(
private userRepository: UserRepository,
private passwordHasher: PasswordHasher,
private emailService: EmailService
) {}
async execute(command: CreateUserCommand): Promise<{ userId: string }> {
// Crear VOs (validan automáticamente)
const email = Email.create(command.email);
const name = Name.create(command.name);
// Verificar duplicado
const existing = await this.userRepository.findByEmail(email.emailValue);
if (existing) {
throw new Error('Email ya existe');
}
// Hash password
const hashedPassword = await this.passwordHasher.hash(command.password);
// Crear entidad (con invariantes)
const user = User.create(
crypto.randomUUID(),
email,
name,
hashedPassword
);
// Guardar
await this.userRepository.save(user);
// Email bienvenida
await this.emailService.sendWelcome(email.emailValue, name.nameValue);
return { userId: user.userId };
}
}
Beneficios:
- ✅ Validación en construcción
- ✅ Invariantes garantizadas
- ✅ Lógica de negocio en entidad
6. Refactoring de Sistema Completo
Ejemplo: API REST con Express
Antes:
// server.ts - Todo mezclado
import express from 'express';
import pg from 'pg';
import bcrypt from 'bcrypt';
const app = express();
const db = new pg.Pool({ connectionString: process.env.DATABASE_URL });
app.post('/users', async (req, res) => {
const { email, name, password } = req.body;
const existing = await db.query('SELECT * FROM users WHERE email = $1', [email]);
if (existing.rows.length > 0) {
return res.status(400).json({ error: 'Email existe' });
}
const hashed = await bcrypt.hash(password, 10);
await db.query(
'INSERT INTO users (id, email, name, password) VALUES ($1, $2, $3, $4)',
[crypto.randomUUID(), email, name, hashed]
);
res.status(201).json({ message: 'Usuario creado' });
});
app.listen(3000);
Después:
// Estructura de directorios
src/
├── domain/
│ └── user/
│ ├── user.entity.ts
│ ├── email.vo.ts
│ └── name.vo.ts
├── application/
│ └── user/
│ ├── ports/
│ │ ├── input/
│ │ │ └── create-user.port.ts
│ │ └── output/
│ │ ├── user-repository.port.ts
│ │ └── password-hasher.port.ts
│ └── use-cases/
│ └── create-user.usecase.ts
├── infrastructure/
│ ├── adapters/
│ │ ├── primary/
│ │ │ └── api/
│ │ │ └── user.controller.ts
│ │ └── secondary/
│ │ ├── repositories/
│ │ │ └── postgres-user.repository.ts
│ │ └── services/
│ │ └── bcrypt-password-hasher.ts
│ ├── composition/
│ │ └── user.composition.ts
│ └── server.ts
// server.ts - Limpio
import express from 'express';
import { createUserModule } from './composition/user.composition';
const app = express();
app.use(express.json());
const userModule = createUserModule();
app.post('/users', (req, res) =>
userModule.userController.createUser(req, res)
);
app.listen(3000);
7. Estrategias Prácticas
Técnica: Feature Toggle
Permite convivir código viejo y nuevo:
// Gradual rollout
class UserController {
constructor(
private oldService: OldUserService,
private newUseCase: CreateUserUseCase
) {}
async createUser(req: Request, res: Response) {
const useNewVersion = process.env.USE_NEW_ARCHITECTURE === 'true';
if (useNewVersion) {
// Nueva arquitectura hexagonal
const result = await this.newUseCase.execute(req.body);
return res.status(201).json(result);
} else {
// Código legacy
const result = await this.oldService.createUser(req.body);
return res.status(201).json(result);
}
}
}
Técnica: Parallel Run
Ejecutar ambas versiones y comparar:
async createUser(req: Request, res: Response) {
// Ejecutar viejo
const oldResult = await this.oldService.createUser(req.body);
try {
// Ejecutar nuevo (sin efectos)
const newResult = await this.newUseCase.execute(req.body);
// Comparar resultados
if (!deepEqual(oldResult, newResult)) {
logger.warn('Diferencia entre viejo y nuevo', { oldResult, newResult });
}
} catch (error) {
logger.error('Error en nueva versión', error);
}
// Retornar resultado viejo (confiable)
return res.status(201).json(oldResult);
}
Técnica: Adaptar Legacy como Puerto
// Legacy envuelto como adaptador
class LegacyUserServiceAdapter implements UserRepository {
constructor(private legacyService: OldUserService) {}
async save(user: User): Promise<void> {
// Traducir del dominio al legacy
await this.legacyService.createUserInDatabase({
id: user.userId,
email: user.userEmail,
name: user.userName
});
}
async findByEmail(email: string): Promise<User | null> {
const legacyUser = await this.legacyService.getUserByEmail(email);
if (!legacyUser) return null;
// Traducir del legacy al dominio
return User.reconstitute(
legacyUser.id,
Email.create(legacyUser.email),
Name.create(legacyUser.name),
legacyUser.password,
'active'
);
}
}
8. Checklist de Refactoring
Fase 1: Preparación
- Identificar bounded context
- Agregar tests de regresión
- Documentar comportamiento actual
- Definir métricas de éxito
Fase 2: Extracción
- Extraer repositorios
- Extraer servicios externos
- Crear interfaces (puertos)
- Tests de adaptadores
Fase 3: Casos de Uso
- Crear puertos primarios
- Implementar casos de uso
- Mover validación a casos de uso
- Tests de casos de uso
Fase 4: Dominio
- Identificar entidades
- Crear value objects
- Mover invariantes a dominio
- Tests de dominio
Fase 5: Limpieza
- Eliminar código muerto
- Actualizar documentación
- Verificar cobertura de tests
- Code review
9. Antipatrones Comunes
❌ Antipatrón: Big Bang Rewrite
// ❌ MAL: Reescribir todo de una vez
// Riesgo: 6 meses sin deploys, funcionalidad rota
// ✅ BIEN: Migrar módulo por módulo
// 1. Migrar Users
// 2. Migrar Orders
// 3. Migrar Products
❌ Antipatrón: Anemic Domain
// ❌ MAL: "Entidad" sin lógica
class User {
id: string;
email: string;
name: string;
}
// Lógica fuera
function suspendUser(user: User) {
user.status = 'suspended';
}
// ✅ BIEN: Lógica en la entidad
class User {
suspend(): void {
if (this.status === 'suspended') {
throw new Error('Ya suspendido');
}
this.status = 'suspended';
}
}
❌ Antipatrón: Leaky Abstraction
// ❌ MAL: Repositorio que expone detalles de BD
interface UserRepository {
executeQuery(sql: string): Promise<any>; // ¡Leaky!
}
// ✅ BIEN: Abstracción pura
interface UserRepository {
save(user: User): Promise<void>;
findById(id: string): Promise<User | null>;
}
10. Conclusión
En este capítulo aprendiste:
- Identificar Legacy: Señales de código que necesita refactoring
- Strangler Fig: Migración incremental sin big bang
- Pasos Incrementales: Repo → Casos de Uso → Dominio
- Técnicas Prácticas: Feature toggles, parallel run, adapters
- Antipatrones: Qué evitar durante la migración
Principios clave:
- ✅ Migrar incrementalmente (módulo por módulo)
- ✅ Mantener tests en verde siempre
- ✅ Usar técnicas de convivencia (toggles)
- ✅ Validar con métricas
- ✅ No caer en big bang rewrite
En el próximo capítulo veremos un caso de estudio completo: sistema de e-commerce.
Glosario del Capítulo
| Término (Inglés) | Término (Español) | Definición |
|---|---|---|
| Legacy Code | Código Legacy | Código antiguo difícil de mantener |
| Refactoring | Refactorización | Mejorar estructura sin cambiar comportamiento |
| Strangler Fig | Higuera Estranguladora | Patrón de migración incremental |
| Feature Toggle | Conmutador de Característica | Switch para activar/desactivar código |
| Parallel Run | Ejecución Paralela | Ejecutar viejo y nuevo para comparar |
| Big Bang Rewrite | Reescritura Completa | Antipatrón de reescribir todo de una vez |
| Anemic Domain | Dominio Anémico | Antipatrón de entidades sin lógica |
| Leaky Abstraction | Abstracción Permeable | Abstracción que expone detalles internos |