6. Refactorización de código duplicado usando abstracciones

Cuando hablamos de eliminar duplicidad, solemos enfocarnos en copiar y pegar. Sin embargo, la verdadera refactorización consiste en construir una abstracción que capture la intención original y permita evolucionar el software sin volver a repetirla. En este tema nos apoyaremos en Java para ejemplificar el proceso, pero las ideas aplican a cualquier lenguaje orientado a objetos.

Una buena abstracción convierte bloques parecidos en un solo punto de mantenimiento, permite extender comportamiento sin tocar todos los sitios afectados y protege al equipo de introducir inconsistencias cuando el negocio cambia. Para lograrlo, necesitamos entender qué se repite, qué varía y qué depende del contexto.

6.1 Preparar la refactorización con evidencia

Antes de modificar código conviene construir un mapa de la duplicación. Buscamos identificar scripts, métodos o servicios que repitan firmas similares, validaciones en cadena o secuencias de pasos parecidas. Herramientas como los reportes de cobertura, las estadísticas de complejidad ciclomática y los análisis estáticos complementan nuestra observación manual.

Documentar el punto de partida es vital. Anotar qué responsabilidades posee cada duplicado, qué dependencias externas utiliza y cuáles son sus entradas y salidas permite detectar diferencias legítimas que no deberíamos unificar. Esta evidencia también nos sirve para comunicar el alcance de la refactorización y evitar sorpresas durante la revisión de código.

6.2 Delimitar qué se repite y qué cambia

Una abstracción efectiva surge de separar el algoritmo invariable de los detalles específicos. Listar en una tabla qué pasos son idénticos, cuáles varían y qué decisiones dependen de datos dinámicos ayuda a escoger la técnica adecuada: extracción de método, herencia, composición u orientación funcional.

Si detectamos que el comportamiento común ocupa la mayor parte de la lógica y las diferencias se concentran en pequeñas decisiones, el patrón Template Method puede resultar natural. Si todas las variaciones caben en una estrategia de cálculo o validación, una interfaz y clases concretas podrían ser suficiente. Cuando los duplicados solo comparten un fragmento chico, quizá sea preferible extraer una utilidad estática o una función pura.

6.3 Empezar por extract method y contratos explícitos

El primer paso práctico consiste en extraer funciones claras que expresen el propósito de cada bloque repetido. Un método con un nombre expresivo revela la intención y nos permite reutilizarlo en cada punto duplicado antes de construir abstracciones más sofisticadas. Este movimiento facilita las pruebas unitarias porque podemos llamar al nuevo método directamente y comprobar que el resultado se mantiene.

Al crear estos métodos, definimos contratos explícitos: parámetros con tipos precisos, objetos que encapsulan datos relacionados y valores de retorno que no dependen de efectos secundarios. Con contratos bien definidos podremos reorganizar el código sin que el resto del sistema se rompa.

6.4 Construir abstracciones orientadas a objetos

Cuando varias clases ejecutan la misma secuencia de pasos con pequeñas variaciones, una clase base o una interfaz suelen consolidar el comportamiento. Veamos un ejemplo típico de duplicación en dos importadores de datos.

class CsvClienteImporter {
    Cliente importar(Path archivo) {
        validarContenido(archivo);
        List<String> lineas = Files.readAllLines(archivo);
        Cliente cliente = mapear(lineas.get(1));
        auditar(cliente);
        return cliente;
    }

    // Lógica específica omitida
}

class JsonClienteImporter {
    Cliente importar(Path archivo) {
        validarContenido(archivo);
        String contenido = Files.readString(archivo);
        Cliente cliente = mapear(contenido);
        auditar(cliente);
        return cliente;
    }

    // Lógica específica omitida
}

Ambas clases comparten la validación, la auditoría y la estructura general. Podemos extraer una superclase que defina el flujo y delegue los detalles variables.

abstract class ClienteImporter {
    final Cliente importar(Path archivo) throws IOException {
        validarContenido(archivo);
        Cliente cliente = transformar(archivo);
        auditar(cliente);
        return cliente;
    }

    abstract Cliente transformar(Path archivo) throws IOException;

    void validarContenido(Path archivo) throws IOException {
        if (Files.size(archivo) == 0L) {
            throw new IllegalArgumentException("Archivo vacío");
        }
    }

    void auditar(Cliente cliente) {
        System.out.println("Importado cliente " + cliente.id());
    }
}

final class CsvClienteImporter extends ClienteImporter {
    @Override
    Cliente transformar(Path archivo) throws IOException {
        List<String> lineas = Files.readAllLines(archivo);
        return mapear(lineas.get(1));
    }
}

final class JsonClienteImporter extends ClienteImporter {
    @Override
    Cliente transformar(Path archivo) throws IOException {
        String contenido = Files.readString(archivo);
        return mapear(contenido);
    }
}

