Módulo 7: Patrones como vocabulario de revisión
6. Refactorizar hacia un patrón (y hacia afuera de uno)
Descripción
Al terminar esta lección vas a poder proponer, en una revisión, un camino y no solo un destino. Vas a tener la secuencia concreta para llegar a un patrón desde código que no lo tiene, paso por paso, con el sistema funcionando después de cada paso. Y vas a tener la secuencia contraria —la que casi nadie enseña— para quitar un patrón que no se ganó su lugar, que es un trabajo distinto, con riesgos distintos y con una técnica propia.
Esto importa porque la lección 4 te pidió que todo diagnóstico terminara en una dirección, y una dirección sin camino es una buena intención. "Esto pide una Strategy" es correcto y no es accionable si quien lo lee no sabe cómo se llega ahí sin un salto mortal de doscientas líneas. La diferencia entre un revisor útil y uno que solo señala está exactamente en esta lección: el útil puede decir "y el primer paso es este, son diez líneas y se mergea hoy".
Y hay una razón más de fondo, que es la que da título a la lección. El movimiento va en las dos direcciones y ambas son legítimas. Toda la industria enseña la primera —cómo introducir un patrón— y casi nadie enseña la segunda. El resultado es predecible: los sistemas acumulan estructura y nunca la sueltan, porque quitar se siente como retroceder. No lo es. Un sistema del que se puede quitar una abstracción es un sistema más sano que uno donde todas las abstracciones son permanentes, y saber quitar es lo que hace que el equipo se atreva a poner.
Conexión con el módulo: las lecciones 2, 3 y 5 te dieron el vocabulario para nombrar lo que está mal; la 4 te dio la forma de decirlo. Esta lección cierra el circuito: qué hacer después de que el diagnóstico se aceptó. Va aquí y no en el módulo 8 porque en una revisión la dirección es parte del comentario, no una fase posterior. El módulo 8 —el capstone— retoma esto con más profundidad y sobre un encargo completo; aquí lo que interesa es lo que cabe en un comentario y en un PR chico. La lección 7, que viene después, se ocupa de la parte que ninguna técnica resuelve: cómo se acuerda entre dos personas cuál de las dos direcciones tomar.
Mudarse de casa sin dormir en la calle
Te mudas. Hay dos formas de hacerlo.
La primera: un sábado sacas absolutamente todo, cargas el camión, y hasta que no esté todo instalado en la casa nueva no tienes dónde dormir, dónde cocinar ni dónde encontrar tu cepillo de dientes. Si algo sale mal a media tarde —el camión no cabe, la llave no abre—, no tienes a dónde volver. Es rápido cuando sale bien y es catastrófico cuando no.
La segunda: te quedas con las dos casas dos semanas. Llevas la ropa de invierno primero, después los libros, después la cocina. Cada noche duermes en algún lado. En cualquier momento puedes parar y todo sigue funcionando, un poco desordenado pero funcionando. Es más lento en total, cuesta dos semanas de renta doble, y nunca hay un momento donde no tengas dónde dormir.
Refactorizar es lo segundo. La renta doble es el precio real de esta lección: durante un tiempo van a convivir la forma vieja y la forma nueva, el código se va a ver más feo que al principio y que al final, y eso incomoda. Es exactamente el precio de poder parar en cualquier momento sin dejar el sistema roto.
Y fíjate en la propiedad que hace posible la segunda forma: en ningún momento el estado intermedio es inválido. Tener la mitad de las cosas en cada casa es raro pero vivible. En código, ese "vivible" tiene un nombre operativo muy concreto: el sistema pasa las pruebas y se puede mergear. Si tu plan de refactor tiene un tramo donde nada compila hasta que termines, no estás refactorizando: estás reescribiendo, y reescribir tiene un riesgo distinto que hay que aceptar a propósito, no por accidente.
Qué es refactorizar, exactamente
Refactorizar es cambiar la estructura interna del código sin cambiar su comportamiento observable.
Las dos mitades de esa frase son igual de importantes y la segunda es la que se viola todo el tiempo.
"Sin cambiar el comportamiento observable" significa que, desde fuera, nadie puede notar la diferencia. Las mismas entradas producen las mismas salidas, los mismos errores, los mismos efectos. Si al mover el cálculo del precio a otra clase aprovechaste para arreglar un redondeo, ya no estás refactorizando: estás haciendo dos cosas a la vez, y si algo falla no vas a saber cuál de las dos lo causó.
De ahí sale la regla más importante de la lección, y es una regla de proceso, no de diseño:
Refactorizar y cambiar comportamiento nunca van en el mismo commit.
Cuesta disciplina porque la tentación es constante: estás moviendo un método y ves un bug obvio ahí mismo. La salida correcta es anotarlo, terminar el movimiento, mergear, y arreglar el bug en el commit siguiente. Dos commits chicos y claros valen más que uno grande donde nadie —incluido tú, en tres meses— puede saber qué se movió y qué se cambió.
La red: pruebas de comportamiento, no de estructura
Refactorizar sin red es adivinar. Pero no cualquier prueba sirve, y esta distinción es la que decide si el refactor va a ser posible o imposible.
Compara estas dos pruebas del mismo comportamiento de Boletia:
# Archivo: tests/test_seating_structure.py
# ❌ Esta prueba está amarrada a la ESTRUCTURA.
def test_seating_plugin_is_registered():
assert "default_seating" in SeatingPluginRegistry.available()
def test_registry_returns_plugin_instance():
plugin = SeatingPluginRegistry.get("default_seating")
assert isinstance(plugin, SeatingPlugin)
# Archivo: tests/test_seating_behavior.py
# ✅ Esta prueba está amarrada al COMPORTAMIENTO.
# No menciona plugins, registro ni interfaces. Solo qué debe pasar.
def test_assigns_consecutive_seats_in_the_same_row():
event = make_event(rows=["A", "B"], seats_per_row=10)
order = make_order(tickets=3)
assign_seats(event, order)
assert [t.seat for t in order.tickets] == ["A-1", "A-2", "A-3"]
def test_falls_back_to_next_row_when_the_row_runs_out():
event = make_event(rows=["A", "B"], seats_per_row=2)
order = make_order(tickets=3)
assign_seats(event, order)
assert [t.seat for t in order.tickets] == ["A-1", "A-2", "B-1"]
Las de arriba se rompen en cuanto quitas el registro, aunque el comportamiento del sistema sea idéntico. Y eso las vuelve peor que inútiles: son un candado. Cada vez que alguien quiera mejorar la estructura, esas pruebas se van a poner en rojo y van a producir la sensación —falsa— de que el refactor rompió algo. En la práctica, lo que hace la gente es cambiar las pruebas para que pasen, y en ese momento la red dejó de existir.
Las de abajo sobreviven a cualquier reestructuración interna porque solo hablan de lo que el sistema hace. Son la red de verdad.
Esto tiene una consecuencia directa para tus comentarios de revisión, y es de las cosas más útiles que puedes decir en un PR: cuando propongas un refactor, di si la red existe. "El test de asignación de asientos no menciona plugins, así que sirve igual después del cambio" es información que convierte una propuesta arriesgada en una segura. Y si la red no existe, el primer paso del refactor no es tocar el código: es escribirla.
Cuando el código heredado no tiene pruebas, la técnica se llama prueba de caracterización: escribes una prueba que documenta lo que el código hace hoy, incluidas las rarezas, sin juzgar si está bien. No es una prueba de que el comportamiento sea correcto; es una prueba de que no cambió. Es exactamente lo que hace falta para refactorizar.
Dirección A: refactorizar hacia un patrón
La idea central, y es contraintuitiva: no se llega a un patrón de un salto. Se llega por una secuencia de movimientos pequeños, cada uno de los cuales tiene sentido por sí solo, y en algún momento la estructura resultante ya es el patrón. El patrón es el destino, no el primer paso; y muchas veces uno se da cuenta a mitad de camino de que el destino era otro.
La secuencia general, en cinco movimientos:
- Asegura la red. Pruebas de comportamiento sobre lo que vas a tocar.
- Extrae. Saca cada rama o cada caso a su propia función o método, sin cambiar quién llama a quién.
- Unifica las firmas. Haz que todas las piezas extraídas reciban lo mismo y devuelvan lo mismo. Este paso es el que más gente se salta y es el que hace posible el siguiente.
- Sustituye el condicional por una búsqueda. El
if/elifse convierte en un diccionario o en una tabla. - Recién ahora, decide si hace falta la clase. Muchas veces, no.
Ejemplo trabajado: de un if a una Strategy, en cinco commits
Vamos a hacerlo completo sobre pricing/calculator.py, y quiero que veas los estados intermedios porque son la parte que nunca se muestra.
Punto de partida.
# Archivo: pricing/calculator.py
def calculate_price(ticket, order_date):
if ticket.kind == "general":
return ticket.base_price
elif ticket.kind == "vip":
return ticket.base_price * 1.40
elif ticket.kind == "early_bird":
cutoff = get_early_bird_cutoff(ticket.event_id)
return ticket.base_price * 0.75 if order_date < cutoff else ticket.base_price
elif ticket.kind == "courtesy":
if courtesy_count(ticket.event_id) > COURTESY_LIMIT:
raise CourtesyLimitExceeded(ticket.event_id)
return 0.0
else:
raise ValueError(f"Tipo de boleto desconocido: {ticket.kind}")
Commit 1 — la red. Antes de tocar nada, una prueba por rama, incluyendo el error.
# Archivo: tests/test_pricing.py
# Prueba de caracterización: documenta lo que hace HOY, sin juzgarlo.
def test_general_uses_base_price():
assert calculate_price(ticket(kind="general", base_price=500), TODAY) == 500
def test_vip_adds_forty_percent():
assert calculate_price(ticket(kind="vip", base_price=500), TODAY) == 700
def test_early_bird_discounts_before_cutoff():
assert calculate_price(ticket(kind="early_bird", base_price=500), BEFORE) == 375
def test_early_bird_full_price_after_cutoff():
assert calculate_price(ticket(kind="early_bird", base_price=500), AFTER) == 500
def test_courtesy_is_free():
assert calculate_price(ticket(kind="courtesy"), TODAY) == 0.0
def test_courtesy_over_limit_raises():
with pytest.raises(CourtesyLimitExceeded):
calculate_price(ticket(kind="courtesy", event_id=EVENT_AT_LIMIT), TODAY)
def test_unknown_kind_raises():
with pytest.raises(ValueError):
calculate_price(ticket(kind="student"), TODAY)
Nota que la prueba del else está incluida. Ese detalle importa: si tu refactor cambia el error que lanza un tipo desconocido, cambiaste comportamiento observable aunque el camino feliz siga igual.
Commit 2 — extraer cada rama. Sin tocar la estructura del condicional. Es un movimiento mecánico y de riesgo casi nulo.
def _price_general(ticket, order_date):
return ticket.base_price
def _price_vip(ticket, order_date):
return ticket.base_price * 1.40
def _price_early_bird(ticket, order_date):
cutoff = get_early_bird_cutoff(ticket.event_id)
return ticket.base_price * 0.75 if order_date < cutoff else ticket.base_price
def _price_courtesy(ticket, order_date):
if courtesy_count(ticket.event_id) > COURTESY_LIMIT:
raise CourtesyLimitExceeded(ticket.event_id)
return 0.0
def calculate_price(ticket, order_date):
if ticket.kind == "general":
return _price_general(ticket, order_date)
elif ticket.kind == "vip":
return _price_vip(ticket, order_date)
elif ticket.kind == "early_bird":
return _price_early_bird(ticket, order_date)
elif ticket.kind == "courtesy":
return _price_courtesy(ticket, order_date)
else:
raise ValueError(f"Tipo de boleto desconocido: {ticket.kind}")
Aquí es donde ya se hizo el trabajo importante, aunque no lo parezca. Al forzar la misma firma (ticket, order_date) en las cuatro funciones, las volviste intercambiables. _price_general no necesita order_date y lo recibe igual; ese parámetro que sobra es el precio de la uniformidad, y es lo que hace posible el paso siguiente. El paso 3 de la secuencia —unificar firmas— quedó hecho aquí mismo.
Este commit se mergea solo. El sistema funciona, las pruebas pasan, y aunque el refactor se abandonara aquí, el código quedó mejor que antes.
Commit 3 — sustituir el condicional por una tabla.
# Un diccionario que mapea tipo de boleto → cómo se calcula su precio.
# Agregar un tipo es agregar una entrada, no editar un condicional.
_PRICERS = {
"general": _price_general,
"vip": _price_vip,
"early_bird": _price_early_bird,
"courtesy": _price_courtesy,
}
def calculate_price(ticket, order_date):
pricer = _PRICERS.get(ticket.kind)
if pricer is None:
raise ValueError(f"Tipo de boleto desconocido: {ticket.kind}")
return pricer(ticket, order_date)
Y aquí llega la pregunta que da sentido a toda la lección. Esto ya es una Strategy. Cada función es una estrategia, el diccionario es el selector, y calculate_price es el contexto que la usa sin saber cuál es. No hay clases, no hay interfaz, no hay herencia — y el patrón está completo, porque un patrón es una forma de organizar, no un conjunto de clases.
En Python, muchas veces este es el final del camino, y el módulo 3 le dedicó una lección entera a esa idea: cuando el lenguaje tiene funciones de primera clase, la Strategy con clases suele ser ceremonia.
Commit 4 — solo si hace falta: subir a clases. ¿Cuándo hace falta? Cuando la estrategia necesita estado o más de un método. Y en Boletia eso sí ocurre, porque la lección 2 lo destapó: el tipo de boleto no solo decide el precio; también decide la ventana de reembolso y si el boleto es transferible. Tres preguntas sobre lo mismo, hoy repartidas en tres archivos.
# Archivo: tickets/kinds.py
# Un tipo de boleto sabe TODO lo que depende de ser ese tipo.
class TicketKind:
def price(self, ticket, order_date): raise NotImplementedError
def refund_window_days(self): return 14
def is_transferable(self): return True
class GeneralKind(TicketKind):
def price(self, ticket, order_date):
return ticket.base_price
class VipKind(TicketKind):
def price(self, ticket, order_date):
return ticket.base_price * 1.40
class EarlyBirdKind(TicketKind):
def price(self, ticket, order_date):
cutoff = get_early_bird_cutoff(ticket.event_id)
return ticket.base_price * 0.75 if order_date < cutoff else ticket.base_price
def refund_window_days(self):
# La regla que vivía escondida en refunds/policy.py.
return 3
class CourtesyKind(TicketKind):
def price(self, ticket, order_date):
if courtesy_count(ticket.event_id) > COURTESY_LIMIT:
raise CourtesyLimitExceeded(ticket.event_id)
return 0.0
def is_transferable(self):
# La regla que vivía escondida en tickets/transfer.py.
return False
KINDS = {
"general": GeneralKind(),
"vip": VipKind(),
"early_bird": EarlyBirdKind(),
"courtesy": CourtesyKind(),
}
Mira lo que pasó, porque es el argumento entero a favor de subir a clases: el shotgun surgery desapareció. Antes, agregar un tipo de boleto obligaba a acordarse de tres archivos que no se conocían entre sí, y olvidar uno producía una decisión tomada por omisión. Ahora agregar un tipo es escribir una clase, y el sistema de tipos —o al menos el NotImplementedError— te recuerda qué falta.
Commit 5 — migrar a los otros dos consumidores. Uno por commit: refunds/policy.py pasa a usar KINDS[ticket.kind].refund_window_days(), y tickets/transfer.py a KINDS[ticket.kind].is_transferable(). Cada uno con sus pruebas, cada uno mergeable solo.
Qué esperar de esta secuencia. Lo primero: cinco commits, cinco puntos donde se podía parar. Si a mitad de camino entra una urgencia, el trabajo hecho queda integrado y no hay que revertir nada. Esa propiedad es todo el valor del método. Compara con la alternativa —una rama de dos semanas que introduce TicketKind de golpe y toca los tres consumidores— que tiene tres problemas: nadie la puede revisar de verdad, choca con todo lo que pase mientras tanto, y si algo sale mal no se sabe qué paso lo causó.
Lo segundo: el commit 3 ya era una Strategy completa, y en muchos sistemas ese sería el final correcto. La decisión de seguir al commit 4 no vino de querer "aplicar bien el patrón": vino de un hecho concreto del sistema —que había tres preguntas repartidas en tres archivos—. Si esa evidencia no hubiera existido, parar en el 3 habría sido lo correcto y las clases habrían sido ceremonia.
Lo tercero, y es lo que quiero que te lleves para tus comentarios: el primer paso es minúsculo. El commit 2 es mecánico, sin riesgo, y se revisa en tres minutos. Cuando en una revisión propongas una dirección, propón ese paso, no el destino. "Como primer paso, ¿qué tal si cada rama sale a su función con la misma firma? Es mecánico y desde ahí se ve mejor si vale la pena seguir." Esa frase consigue mucho más que un párrafo sobre Strategy.
Dirección B: refactorizar hacia afuera de un patrón
Ahora la dirección que casi nadie enseña.
Antes de la técnica, la razón por la que hace falta enseñarla, que es cultural más que técnica. Quitar estructura se siente como retroceder. Hay una asimetría emocional muy real: agregar una abstracción se percibe como madurez profesional, y quitarla, como admitir un error —propio o, peor, de un compañero—. Esa asimetría es la razón por la que los sistemas acumulan capas y nunca las sueltan.
Y es falsa. Quitar una abstracción que no se gana su lugar es exactamente el mismo trabajo de ingeniería que ponerla: se hace por la misma razón —que el código sea más fácil de cambiar—, con la misma técnica —pasos pequeños con red— y con el mismo criterio. La única diferencia es la dirección de la flecha.
La secuencia general, en seis movimientos:
- Escribe la red, en términos de comportamiento, sin mencionar la estructura que vas a quitar. Este paso es más crítico aquí que en la dirección A, porque las pruebas que existen probablemente estén amarradas a la estructura condenada.
- Cuenta las implementaciones reales. Si es una, sigue. Si son dos o más, para: la abstracción se gana su lugar.
- Instancia lo concreto directo en el punto de uso, dejando el mecanismo viejo en su lugar. Aquí empieza la renta doble.
- Quita el mecanismo de selección —el registro, la fábrica, la configuración— cuando ya nadie lo llame.
- Quita la clase base o la interfaz, y con ella la herencia.
- Aplana si sobra la clase. Si lo que queda no tiene estado, probablemente sean funciones de módulo.
Y un séptimo que la gente olvida: borra las pruebas del andamio. Las que probaban el registro prueban algo que ya no existe; conservarlas es conservar el andamio después de quitar el edificio.
Ejemplo trabajado: aplanar la jerarquía de exportadores
El módulo 2 desmontó plugins/ paso a paso, así que aquí voy a hacer el otro caso de Boletia: la herencia de cinco niveles de reports/ que viste en la lección 5. Es un caso más interesante porque aquí no hay una sola implementación —hay tres exportadores reales— y aun así hay que quitar estructura. Eso enseña algo importante: refactorizar hacia afuera no es solo para abstracciones con un implementador.
Punto de partida: BaseExporter → TabularExporter → SortedTabularExporter → AttendeeExporter → CsvExporter / PdfExporter / XlsxExporter. Cinco niveles, y _prepare sobrescrito en tres de ellos con super() en medio de expresiones.
Paso 1 — la red, en comportamiento. Antes de tocar nada:
# Archivo: tests/test_exporters_behavior.py
# No menciona ninguna clase de la jerarquía salvo el punto de entrada.
def test_csv_lists_only_paid_attendees_sorted_by_name():
event = event_with(attendees=[("Zoe", "paid"), ("Ana", "paid"), ("Ivo", "pending")])
path = CsvExporter().export(event.id)
assert read_names(path) == ["Ana", "Zoe"]
def test_all_formats_produce_the_same_rows():
# La propiedad que de verdad nos importa: los tres coinciden.
event = event_with(attendees=[("Zoe", "paid"), ("Ana", "paid")])
assert read_names(CsvExporter().export(event.id)) == \
read_names(PdfExporter().export(event.id)) == \
read_names(XlsxExporter().export(event.id))
La segunda prueba es la valiosa, y es del tipo que solo se te ocurre cuando piensas en comportamiento: captura la propiedad que la jerarquía existía para garantizar. Con ella en verde, cualquier reestructuración que la mantenga es segura.
Paso 2 — contar. Tres formatos reales: CSV, PDF, XLSX. Así que la variación existe y hay que preservarla. Lo que no está justificado son los cinco niveles para expresar esa variación, ni los tres niveles intermedios que nadie instancia jamás.
# ¿Alguien instancia los niveles intermedios?
$ grep -rn "TabularExporter(\|SortedTabularExporter(\|AttendeeExporter(" --include="*.py" .
# (sin resultados)
Cero. Tres clases que solo existen para ser heredadas, en una cadena que nadie ramifica: cada nivel tiene exactamente un hijo hasta el final.
Paso 3 — hacer explícito el esqueleto, en paralelo. Escribimos la versión plana sin borrar nada todavía:
# Archivo: reports/exporter.py (nuevo, convive con la jerarquía)
def prepare_attendee_rows(event_id):
# El orden de los pasos, visible de una sola lectura.
# Antes había que reconstruirlo siguiendo cinco super() hacia arriba.
rows = db.fetch_attendees(event_id)
rows = [{h: r[h] for h in HEADERS} for r in rows]
rows = sorted(rows, key=lambda r: r["name"])
rows = [r for r in rows if r["status"] == "paid"]
return rows
Aquí conviene detenerse, porque este paso destapó algo. Al escribir el orden explícito hubo que reconstruirlo desde la cadena de super(), y el orden real resultó ser mapear → ordenar → filtrar. Ordenar antes de filtrar es trabajo de más y, sobre todo, nadie lo eligió: es un efecto colateral del orden en que se fueron agregando los niveles. Ese es un hallazgo típico de refactorizar hacia afuera: la estructura estaba escondiendo una decisión que nadie tomó.
Y aquí es donde la regla de la lección se pone a prueba. La tentación es arreglarlo ahora —filtrar primero es obviamente mejor—. No. Eso es cambio de comportamiento observable si algún reporte depende del orden de las filas, y va en otro commit. Se anota y se sigue.
Paso 4 — que los tres concretos usen la función, uno por commit.
# Archivo: reports/csv_exporter.py
class CsvExporter: # ← ya no hereda de nadie
extension = "csv"
def export(self, event_id):
rows = prepare_attendee_rows(event_id)
path = f"/tmp/attendees_{event_id}.csv"
write_text(path, self._render(rows))
return path
def _render(self, rows):
body = ",".join(HEADERS) + "\n"
for r in rows:
body += ",".join(str(r[h]) for h in HEADERS) + "\n"
return body
Tres commits, uno por formato. Después de cada uno, las pruebas de comportamiento en verde y el sistema mergeable. Durante estos tres commits conviven la jerarquía y la función nueva —la renta doble—, y eso está bien.
Paso 5 — borrar los niveles intermedios. Cuando los tres concretos ya no heredan, BaseExporter, TabularExporter, SortedTabularExporter y AttendeeExporter no los usa nadie. Fuera los cuatro archivos.
Paso 6 — aplanar lo que sobre. Ahora que las tres clases no tienen estado —solo extension y un _render—, la pregunta honesta es si tienen que ser clases. En este caso sí conviene conservarlas, porque el ReportManager las selecciona por nombre y tres objetos con la misma interfaz es exactamente lo que necesita. Pero la pregunta hay que hacérsela: no todo lo que sobrevive a un aplanamiento merece seguir siendo clase.
Paso 7 — borrar las pruebas del andamio. Todas las que decían cosas como assert isinstance(exporter, TabularExporter) o test_sorted_tabular_exporter_sorts prueban una estructura que ya no existe. Fuera.
Y el commit siguiente, ya no de refactor: arreglar el orden. Filtrar antes de ordenar, con su prueba, y una nota en el PR explicando que es un cambio de comportamiento —invisible en el resultado, pero cambio al fin— y por qué.
Qué esperar de esta secuencia. Lo primero: la red se escribió antes de saber cómo iba a quedar el código, y por eso funcionó. Si hubieras escrito las pruebas después de decidir la estructura nueva, habrías escrito pruebas que confirman tu decisión en vez de pruebas que protegen el comportamiento.
Lo segundo: el refactor encontró un bug que nadie buscaba —el orden de las operaciones—. Esto pasa tan seguido que casi es una regla: cuando una estructura obliga a reconstruir mentalmente qué hace el sistema, siempre hay algo ahí dentro que nadie decidió. El valor de aplanar no es estético; es que las decisiones vuelven a ser visibles.
Lo tercero: cinco archivos y una jerarquía se convirtieron en una función de seis líneas y tres clases planas, y el conteo de la pregunta 2 de la lección 5 —cuántos archivos para responder algo simple— pasó de cinco a uno.
Cómo saber en qué dirección vas
En una revisión tienes que decidir esto rápido. Tres preguntas alcanzan.
¿Cuántas implementaciones reales existen hoy? Una: dirección B, hacia afuera. Tres o más con lógica de verdad distinta: dirección A, hacia el patrón. Dos: no hagas nada todavía — es la zona de la regla de tres del módulo 2, donde no hay suficiente información para saber cuál es el eje de variación correcto.
¿El condicional crece? Si cada mes alguien agrega un elif al mismo if, hay presión hacia el patrón. Si el if tiene tres ramas desde hace dos años, no hay ninguna presión y meter estructura es inventarse un problema.
¿El flujo se puede seguir con el dedo? Si para entender qué pasa cuando alguien compra un boleto hay que abrir cinco archivos y seguir dos saltos dinámicos, hay demasiada indirección: dirección B. Esta pregunta es la que más rápido detecta el exceso, y es la que el módulo 6 usó para hablar del costo escondido del Observer.
Y una cuarta, que no es sobre el código sino sobre el equipo y que en la práctica decide más que las tres anteriores: ¿alguien se atascó aquí recientemente? Si tres personas nuevas seguidas no entendieron el mismo rincón, eso es un dato duro sobre el rincón, no sobre las personas.
Las tres reglas del paso pequeño
Uno: cada paso deja el sistema funcionando. El criterio operativo es el mergeo. Si no puedes mergear el estado intermedio a la rama principal, el paso es demasiado grande. Y no es una regla estética: un refactor que vive dos semanas en una rama aparte acumula conflictos con todo lo que el equipo hace mientras tanto, y esos conflictos se resuelven mal, porque quien los resuelve no entiende las dos mitades.
Dos: un tipo de cambio por commit. Ya lo dijimos y vale repetirlo porque es el que más se viola: refactorizar y cambiar comportamiento nunca van juntos. Y hay un corolario práctico: cuando revises un PR, si ves movimientos de código y cambios de lógica mezclados, eso solo ya justifica pedir que se separen. No es purismo — es que un diff mezclado es imposible de revisar de verdad.
Tres: la renta doble se paga en el orden correcto. Primero se introduce la forma nueva, después se migran los consumidores uno por uno, y solo al final se quita la vieja. El error clásico es quitar la vieja primero "para que no haya duplicación", lo que obliga a migrar todo de golpe. La duplicación temporal es el precio de la seguridad, y es barato.
Errores comunes
Perseguir el patrón en vez del problema (de criterio). Qué pasa: alguien decide que el destino es una Strategy y empuja el refactor hasta llegar ahí, aunque a mitad de camino el código ya estaba bien. El resultado típico es el commit 4 sin la evidencia del commit 4: cuatro clases donde el diccionario de funciones bastaba. Por qué pasa: tener un destino nombrado es motivante y da una sensación clara de progreso, mientras que parar a mitad se siente como dejar algo incompleto. Cómo detectarlo: si en algún paso no puedes nombrar qué problema concreto resuelve ese paso —más allá de "nos acerca al patrón"—, ahí es donde había que parar. Cómo corregirlo: después de cada paso, vuelve a preguntarte si el siguiente se gana su lugar. El commit 3 del ejemplo era un final perfectamente válido; lo que justificó seguir fue un hecho del sistema —tres preguntas repartidas en tres archivos—, no el nombre del patrón.
Refactorizar sin red y creer que se tiene (de método). Qué pasa: alguien mira la carpeta de pruebas, ve doscientos tests en verde y se lanza. Reestructura, y de los doscientos, ciento cuarenta se ponen rojos. Los revisa uno por uno, descubre que estaban amarrados a la estructura, y los va "arreglando" para que pasen. Al terminar, todo está verde y nadie verificó que el comportamiento no cambió. Por qué pasa: la cantidad de pruebas se confunde con cobertura de comportamiento, y cuando las pruebas se ponen rojas por razones estructurales el camino de menor resistencia es editarlas. Cómo detectarlo: antes de empezar, abre tres pruebas al azar de la zona que vas a tocar. Si mencionan clases internas, registros o isinstance, no tienes red. Cómo corregirlo: el paso cero de cualquier refactor es escribir dos o tres pruebas de comportamiento de alto nivel, aunque ya existan doscientas de otro tipo. Son las únicas que van a seguir significando algo al otro lado.
Quitar una abstracción de un solo golpe (de método). Qué pasa: alguien diagnostica correctamente que sobra una capa y abre un PR que la borra completa y ajusta los once puntos de uso. El PR es imposible de revisar, choca con todo, y si algo falla no hay forma de saber cuál de los once ajustes lo causó. Por qué pasa: borrar se siente distinto de construir —parece que no hay estados intermedios posibles, que o está o no está—. Y además queda la incomodidad de la renta doble: tener la capa vieja sin usar, ahí, molestando. Cómo detectarlo: si tu PR de eliminación toca más de dos o tres archivos, es demasiado grande. Cómo corregirlo: la secuencia de seis pasos. Se introduce el uso directo, se migran los consumidores uno por commit, y la capa vieja se borra cuando ya no la llama nadie —momento en el cual borrarla es un diff trivial que se revisa en un minuto—. La incomodidad de tener las dos formas conviviendo dura unos días y compra la posibilidad de parar en cualquier punto.
Ejercicios
Ejercicio 1 — Diseña la secuencia hacia el patrón. El if/elif de proveedores de pago de checkout.py tiene cuatro ramas y cada una construye su cliente con su credencial. Escribe la secuencia de commits para llevarlo a un registro en payments/, indicando para cada commit qué toca y por qué es mergeable solo.
Ver solución
Una secuencia posible, en cinco commits.
Commit 1 — la red. Una prueba por proveedor que verifique el comportamiento observable del checkout: que una orden con provider="stripe" termina en paid con la referencia del proveedor, y que una con proveedor desconocido lanza ValueError. Y una que verifique el caso de fallo: si el cobro no sale bien, la orden queda cancelled y se lanza PaymentFailed. Ojo con lo que no debe hacer esta prueba: mencionar StripeProvider o StripeClient. Debe hablar de órdenes y estados.
Commit 2 — extraer la construcción a una función, con el if intacto.
# Archivo: payments/registry.py
def provider_for(name):
if name == "stripe":
return StripeProvider(StripeClient(api_key=settings.STRIPE_KEY))
elif name == "mercadopago":
return MercadoPagoProvider(MercadoPagoClient(token=settings.MP_TOKEN))
elif name == "cash":
return CashProvider()
raise UnknownProvider(name)
Y en checkout.py, las diez líneas del condicional se convierten en provider = payments.provider_for(order.provider). Mergeable solo, y ya rinde: el checkout perdió el conocimiento de los tres SDKs y sus credenciales. Si el refactor se abandonara aquí, valió la pena.
Commit 3 — el if se vuelve tabla.
_PROVIDERS = {
"stripe": lambda: StripeProvider(StripeClient(api_key=settings.STRIPE_KEY)),
"mercadopago": lambda: MercadoPagoProvider(MercadoPagoClient(token=settings.MP_TOKEN)),
"cash": lambda: CashProvider(),
}
def provider_for(name):
if name not in _PROVIDERS:
raise UnknownProvider(name)
return _PROVIDERS[name]()
def available():
return tuple(_PROVIDERS)
Aquí ya es una Factory y agregar un proveedor es una entrada del diccionario.
Commit 4 — migrar el segundo consumidor. api/routes.py borra su ALLOWED_PROVIDERS y usa payments.available(). Este commit es el que de verdad quita el shotgun surgery: mata la segunda lista, que era el modo de falla silencioso.
Commit 5 — migrar el tercero. reports/reconciliation.py deja de tener su diccionario de etiquetas y usa payments.LABELS, que se agrega junto a _PROVIDERS.
Por qué cada uno es mergeable solo. El 1 no toca código de producción. El 2 es una extracción sin cambio de lógica. El 3 cambia la forma de decidir pero no lo decidido, y las pruebas del 1 lo cubren. El 4 y el 5 cambian de dónde saca su información un consumidor, sin cambiar la información.
Lo que hay que notar. El resultado no tiene clases nuevas ni interfaz nueva —PaymentProvider ya existía—. Es una Factory hecha con un diccionario y dos funciones. Si la propuesta hubiera sido "hagamos una jerarquía de fábricas", habríamos terminado en el anti-patrón de la lección 5.
Por qué funciona: el ejercicio te obliga a producir lo que la lección 4 llama una dirección accionable, en su forma más fuerte. Un comentario que dice "esto pide una Factory" es un destino; uno que dice "el primer paso es sacar el if a payments.provider_for(), son diez líneas movidas y se mergea hoy" es un camino.
Ejercicio 2 — Diseña la secuencia hacia afuera. Boletia tiene una interfaz NotificationFormatter con un método format(order) y una sola implementación, DefaultFormatter, que la usan cuatro puntos del sistema. La interfaz se agregó "por si algún día queremos formatos distintos por cliente", hace catorce meses. Escribe la secuencia para quitarla y di en qué paso pararías si a mitad de camino apareciera una segunda implementación real.
Ver solución
Paso 1 — la red. Una prueba por cada uno de los cuatro puntos de uso, verificando el texto que se produce. No menciona NotificationFormatter ni DefaultFormatter: verifica que la confirmación de una orden contiene el nombre del cliente, el total y el nombre del evento.
Paso 2 — contar. grep -rn "NotificationFormatter" . y grep -rn "Formatter)" . para encontrar subclases. Una sola implementación en catorce meses. Y la pregunta de la lección 5, que es la que decide: ¿alguien puede nombrar una vez concreta en la que esta interfaz nos ahorró trabajo? Si la respuesta es un escenario futuro, sigue.
Paso 3 — usar la concreta directo, un consumidor por commit. Cuatro commits, cada uno cambia formatter: NotificationFormatter por DefaultFormatter en un punto de uso. La interfaz sigue ahí, sin usarse, y eso está bien: es la renta doble.
Paso 4 — quitar la herencia. class DefaultFormatter(NotificationFormatter) pasa a class DefaultFormatter. Un commit de una línea.
Paso 5 — borrar la interfaz. Ya no la referencia nadie. Otro commit trivial.
Paso 6 — aplanar si sobra la clase. Si DefaultFormatter no tiene estado —solo un format(order)— probablemente sea una función de módulo: format_confirmation(order). Y ahí conviene renombrarla, porque "Default" era un nombre que solo tenía sentido cuando había una jerarquía.
Paso 7 — borrar las pruebas del andamio. Cualquier test_default_formatter_implements_interface prueba algo que ya no existe.
Dónde pararía si aparece una segunda implementación real. Depende de cuándo aparezca, y la respuesta correcta no es "parar y ya":
- Si aparece antes del paso 3, se detiene todo. La abstracción se ganó su lugar: hay dos implementaciones reales y la interfaz está justificada. Se cierra el ticket con una nota que diga por qué se decidió no quitarla — esa nota vale, porque evita que dentro de un año alguien lo replantee sin la información.
- Si aparece a mitad del paso 3 —con dos consumidores migrados y dos no—, la salida sensata es terminar de migrar hacia la concreta y después introducir la abstracción de nuevo, ahora con las dos implementaciones reales sobre la mesa. Suena a trabajo de más y no lo es: la interfaz que se diseña conociendo dos implementaciones concretas casi nunca tiene la misma forma que la que se diseñó imaginando una. Es exactamente el argumento de la regla de tres del módulo 2.
- Si aparece después del paso 5, se reintroduce con los dos casos a la vista. Es una tarde de trabajo, que era justamente el argumento para haberla quitado.
Por qué funciona: la pregunta del final es la que separa el criterio del dogma. Alguien que quita abstracciones por principio se sentiría contrariado si aparece una segunda implementación. Alguien con criterio la recibe como información nueva y ajusta. El objetivo nunca fue tener menos abstracciones: era tener las que se ganan su lugar.
Ejercicio 3 — Encuentra el commit que no debía estar. Un compañero manda un PR titulado "Refactor: extraer el cálculo de precio a pricing/". Estos son sus cinco commits. Identifica cuál no pertenece al refactor y explica qué problema causa que esté ahí.
a1b2c3d Prueba de caracterización de calculate_price (7 casos)
d4e5f6a Extraer cada rama a su función con firma (ticket, order_date)
7b8c9d0 Sustituir el if/elif por un diccionario _PRICERS
1e2f3a4 Corregir redondeo de VIP: usar round(..., 2) en vez de truncar
5b6c7d8 Mover _PRICERS y las funciones a pricing/rules.py
Ver solución
El commit que no pertenece es 1e2f3a4 — "Corregir redondeo de VIP".
Los otros cuatro son refactorizaciones puras: mueven código sin cambiar lo que el sistema hace observable desde fuera. Ese cambia el resultado de un cálculo. Un boleto VIP de 333.33 devolvía un valor y ahora devuelve otro. Es un cambio de comportamiento, y muy probablemente uno correcto y necesario — pero no es un refactor.
Los cuatro problemas concretos que causa.
Rompe la red. La prueba de caracterización del commit 1 documentaba lo que el sistema hacía hoy, redondeo torcido incluido. Para que este commit pase, hubo que editar esa prueba. En ese momento la red dejó de proteger: ya no se puede saber si algún otro cambio del PR alteró un comportamiento, porque las pruebas se movieron con el código.
Impide revertir. Si mañana el refactor causa un problema en producción, el instinto es revertir el PR. Pero revertirlo también deshace la corrección del redondeo, que quizá ya se comunicó a finanzas o se anunció a los clientes. Ahora hay que hacer una reversión selectiva bajo presión, que es cuando peor se hacen las cosas.
Confunde el diagnóstico. Si después del despliegue aparece una discrepancia en los totales, hay dos causas candidatas: el refactor y el redondeo. Con los commits separados, git bisect responde en minutos. Mezclados, alguien pasa una tarde leyendo diffs.
Engaña al revisor. El título del PR dice "Refactor", y un revisor razonable revisa un refactor distinto de como revisa un cambio de lógica: en el primero busca movimientos correctos, en el segundo busca casos límite. El cambio de redondeo tiene muchas probabilidades de pasar sin que nadie piense en qué hace con montos negativos, con cortesías de cero, o con la suma de varios boletos.
Qué comentar en la revisión. Con las cinco partes de la lección 4:
Bloqueante — commit
1e2f3a4. Este commit cambia comportamiento observable (el total de un VIP cambia) dentro de un PR de refactor, y para que pase hubo que editar la prueba de caracterización del commita1b2c3d— o sea que perdimos la red justo en el PR donde más la necesitábamos.¿Lo sacas a un PR aparte? Con eso este queda como un refactor puro, revertible de un botón, y el del redondeo se revisa mirando los casos límite que merece: montos con muchos decimales, cortesías, y la suma de varios boletos en la misma orden. Son dos PRs chicos en vez de uno mezclado.
Por qué funciona: la regla "refactorizar y cambiar comportamiento nunca van en el mismo commit" suena a purismo hasta que ves las cuatro consecuencias juntas. Y este es uno de los comentarios de revisión más útiles que puedes aprender a escribir, porque el error es muy común, muy fácil de detectar en el diff, y muy barato de corregir para el autor.
Resumen y siguiente paso
En esta lección aprendiste que refactorizar es cambiar la estructura sin cambiar el comportamiento observable, y que de esa definición sale la regla que más se viola: refactorizar y cambiar comportamiento nunca van en el mismo commit. Viste que la red no es cualquier prueba: las que están amarradas a la estructura son un candado que se rompe con cualquier mejora, y las únicas que sirven son las de comportamiento, que no mencionan las clases internas. Cuando no existen, se escriben primero, y se llaman pruebas de caracterización.
Recorriste la dirección A —hacia un patrón— en cinco commits sobre calculate_price: la red, extraer cada rama con firma uniforme, sustituir el condicional por una tabla —momento en el que la Strategy ya está completa, sin una sola clase—, y solo con evidencia concreta subir a clases y migrar a los otros consumidores. Cinco puntos donde se podía parar, y un primer paso minúsculo que es el que conviene proponer en una revisión.
Recorriste la dirección B —hacia afuera— sobre la jerarquía de exportadores: la red en comportamiento, contar las implementaciones y los niveles que nadie instancia, escribir la versión plana en paralelo, migrar un consumidor por commit, borrar los niveles intermedios, aplanar lo que sobre y borrar las pruebas del andamio. Y viste dos cosas que suelen pasar en esta dirección: que aplanar destapa decisiones que nadie tomó —el orden de las operaciones—, y que la disciplina exige anotar ese hallazgo y arreglarlo en otro commit.
Te llevas las tres preguntas que deciden la dirección —cuántas implementaciones reales, si el condicional crece, si el flujo se sigue con el dedo— más la cuarta, que en la práctica pesa más que todas: si alguien se atascó ahí recientemente. Y las tres reglas del paso pequeño: cada paso mergeable, un tipo de cambio por commit, y la renta doble pagada en el orden correcto —forma nueva primero, forma vieja al final—.
Antes de avanzar deberías poder: explicar por qué una prueba que menciona isinstance no sirve como red; describir el primer paso concreto de un refactor hacia Strategy; y decir por qué quitar una abstracción es el mismo trabajo de ingeniería que ponerla.
Lo que falta no es técnica. Tienes el vocabulario para nombrar, la fórmula para decirlo y el camino para proponerlo. Falta la parte que ninguna de las tres resuelve: del otro lado hay una persona. Alguien que escribió ese código con una razón, que quizá sabe algo que tú no, y que va a leer tu diagnóstico un martes por la tarde después de una reunión larga. La lección 7 trata de eso: cómo se ofrece un diagnóstico en forma de pregunta, cómo se recibe uno sin defenderse, y por qué el vocabulario mal usado —etiquetar por etiquetar— hace más daño que no tenerlo.
Recursos
- Refactoring: Improving the Design of Existing Code, 2ª edición (Martin Fowler) — el catálogo de refactorizaciones con sus pasos mecánicos. La segunda edición usa JavaScript y es la referencia de dónde salen "Extract Function", "Replace Conditional with Polymorphism" e "Inline Class".
- Refactoring to Patterns (Joshua Kerievsky) — el libro dedicado exactamente a esta lección, y el único conocido que trata en serio la dirección de salida: incluye "Inline Singleton" y varias refactorizaciones away from un patrón.
- Working Effectively with Legacy Code (Michael Feathers) — la fuente de la prueba de caracterización y de las técnicas para meter una red en código que no tiene ninguna. Es el manual del paso cero.
- Refactoring Guru — Replace Conditional with Polymorphism — el paso a paso de la refactorización central de la dirección A, con los estados intermedios explícitos.