← Volver al listado de tecnologías

Capítulo 9: Refactoring hacia Hexagonal

Por: Alfred Pennyworth
arquitectura-hexagonalrefactoringlegacy-codemigrationtypescript

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:

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

  1. Extraer Repositorio (Semana 1-2)
  2. Crear Casos de Uso (Semana 2-3)
  3. Modelar Dominio (Semana 3-4)
  4. Adaptar Controladores (Semana 4)
  5. 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:

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:

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:

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

Fase 2: Extracción

Fase 3: Casos de Uso

Fase 4: Dominio

Fase 5: Limpieza

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:

  1. Identificar Legacy: Señales de código que necesita refactoring
  2. Strangler Fig: Migración incremental sin big bang
  3. Pasos Incrementales: Repo → Casos de Uso → Dominio
  4. Técnicas Prácticas: Feature toggles, parallel run, adapters
  5. Antipatrones: Qué evitar durante la migración

Principios clave:

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 CodeCódigo LegacyCódigo antiguo difícil de mantener
RefactoringRefactorizaciónMejorar estructura sin cambiar comportamiento
Strangler FigHiguera EstranguladoraPatrón de migración incremental
Feature ToggleConmutador de CaracterísticaSwitch para activar/desactivar código
Parallel RunEjecución ParalelaEjecutar viejo y nuevo para comparar
Big Bang RewriteReescritura CompletaAntipatrón de reescribir todo de una vez
Anemic DomainDominio AnémicoAntipatrón de entidades sin lógica
Leaky AbstractionAbstracción PermeableAbstracción que expone detalles internos

Referencias