Módulo 3: Patrones para el comportamiento que varía
2. Strategy: intercambiar el cómo
Descripción
Al terminar esta lección vas a poder hacer tres cosas con el patrón Strategy. Primero, reconocerlo en código ajeno aunque nadie lo haya etiquetado, porque vas a conocer sus tres piezas y dónde se esconde cada una. Segundo, implementarlo en su forma mínima en Python, sobre un caso real de Boletia, sin ceremonia de más. Y tercero —la parte que casi ninguna explicación de Strategy incluye y que aquí ocupa una sección entera— vas a saber cuándo no usarlo, es decir, cuándo el if que ya está ahí es la mejor decisión de ingeniería disponible.
Strategy es, con diferencia, el patrón que más vas a usar en tu carrera. Hay una razón estructural para eso: resuelve la situación más frecuente del software real, que es "hay varias formas de hacer lo mismo y hay que poder cambiar entre ellas". Cobrar con distintos proveedores. Ordenar una lista con distintos criterios. Comprimir con distintos algoritmos. Calcular impuestos según el país. Validar según el tipo de documento. Todas son la misma forma con distinto vestido, y cuando conoces la forma, el vestido deja de importar.
También es el patrón que más se usa mal, y por el mismo motivo: al ser tan general, se le encuentra lugar en cualquier parte. Un if de dos ramas puede convertirse en Strategy, técnicamente. La pregunta nunca es si se puede: es si se gana su lugar. Por eso esta lección tiene la anatomía y el costo pegados uno al otro, en vez de dejar el costo para una nota al pie.
Conexión con el módulo: la lección 1 te dejó el principio —separa lo que cambia de lo que no— y la habilidad de nombrar el eje de variación en el checkout de Boletia. Esta lección toma ese principio y le da su primera forma concreta. Aquí vas a ver la anatomía completa sobre los proveedores de pago, que es el caso que ya conociste en el módulo 1 cuando comparamos una revisión de código con y sin vocabulario. La lección 3 aplica lo mismo, con mucho más detalle y paso a paso, sobre las reglas de precio —el material central del módulo—. La lección 4 trae el patrón primo, Template Method, para cuando lo que varía no es el algoritmo entero sino unos pasos. Y la lección 6 va a poner en duda, con buenas razones, que en Python haga falta escribir clases para todo esto.
Cómo llegas al aeropuerto
Tienes un vuelo a las siete de la tarde y vives a treinta kilómetros del aeropuerto. Hay varias formas de llegar: tu propio auto, un taxi de aplicación, el metro más el autobús, o que te lleve un amigo.
Fíjate en lo que todas tienen en común. El objetivo es idéntico: estar en la terminal antes de cierta hora. La entrada también: sales del mismo lugar, a la misma hora, con las mismas maletas. Y la salida es la misma: tú, en el aeropuerto. Lo que cambia por completo es el cómo: qué haces, en qué orden, cuánto pagas, qué puede salir mal.
Fíjate ahora en lo que no es el aeropuerto. El aeropuerto no sabe cómo llegaste, ni le interesa. La aerolínea no tiene una recepción distinta para los que llegaron en taxi. Todo el mecanismo que hay después de tu llegada —documentar, seguridad, la sala— funciona exactamente igual sin importar cuál de las cuatro formas usaste. Esa indiferencia es el punto: quien recibe el resultado está desacoplado del método que lo produjo.
Y fíjate, por último, en quién elige. No elige el aeropuerto, ni el taxi, ni el metro. Eliges tú, y eliges según el contexto de ese día: si llueve, taxi; si vas sobrado de tiempo y corto de dinero, metro; si tu amigo se ofreció, tu amigo. La misma persona, la misma ruta, el mismo destino, y una elección distinta cada vez. Esa capacidad de decidir en el momento, y de cambiar de opinión sin rediseñar el aeropuerto, es exactamente lo que Strategy te da en el código.
Con esa imagen tienes las tres piezas del patrón sin que te las haya nombrado todavía: el contrato (salir de casa y terminar en el aeropuerto), las implementaciones (auto, taxi, metro, amigo) y el punto de elección (tú, esa mañana). Vamos a verlas en código y después les ponemos nombre.
Ejemplo trabajado: los tres proveedores de pago de Boletia
Este es el código que viste en el módulo 1, cuando comparamos cómo suena una observación en una revisión con y sin vocabulario compartido. Ahora vamos a hacerle de verdad lo que ahí solo se nombró.
El punto de partida. En checkout/checkout.py, la función que cobra:
# Archivo: checkout/checkout.py
def charge(order, provider_name):
"""Cobra una orden con el proveedor indicado. Devuelve el resultado del cobro."""
if provider_name == "stripe":
client = StripeClient(api_key=settings.STRIPE_KEY)
# Stripe trabaja en centavos y como entero, por eso el *100 y el int().
return client.create_charge(amount=int(order.total * 100), currency="MXN")
elif provider_name == "mercadopago":
client = MercadoPagoClient(token=settings.MP_TOKEN)
# MercadoPago quiere el monto como float y el concepto en un campo aparte.
return client.pay(order.total, description=f"Boletia #{order.id}")
elif provider_name == "cash":
# El pago en efectivo no cobra nada ahora: genera una referencia
# con la que el cliente va a pagar a una tienda de conveniencia.
reference = generate_cash_reference(order.id)
return {"status": "pending", "reference": reference}
else:
raise ValueError(f"Proveedor desconocido: {provider_name}")
Antes de tocarlo, quiero que notes algo que no salta a la vista: cada rama devuelve una cosa distinta. Stripe devuelve lo que devuelva su cliente. MercadoPago devuelve lo suyo. Efectivo devuelve un diccionario inventado aquí mismo. Quien llama a charge() recibe tres formas distintas de "resultado" y tiene que saber cuál le tocó, lo que significa que río abajo hay otro condicional por proveedor esperándolo. Ese detalle es más importante que el if en sí, y lo vamos a arreglar de paso.
Paso 1: definir el contrato. El contrato es la pregunta común y la forma de la respuesta. Aquí la pregunta es "cóbrame esta orden" y la respuesta va a ser un resultado uniforme, decidido por nosotros y no por cada API externa.
# Archivo: payments/provider.py
from typing import Protocol
from dataclasses import dataclass
@dataclass(frozen=True)
class ChargeResult:
"""Lo que Boletia entiende por 'resultado de un cobro'.
Es nuestro vocabulario, no el de ningún proveedor. Que cada API externa
devuelva lo que quiera: aquí adentro todos hablan este idioma.
"""
status: str # "paid" | "pending" | "failed"
external_id: str | None # el id del cobro en el proveedor, si lo hay
reference: str | None # referencia para pagar en tienda, si aplica
class PaymentProvider(Protocol):
"""El contrato: cualquier forma de cobrar en Boletia responde a esto."""
def charge(self, order) -> ChargeResult:
...
Dos decisiones vale la pena señalar aquí, porque son las que más se equivocan.
La primera: usamos Protocol y no una clase base abstracta con herencia. En Python, Protocol describe una forma —"cualquier objeto que tenga un método charge(order) que devuelva un ChargeResult sirve"— sin obligar a nadie a heredar de nada. Es lo que se llama tipado estructural, y es la forma más liviana de escribir un contrato en Python. Con una clase base abstracta funcionaría igual, pero cada implementación quedaría atada por herencia a nuestra jerarquía, y eso es una atadura que aquí no necesitamos. Volveremos sobre este tema en la lección 4, donde la herencia sí es el mecanismo del patrón y hay que hablar de sus riesgos.
La segunda: definimos ChargeResult nosotros. Podríamos haber dicho "que cada proveedor devuelva lo que devuelva". Hacerlo habría convertido esto en media Strategy: uniformamos la entrada pero no la salida, y quien llama sigue teniendo que saber con quién habló. Un contrato que solo cubre la mitad del intercambio no desacopla nada.
Paso 2: una implementación por forma de cobrar. Cada rama del if se muda a su propio archivo, casi tal cual estaba.
# Archivo: payments/stripe_provider.py
from payments.provider import ChargeResult
class StripeProvider:
def __init__(self, api_key: str, currency: str = "MXN"):
# La configuración entra por el constructor. Así el objeto se puede
# construir en pruebas con una llave falsa sin tocar settings.
self._client = StripeClient(api_key=api_key)
self._currency = currency
def charge(self, order) -> ChargeResult:
# Stripe cobra en centavos como entero: 350.50 pesos son 35050.
amount_in_cents = int(round(order.total * 100))
response = self._client.create_charge(
amount=amount_in_cents,
currency=self._currency,
)
# Traducimos la respuesta de Stripe a NUESTRO vocabulario.
return ChargeResult(
status="paid" if response["paid"] else "failed",
external_id=response["id"],
reference=None,
)
# Archivo: payments/mercadopago_provider.py
from payments.provider import ChargeResult
class MercadoPagoProvider:
def __init__(self, token: str):
self._client = MercadoPagoClient(token=token)
def charge(self, order) -> ChargeResult:
# MercadoPago recibe el monto como float y el concepto por separado.
response = self._client.pay(
order.total,
description=f"Boletia #{order.id}",
)
return ChargeResult(
status="paid" if response["status"] == "approved" else "failed",
external_id=str(response["payment_id"]),
reference=None,
)
# Archivo: payments/cash_provider.py
from payments.provider import ChargeResult
class CashProvider:
def __init__(self, reference_generator):
# Recibe la función que genera referencias en vez de importarla.
# Así en pruebas se le pasa una que devuelve siempre "REF-TEST".
self._generate_reference = reference_generator
def charge(self, order) -> ChargeResult:
# El efectivo no cobra ahora: emite una referencia y espera.
# Por eso el status es "pending" y no "paid": la orden todavía no
# está pagada, y el checkout tiene que saberlo.
reference = self._generate_reference(order.id)
return ChargeResult(
status="pending",
external_id=None,
reference=reference,
)
Detente en CashProvider un segundo, porque es el que enseña más. Es el proveedor que no cobra. En el if original eso quedaba disimulado —era una rama más—, pero es una diferencia enorme de comportamiento: después de un cobro con Stripe la orden está pagada, y después de uno en efectivo no lo está. El contrato lo hace visible: el status es "pending" y quien llame tiene que hacerse cargo de esa posibilidad para todos los proveedores, no solo para efectivo. La Strategy convirtió un caso especial escondido en una parte explícita del contrato. Eso, por sí solo, ya justificó buena parte del trabajo.
Paso 3: el punto de elección. Alguien tiene que decidir qué implementación se usa. En su forma más simple, un diccionario:
# Archivo: payments/registry.py
from payments.stripe_provider import StripeProvider
from payments.mercadopago_provider import MercadoPagoProvider
from payments.cash_provider import CashProvider
from utils.references import generate_cash_reference
def build_provider(provider_name: str):
"""Devuelve la implementación que corresponde al nombre guardado en la orden."""
providers = {
"stripe": lambda: StripeProvider(api_key=settings.STRIPE_KEY),
"mercadopago": lambda: MercadoPagoProvider(token=settings.MP_TOKEN),
"cash": lambda: CashProvider(reference_generator=generate_cash_reference),
}
if provider_name not in providers:
raise ValueError(f"Proveedor desconocido: {provider_name}")
# Cada valor del diccionario es una función que construye el objeto.
# Se llama solo la del proveedor elegido: no se instancian los tres.
return providers[provider_name]()
Sí, ahí sigue habiendo un condicional —un in sobre un diccionario, que es un condicional con otro traje—. Y está bien que siga ahí. Strategy no elimina la decisión; la mueve a un solo lugar y la separa de la ejecución. Antes, decidir y cobrar ocurrían en la misma función revuelta; ahora la decisión ocupa ocho líneas en un archivo que no hace nada más, y la ejecución vive en tres archivos que no deciden nada.
Cómo se construye ese objeto —y si ese diccionario debería ser una Factory con todas las letras, o un registro con auto-descubrimiento, o simplemente inyección de dependencias— es un problema distinto, con su propia familia de patrones. Es el módulo 4 completo. Aquí nos basta con que la elección esté aislada.
Paso 4: el checkout adelgaza.
# Archivo: checkout/checkout.py
from payments.registry import build_provider
def charge(order):
provider = build_provider(order.provider)
return provider.charge(order)
Dos líneas. Y —esto es lo que de verdad importa— dos líneas que no van a cambiar cuando llegue el cuarto proveedor.
Qué esperar. Vamos a ejecutarlo mentalmente con una orden concreta y a ver qué sale por cada camino.
# Una orden de ejemplo
order = Order(id=8812, total=350.50, provider="stripe", ...)
result = charge(order)
print(result)
# ChargeResult(status='paid', external_id='ch_3Nq...', reference=None)
# La misma orden, cambiando solo el proveedor guardado en ella:
order.provider = "cash"
result = charge(order)
print(result)
# ChargeResult(status='pending', external_id=None, reference='BOL-8812-4471')
Lo que tienes que notar es que la línea que llama es idéntica en los dos casos. charge(order) no cambió, no se enteró, no le importó. Lo único que cambió fue un dato de la orden. Esa indiferencia de quien llama respecto de quién ejecuta es la señal de que la Strategy está bien puesta: si para usar una implementación distinta tuvieras que escribir código distinto en el llamador, no tienes una Strategy, tienes un if con más pasos.
Y ahora la prueba de fuego, la que justifica todo el ejercicio. Llega un cuarto proveedor —transferencia bancaria SPEI—. ¿Qué hay que tocar?
payments/spei_provider.py ← archivo NUEVO
payments/registry.py ← una línea más en el diccionario
checkout.py no se abre. Los otros tres proveedores no se abren. El contrato no se abre. Se agrega un archivo y se registra. Compara eso con el punto de partida, donde el cuarto proveedor era un elif más dentro de la función que cobra.
Ahora la parte honesta, porque esto también hay que verlo. ¿Qué se pagó?
- Donde había un archivo con una función de veinte líneas, ahora hay seis archivos.
- Para entender qué pasa cuando alguien paga con Stripe, hay que abrir
checkout.py, luegoregistry.py, luegostripe_provider.py. Tres saltos donde antes había cero. - Alguien que entre nuevo al equipo tiene que aprender un concepto más: qué es
PaymentProvidery por qué existe. - Apareció un tipo nuevo,
ChargeResult, que hay que mantener y que va a tener que crecer cuando algún proveedor necesite devolver algo que hoy no está previsto.
¿Valió la pena aquí? Sí, y por razones concretas: hay tres implementaciones reales, con lógica de verdad distinta, en un dominio donde el negocio agrega proveedores, y viven en el archivo más delicado del sistema. Tres implementaciones, cambio probable, ubicación crítica. Es exactamente el perfil donde la indirección se paga sola.
¿Habría valido la pena con un proveedor? No, y con dos tampoco. Con uno solo, esto es el anti-patrón del plugin para una sola implementación que desmontaste en el módulo 2, con otro nombre.
Anatomía de una Strategy: las tres piezas
Ya la construiste; ahora vamos a nombrar lo que hiciste, porque el nombre es lo que te va a permitir reconocerla en código de otros.
Pieza 1: el contrato. Es la forma común: qué se pide y qué se devuelve. En nuestro caso, PaymentProvider con su único método charge(order) -> ChargeResult. En otros lenguajes se llama interfaz; en Python puede ser un Protocol, una clase base abstracta, o —en la práctica más común— nada escrito en absoluto, solo un acuerdo de que todos tienen un método con cierto nombre.
El contrato es la pieza que la gente subestima y es la más importante de las tres. Un contrato bien elegido hace que todo lo demás encaje sin fricción. Uno mal elegido convierte cada implementación nueva en una pelea. Dos criterios para acertar:
- Que la firma le sirva a todas las implementaciones, sin argumentos que la mitad ignora. Si tu método recibe cinco parámetros y cada implementación usa dos distintos, el contrato está mal cortado. Esto va a doler en la lección 3 y lo vamos a resolver ahí explícitamente.
- Que la salida sea tuya, no de las implementaciones. Si cada una devuelve su propio formato, no desacoplaste: solo escondiste el
ifun poco más lejos.
Pieza 2: las implementaciones. Una por cada forma de hacer la cosa. StripeProvider, MercadoPagoProvider, CashProvider. Cada una es autónoma: no sabe que las otras existen, no las importa, no las menciona. Esa autonomía es lo que hace que se puedan probar solas y que agregar una no toque las demás.
Hay una prueba rápida y despiadada para saber si tus implementaciones están bien separadas: ¿puedes borrar una y que las otras sigan funcionando? Si borrar CashProvider rompe StripeProvider, no son implementaciones independientes; son un condicional repartido en varios archivos, que es peor que el condicional original porque ahora además está disperso.
Pieza 3: el punto de elección. Alguien decide cuál se usa. Y aquí hay una distinción que casi nunca se explica, así que quiero dejarla clara: elegir es un problema aparte de ejecutar, y se puede resolver de varias maneras según de dónde venga la decisión.
| De dónde viene la decisión | Cómo se resuelve normalmente | Ejemplo en Boletia |
|---|---|---|
| De un dato guardado (un texto, un enum) | Un diccionario que mapea el dato a la implementación | order.provider es "stripe" → build_provider() |
| De quien llama, en el momento | Se pasa la implementación como argumento | export_report(sales, exporter=CsvExporter()) |
| De la configuración de la app | Se resuelve una vez al arrancar y se inyecta | El proveedor de correo, que no cambia por orden |
| De una regla más compleja | Una función de selección con su propia lógica | "Si el monto supera X y el cliente es extranjero, usa este" |
Las cuatro son legítimas. Lo que no es legítimo es que la elección viva desparramada en varios lugares: si el mapa de nombre a implementación aparece en tres archivos, agregar el cuarto proveedor vuelve a ser una cacería, que es justo lo que querías evitar.
Y una nota de vocabulario que te va a servir en revisiones de código: al objeto que usa la strategy sin saber cuál es —en nuestro caso el checkout— la literatura lo llama el contexto. No es un nombre especialmente feliz y no hace falta que lo uses, pero si alguien dice "el contexto no debería conocer las implementaciones concretas", ya sabes de qué está hablando: del checkout que no debe importar StripeProvider.
Qué cuesta exactamente
El módulo 2 te enseñó que cada abstracción se paga. Vamos a poner el recibo de esta con detalle, porque "cuesta complejidad" es una frase inútil a la hora de decidir.
Cuesta saltos de lectura. Es el costo principal y el más subestimado. Antes, para saber cómo se cobra con MercadoPago, abrías un archivo y leías veinte líneas. Ahora abres checkout.py, ves provider.charge(order), no sabes cuál es provider, vas a registry.py, ves el diccionario, encuentras MercadoPagoProvider, abres el tercer archivo. Ese recorrido tiene un costo real que se paga cada vez que alguien lee el código, y en un equipo de seis personas eso es muchas veces. La indirección no se paga una vez: se paga en cuotas, para siempre.
Cuesta un concepto más en la cabeza de quien entra. PaymentProvider es una idea que no existía. Quien llega nuevo tiene que aprender que existe, para qué está, y cuál es la relación entre el contrato y las implementaciones. Con un patrón, ese costo es bajo porque el patrón tiene nombre y probablemente ya lo conoce —ese es literalmente el argumento del módulo 1—. Con una abstracción casera sin nombre, el costo es mucho más alto.
Cuesta un lugar más donde esconder un bug. El contrato es código, y el código tiene errores. Si ChargeResult mapea mal el estado —digamos que un cobro rechazado de MercadoPago cae en "paid" porque el campo se llamaba distinto—, el bug vive en la capa de traducción, que es justo la capa que nadie mira porque "solo traduce". Cada pieza intermedia es superficie donde algo puede fallar en silencio.
Cuesta la posibilidad de haber elegido el eje equivocado. Este es el costo caro, el que el módulo 2 llamó abstracción prematura. Si dentro de un año Boletia necesita cobrar en cuotas, y las cuotas no son un proveedor sino una modalidad que atraviesa a los tres, el eje "un objeto por proveedor" te queda chico. Entonces empiezan los parches: un parámetro installments en charge() que dos de los tres proveedores ignoran, una bandera, un if dentro de una implementación. La abstracción sigue ahí, pero ya no ordena nada: solo cobra su costo.
Y ahora el otro lado del recibo, para que la comparación sea honesta: el if gigante también cuesta, y hay que ponerlo en la misma balanza.
El if de veinte líneas | La Strategy | |
|---|---|---|
| Leer un caso concreto | Barato: un archivo, todo a la vista | Caro: dos o tres saltos |
| Leer todos los casos juntos y compararlos | Barato: están uno debajo del otro | Caro: hay que abrir varios archivos |
| Agregar un caso nuevo | Caro: se modifica un archivo delicado y hay que reprobar todo | Barato: archivo nuevo, una línea de registro |
| Probar un caso solo | Caro o imposible: arrastra el contexto de los demás | Barato: se construye el objeto y se llama |
| Borrar un caso | Riesgoso: hay que cortar en el lugar exacto | Barato: se borra el archivo y su línea |
| Entender el sistema completo la primera vez | Barato | Caro |
Mira esa tabla con atención, porque contiene la decisión entera. La Strategy no es mejor: es mejor en unas cosas y peor en otras. Gana cuando lo que haces seguido es agregar, borrar y probar casos. Pierde cuando lo que haces seguido es leer para entender. Un condicional en un rincón estable que se lee de vez en cuando y nunca crece está mejor como está. Un condicional en el corazón del sistema que crece cada trimestre está mejor como Strategy.
La pregunta correcta no es "¿cuál es más limpio?". Es "¿cuál de estas operaciones voy a hacer más veces en los próximos dos años?".
Cuándo el if es mejor
Esta sección existe porque la mayoría de las explicaciones de Strategy terminan en la anterior, y ese es exactamente el motivo por el que hay tanto código sobre-diseñado. Aquí van los casos donde dejar el condicional es la decisión correcta, no la perezosa.
Cuando hay dos ramas y las dos son estables. Un if is_weekend: ... else: ... no es un eje de variación: es una decisión binaria de la que no van a salir más casos. Convertirlo en WeekdayStrategy y WeekendStrategy agrega dos archivos y un contrato para modelar algo que el idioma ya modelaba con una palabra. La regla de tres del módulo 2 dice esto mismo desde el otro lado: con dos casos todavía no sabes cuál es el eje.
Cuando las ramas son de una línea. Compara:
# Esto está bien. No necesita nada.
def format_amount(amount, currency):
if currency == "MXN":
return f"${amount:,.2f} MXN"
elif currency == "USD":
return f"US${amount:,.2f}"
return f"{amount:,.2f} {currency}"
Tres ramas, tres líneas, cero lógica. La estructura de una Strategy —un contrato, tres clases, un registro— pesaría más que el problema que resuelve. Cuando el cuerpo de cada rama es una expresión, lo que tienes no es comportamiento variable: es datos. Y los datos se resuelven con un diccionario, no con un patrón:
FORMATS = {
"MXN": "${:,.2f} MXN",
"USD": "US${:,.2f}",
}
def format_amount(amount, currency):
template = FORMATS.get(currency, "{:,.2f} " + currency)
return template.format(amount)
Esa tabla es la versión honesta. Si tu "Strategy" no tiene comportamiento —solo constantes distintas—, escribe la tabla.
Cuando el condicional es una guarda y no una decisión de negocio. Mira estos dos:
# Guarda. Protege la ejecución. NO es un eje de variación.
if customer.phone is None:
return # no hay a dónde mandar el SMS
# Decisión de negocio. Cada tipo trae reglas propias. SÍ puede serlo.
if ticket.kind == "vip":
...
Se parecen —los dos son if— y no tienen nada que ver. El primero valida una precondición; nunca va a tener una tercera rama porque un teléfono está o no está. El segundo distingue conceptos del dominio, y el dominio inventa conceptos nuevos. Confundir los dos es el error más frecuente después de este módulo, y la lección 7 se dedica entera a la distinción.
Cuando todavía no sabes cuál es el eje. Si tienes dos casos y sospechas que vendrán más, pero no tienes idea de en qué se van a diferenciar, espera. Un if de dos ramas es reversible: cuando llegue el tercer caso, la forma correcta se va a hacer evidente y el refactor cuesta media hora. Una abstracción en el eje equivocado no es reversible con la misma facilidad, porque para entonces habrá tres módulos dependiendo de ella. Es el argumento central del módulo 2 y aquí se aplica sin cambios: cuando la información es incompleta, elige la opción que se pueda deshacer barato.
Cuando el condicional está aislado y nadie lo toca. Un if de seis ramas en un script que corre una vez al mes, que escribió una persona hace tres años y que nadie ha modificado desde entonces, no es un problema. Es feo, y ser feo no es un costo. El costo aparece cuando el código se lee seguido o se modifica seguido. Si no ocurre ninguna de las dos, refactorizarlo es gastar tu tiempo y el riesgo de romper algo a cambio de estética. El módulo 8 tiene una lección con este nombre exacto: cuándo dejar el código feo en paz.
La regla de bolsillo para todo lo anterior, si te quedas con una sola frase: Strategy se gana su lugar cuando hay al menos tres implementaciones reales, con lógica de verdad distinta, en código que se modifica seguido. Si falta cualquiera de las tres condiciones, revisa dos veces antes de mover una línea.
Errores comunes
Crear la interfaz antes que las implementaciones (de diseño). Qué pasa: alguien decide que "esto va a ser una Strategy", escribe primero el Protocol con la firma que le parece razonable, y después intenta acomodar las implementaciones dentro. Casi siempre la segunda encaja a presión y la tercera no encaja, así que aparece un parámetro opcional, luego un **kwargs, y en un semestre el contrato tiene seis argumentos de los que cada implementación usa dos. Por qué pasa: es el orden en que se enseña el patrón —primero el diagrama, después el código— y el orden en que se ve en los libros. Pero es el orden inverso al que funciona. Cómo detectarlo: si alguna implementación recibe un argumento que ignora por completo, el contrato se escribió antes de tiempo. Cómo corregirlo: escribe primero las implementaciones concretas, con las firmas que cada una necesita de verdad, y extrae el contrato después, cuando ya puedes ver qué tienen en común. El contrato es un descubrimiento, no una decisión previa. Es exactamente el argumento de la lección 4 del módulo 1: los patrones se descubren, no se inventan.
Uniformar la entrada pero no la salida (de implementación). Qué pasa: se crea el contrato con un método común, todas las implementaciones lo cumplen, y el if desaparece del lugar donde se ejecuta… para reaparecer intacto donde se usa el resultado, porque cada implementación devuelve el objeto crudo de su API externa. El código quedó con más archivos y el mismo acoplamiento. Por qué pasa: definir el tipo de retorno propio se siente como trabajo extra que no resuelve nada visible, y en el momento del refactor la tentación de "devolver lo que ya devuelve" es fuerte. Cómo detectarlo: busca if o isinstance sobre el resultado. Si quien llama tiene que preguntar de qué tipo es lo que recibió, el contrato está incompleto. También: si el resultado tiene una llave que solo existe para un proveedor, ahí hay una fuga. Cómo corregirlo: define el tipo de retorno del lado de tu dominio —como hicimos con ChargeResult— y que cada implementación traduzca. Esa traducción, cuando se vuelve gruesa, tiene su propio nombre y su propio patrón: es un Adapter, y es el módulo 5.
Meter la elección dentro de las implementaciones (de estructura). Qué pasa: para "que quede autocontenido", cada implementación incluye un método tipo can_handle(order) y el sistema recorre todas preguntándoles quién se hace cargo. Suena elegante y a veces lo es, pero introduce dos problemas: el orden del recorrido pasa a importar —si dos dicen que sí, gana la primera, y eso depende del orden de registro— y la lógica de selección queda repartida en N archivos, de modo que para entender por qué se eligió un proveedor hay que leerlos todos. Por qué pasa: parece más "orientado a objetos" que un diccionario, y el diccionario se siente pobre. Cómo detectarlo: si para responder "¿por qué esta orden se cobró con MercadoPago?" tienes que abrir cada implementación, la selección está mal ubicada. Cómo corregirlo: mantén la elección en un solo lugar visible, aunque ese lugar tenga un if o un diccionario. Repito la frase de la sección de anatomía porque es la que más se olvida: Strategy no elimina la decisión, la aísla. Un if de selección de ocho líneas en un archivo dedicado no es una derrota; es exactamente el resultado buscado.
Ejercicios
Ejercicio 1 — Identifica las tres piezas en código que no las nombra. Aquí hay un fragmento de la parte de reportes de Boletia. Sin cambiar nada, señala dónde está el contrato, dónde las implementaciones y dónde el punto de elección. Después responde: ¿qué habría que tocar para agregar un exportador de JSON?
# Archivo: reports/service.py
def export_attendees(event_id, fmt, out_path):
rows = fetch_attendees(event_id)
writer = WRITERS[fmt]
writer(rows, out_path)
def write_csv(rows, out_path):
with open(out_path, "w", newline="") as f:
w = csv.writer(f)
w.writerow(["name", "email", "ticket_kind"])
w.writerows([[r.name, r.email, r.kind] for r in rows])
def write_xlsx(rows, out_path):
book = Workbook()
sheet = book.active
sheet.append(["name", "email", "ticket_kind"])
for r in rows:
sheet.append([r.name, r.email, r.kind])
book.save(out_path)
WRITERS = {"csv": write_csv, "xlsx": write_xlsx}
Ver solución
El contrato no está escrito en ninguna parte, pero existe: es la firma (rows, out_path) -> None. Es un contrato implícito, sostenido por acuerdo y no por declaración. En Python esto es completamente normal y no es un defecto. Si quisieras hacerlo explícito, bastaría un alias de tipo: Writer = Callable[[list[Attendee], str], None].
Las implementaciones son write_csv y write_xlsx. Fíjate en que son funciones, no clases, y aun así esto es una Strategy con todas las letras: hay varias formas de hacer lo mismo, intercambiables, con una forma común. Que no haya jerarquía de clases no le quita nada. La lección 6 desarrolla precisamente este punto.
El punto de elección es el diccionario WRITERS más la línea writer = WRITERS[fmt]. Está en un solo lugar, es visible de un vistazo, y agregar una entrada no toca ninguna otra.
Para agregar JSON: escribir write_json(rows, out_path) y agregar "json": write_json al diccionario. Nada más. export_attendees no se toca, y los otros dos escritores tampoco.
Por qué funciona: este ejercicio te muestra que la Strategy no es un conjunto de clases con un diagrama. Es una forma: contrato, implementaciones, elección. Cuando reconoces la forma, la reconoces en funciones sueltas, en diccionarios, en callbacks y en argumentos con nombre. Y de paso: si tu instinto fue decir "esto no es una Strategy porque no hay clases", acabas de encontrar el sesgo que la lección 6 desarma.
Ejercicio 2 — Decide en cinco casos. Para cada uno, responde si extraerías una Strategy o dejarías el condicional, y justifica en una línea con las condiciones de esta lección (tres implementaciones reales, lógica de verdad distinta, código que se modifica seguido).
(a) Una función que decide el color de una etiqueta de estado: verde si está pagada, amarillo si está pendiente, rojo si falló.
(b) El cálculo de comisión de Boletia, que hoy tiene tres esquemas —porcentaje fijo, porcentaje por tramos, cuota fija por boleto— y Ventas pide uno nuevo cada temporada.
(c) Un if que decide si mostrar el precio con impuestos incluidos o desglosados, según una preferencia del organizador.
(d) La validación de un documento de identidad, con reglas distintas para pasaporte, credencial de elector, licencia y documento extranjero.
(e) Un if debug: print(...) repartido por el código.
Ver solución
(a) Dejar el condicional. Tres ramas, sí, pero el cuerpo de cada una es una constante. No hay comportamiento: hay datos. La forma correcta es un diccionario STATUS_COLORS = {"paid": "green", ...}. Una Strategy aquí sería una jerarquía de clases para devolver un string.
(b) Extraer. Cumple las tres condiciones con holgura: tres implementaciones reales hoy, lógica de verdad distinta —un porcentaje por tramos no se parece a una cuota fija—, y un eje que crece cada temporada por pedido del negocio. Además el eje tiene nombre en el dominio: "esquema de comisión". Es el perfil de libro.
(c) Dejar el condicional. Es booleano por naturaleza: los impuestos se muestran incluidos o desglosados y no hay una tercera forma. Un if de dos ramas que no puede crecer no es un eje de variación. Si además el cuerpo de cada rama es corto, la discusión ni siquiera empieza.
(d) Extraer, probablemente. Cuatro tipos, cada uno con reglas de verdad distintas —longitudes, dígitos verificadores, vigencias, formatos—, y los documentos extranjeros van a traer más casos. Ahora bien, mira antes si las reglas son configuración disfrazada: si las cuatro son "una expresión regular más una longitud", entonces son datos y la respuesta correcta es una tabla, no un patrón. Esa revisión —¿es comportamiento o son datos con otro traje?— es la que hay que hacer siempre antes de extraer, y es la que distingue una buena decisión de una aplicación mecánica del patrón.
(e) Dejar el condicional, y probablemente cambiarlo por otra cosa. No es un eje de variación: es instrumentación. La solución no es una LoggingStrategy sino usar el módulo de logging del lenguaje, que ya resolvió el problema con niveles configurables. Vale la pena guardar el reflejo: antes de inventar un patrón, revisa si la librería estándar ya trae la solución.
Por qué funciona: dos de los cinco casos merecen extracción y tres no, y uno de los que sí tiene una trampa dentro. Esa proporción es realista. Si extraes en los cinco, tienes un martillo; si no extraes en ninguno, tienes un condicional de doscientas líneas esperándote.
Ejercicio 3 — Refactoriza un condicional real y mide el costo. Toma este fragmento del envío de notificaciones de Boletia y conviértelo en una Strategy con las tres piezas. Después responde por escrito: cuántos archivos hay ahora, cuántos saltos hacen falta para entender el envío por SMS, y qué se necesitaría para agregar WhatsApp.
# Archivo: notifications/notifier.py
def send(customer, message, channel):
if channel == "email":
smtp = SmtpClient(host=settings.SMTP_HOST)
smtp.send(to=customer.email, subject="Tu compra en Boletia", body=message)
return True
elif channel == "sms":
if customer.phone is None:
return False
api = SmsGateway(key=settings.SMS_KEY)
api.send_text(number=customer.phone, text=message[:160])
return True
elif channel == "push":
if customer.push_token is None:
return False
push = PushService(cert=settings.PUSH_CERT)
push.notify(token=customer.push_token, payload={"body": message})
return True
else:
raise ValueError(f"Canal desconocido: {channel}")
Ver solución
Una implementación razonable:
# Archivo: notifications/channel.py
from typing import Protocol
class NotificationChannel(Protocol):
def is_available_for(self, customer) -> bool:
"""¿Este cliente puede recibir por aquí? (tiene teléfono, tiene token…)"""
...
def send(self, customer, message: str) -> bool:
"""Envía. Devuelve si se logró."""
...
# Archivo: notifications/sms_channel.py
class SmsChannel:
def __init__(self, gateway):
self._gateway = gateway
def is_available_for(self, customer) -> bool:
# Sin teléfono no hay SMS. Se pregunta ANTES de intentar enviar,
# para que "no se pudo" y "falló el envío" no se confundan.
return customer.phone is not None
def send(self, customer, message: str) -> bool:
# El límite de 160 caracteres es del canal, no del mensaje:
# por eso el recorte vive aquí y no en quien llama.
self._gateway.send_text(number=customer.phone, text=message[:160])
return True
Los otros dos canales siguen la misma forma, y la elección va a un diccionario en notifications/registry.py.
Lo que este ejercicio enseña de verdad está en el contrato, no en las clases. El condicional original mezclaba dos preguntas distintas dentro de la misma rama: "¿se puede enviar por aquí?" y "envía". Las dos devolvían False de formas que quien llamaba no podía distinguir: un False por falta de teléfono y un False por error de la pasarela se veían igual. Al escribir el contrato te ves obligado a separarlas, y el resultado es más correcto que el punto de partida. Ese es un beneficio de los patrones que casi nunca se menciona: no es que ordenen el código, es que obligan a explicitar decisiones que estaban implícitas.
Las respuestas medidas. Archivos: pasaste de uno a seis (contrato, tres canales, registro, y el notificador que quedó de dos líneas). Saltos para entender el SMS: dos —del notificador al registro, del registro al canal— donde antes había cero. Para agregar WhatsApp: un archivo nuevo y una línea en el registro; notifier.py no se abre.
¿Valió la pena? Aquí sí: tres canales reales, con lógica distinta y con disponibilidad distinta por cliente, en un flujo que el negocio amplía. Pero fíjate en que la pregunta "¿a quién hay que avisarle?" —al cliente, al organizador, a facturación— no la resuelve esta Strategy. Esa es otra cosa, con otro patrón, y es el módulo 6.
Por qué funciona: es el primer refactor completo que haces solo, y termina con una medición en vez de con una sensación. Acostúmbrate a cerrar así cada refactor: número de archivos, número de saltos, costo de agregar el caso siguiente. Con esos tres números puedes defender o descartar cualquier patrón.
Resumen y siguiente paso
En esta lección conociste Strategy, el patrón más usado del catálogo, con la imagen de las cuatro formas de llegar al aeropuerto: mismo punto de partida, mismo destino, caminos distintos, y un aeropuerto al que no le interesa cómo llegaste.
Construiste una Strategy completa sobre los tres proveedores de pago de Boletia, en cuatro pasos: definir el contrato —incluyendo el tipo de retorno propio, ChargeResult, que es la mitad que se olvida—, mudar cada rama a su implementación, aislar la elección en un solo lugar, y ver cómo el checkout quedaba en dos líneas que no van a cambiar con el cuarto proveedor. De paso descubriste algo que el if original escondía: que el pago en efectivo no deja la orden pagada, y que el contrato obliga a hacerlo explícito.
Nombraste las tres piezas —contrato, implementaciones, punto de elección— con dos criterios para acertar en el contrato: que la firma le sirva a todas y que la salida sea tuya. Y pusiste el recibo completo: saltos de lectura, un concepto más, una capa más donde esconder un bug, y el riesgo caro de elegir el eje equivocado. La tabla comparativa con el if es la que resume la decisión: la Strategy no es mejor, es mejor en unas operaciones y peor en otras, y ganas eligiendo según cuál vas a hacer más veces.
Y viste los cinco casos donde el if es la decisión correcta: dos ramas estables, ramas de una línea (que son datos, no comportamiento), guardas en vez de decisiones de negocio, ejes que todavía no conoces, y código aislado que nadie toca.
Antes de avanzar deberías poder: señalar las tres piezas en código ajeno aunque estén hechas con funciones y un diccionario; escribir el recibo de costos de una Strategy que estés considerando; y decir la regla de bolsillo —tres implementaciones reales, lógica de verdad distinta, código que se modifica seguido—.
Lo que viste aquí fue la anatomía en un caso relativamente amable: los tres proveedores de pago tienen la misma firma natural, charge(order). La lección 3 aplica el patrón al material central del módulo, las cuatro reglas de precio, donde el contrato no sale gratis: cada regla necesita datos distintos, una de ellas puede fallar, y encontrar una firma que le sirva a las cuatro es la parte difícil y la más instructiva. Vamos a hacerlo paso a paso, sin saltarnos el momento incómodo.
Recursos
- Refactoring Guru — Strategy — el diagrama y la explicación canónica, con ejemplos en varios lenguajes. Buen complemento visual de la sección de anatomía.
- Python typing — Protocols (PEP 544) — la especificación del tipado estructural que usamos para el contrato. Explica por qué
Protocolno obliga a heredar y cuándo conviene frente a una clase base abstracta. - Martin Fowler — Replace Conditional with Polymorphism — la ficha del refactor con sus pasos mecánicos. Es la receta de movimientos pequeños detrás de lo que hicimos en el ejemplo trabajado.
- Brandon Rhodes — Composition Over Inheritance — el tratamiento en Python de la idea que sostiene a Strategy: variar el comportamiento componiendo objetos —o funciones— en vez de heredar. Es la antesala natural de la lección 6.