El método importar queda centralizado y cualquier cambio en la validación o la auditoría se realiza en un único lugar. Las subclases solo expresan cómo transformar el archivo en un objeto de dominio, lo que reduce el riesgo de inconsistencias.

6.5 Favorecer la composición y las estrategias

No todas las duplicaciones justifican herencia. Cuando los pasos comunes interactúan con servicios externos, la composición evita acoplar clases que no comparten una relación natural. Al inyectar estrategias, delegamos la variación en colaboraciones en lugar de extender una clase base.

interface CalculoDeComision {
    BigDecimal calcular(Pedido pedido);
}

class CalculadoraDeComision {
    private final CalculoDeComision estrategia;

    CalculadoraDeComision(CalculoDeComision estrategia) {
        this.estrategia = estrategia;
    }

    BigDecimal calcular(Pedido pedido) {
        validarPedido(pedido);
        BigDecimal comision = estrategia.calcular(pedido);
        registrar(pedido, comision);
        return comision;
    }

    private void validarPedido(Pedido pedido) {
        Objects.requireNonNull(pedido, "pedido");
    }

    private void registrar(Pedido pedido, BigDecimal comision) {
        System.out.println("Comisión calculada para " + pedido.id());
    }
}

Ahora cualquier cálculo de comisión reutiliza el flujo principal sin duplicar validaciones ni registros. Podemos crear implementaciones específicas para pedidos nacionales, internacionales o promociones temporales sin tocar el núcleo.

6.6 Reutilizar abstracciones con genéricos y colecciones

Los genéricos de Java permiten parametrizar tipos para evitar duplicar clases casi idénticas. Es habitual encontrar repositorios con el mismo CRUD por entidad; unificando la lógica en una clase genérica reducimos significativamente la repetición y mantenemos una firma consistente.

class Repository<T, ID> {
    private final DataSource dataSource;
    private final RowMapper<T> rowMapper;

    Repository(DataSource dataSource, RowMapper<T> rowMapper) {
        this.dataSource = dataSource;
        this.rowMapper = rowMapper;
    }

    Optional<T> findById(ID id) {
        // Implementación reutilizable
    }

    void save(T entidad) {
        // Implementación reutilizable
    }
}

Esta abstracción reduce el número de consultas duplicadas y deja que cada repositorio concreto solo especifique el mapeo y las consultas personalizadas. Además, facilita aplicar cambios transversales como mejorar el manejo de transacciones o instrumentar métricas.

6.7 Patrones de diseño que refuerzan DRY

Los patrones de diseño nacieron precisamente para encapsular soluciones reutilizables. Template Method y Strategy aparecen en muchos ejemplos de duplicación, pero también vale considerar Builder para reunir la creación de objetos complejos, Decorator para añadir responsabilidades sin copiar clases y Chain of Responsibility para uniformar flujos de validación.

Durante la refactorización debemos evitar aplicar un patrón por moda. Confirmemos que la abstracción elegida reduce el código repetido, simplifica el testeo y no introduce más capas de las necesarias. El objetivo sigue siendo la simplicidad, no demostrar el catálogo completo de patrones.

6.8 Refactorizar de forma incremental y segura

La refactorización debe avanzar en iteraciones pequeñas. Aislar cada paso en commits autocontenidos facilita revertir en caso de problemas y mantener el sistema deployable. Antes de mover código a una abstracción nueva, escribamos pruebas que congelen el comportamiento actual; luego, usemos esas pruebas como red de seguridad mientras reemplazamos instancias duplicadas.

Resulta útil activar banderas de características o realizar despliegues canarios para validar que la abstracción responde bien con carga real. La observabilidad (logs coherentes, métricas y trazas) ayuda a detectar si la consolidación produjo regresiones en entornos productivos.

6.9 Checklist para confirmar que la abstracción aporta valor

  • Hay un único punto donde modificar la regla de negocio compartida.
  • Las variantes se expresan con configuraciones o implementaciones puntuales, no con condicionales dispersos.
  • Las dependencias externas quedaron definidas por contratos claros y fáciles de simular en pruebas.
  • Los nombres elegidos describen el propósito en lenguaje de dominio, evitando términos genéricos como Helper o Util.
  • El equipo comprende cómo extender la abstracción sin romper el comportamiento existente.

Si alguna respuesta es negativa, probablemente debamos revisar la solución para no crear abstracciones accidentales que generen más deuda técnica de la que resuelven.

Refactorizar duplicación con abstracciones es una inversión que devuelve tiempo en el futuro. Mantiene viva la salud del código, evita bugs dispersos y favorece que nuevas personas se integren al equipo sin enfrentar laberintos repetidos. En los próximos temas exploraremos cómo sostener estos beneficios cuando trabajamos a gran escala y con múltiples equipos en paralelo.