Patrones de diseño en Python: GoF, Strategy, Observer y más
- ¿Qué son los patrones de diseño y por qué importan?
- Creacionales: Singleton, Factory y Builder
- Estructurales: Adapter, Decorator y Facade
- Comportamiento: Observer, Strategy y Command
- Patrones idiomáticos de Python
- Cuándo NO usar patrones de diseño
- Principios SOLID y su relación con los patrones
- Preguntas frecuentes
En 1994 cuatro ingenieros publicaron un libro que cambió cómo pensamos el software orientado a objetos. El GoF —Gamma, Helm, Johnson y Vlissides— documentó 23 patrones que aparecían una y otra vez en código bien diseñado. Treinta años después, esos patrones siguen siendo relevantes, pero Python los implementa de forma muy diferente a cómo los describieron originalmente. Algunas veces con menos código. Otras veces directamente con construcciones del propio lenguaje. Y en unos pocos casos, con patrones que en Python directamente no tienen sentido.
Esta lección no es un catálogo de los 23 patrones. Es una guía práctica de los que realmente usarás, con código Python moderno, y con honestidad sobre cuándo el lenguaje ya resuelve el problema sin necesidad de introducir una clase abstracta extra.
¿Qué son los patrones de diseño y por qué importan?
Un patrón de diseño es una solución reutilizable a un problema recurrente de diseño de software. No es un algoritmo —no te dice exactamente qué código escribir— sino una plantilla de solución que puedes adaptar a tu contexto. El patrón Observer, por ejemplo, describe cómo estructurar la relación entre un objeto que cambia de estado y los objetos que necesitan ser notificados de ese cambio. Tú decides los nombres, los tipos, la implementación concreta.
El valor de los patrones no está en el código que generan, sino en el vocabulario que crean. Cuando digo "voy a usar un Strategy aquí", cualquier desarrollador que conozca el patrón entiende inmediatamente qué está pasando: hay un algoritmo intercambiable, la lógica de selección está separada de la lógica de ejecución. Eso es comunicación de alta densidad.
# ── Por qué los patrones existen: el problema del código sin estructura ──
# Código sin patrón: lógica de creación duplicada en todas partes
def procesar_pago_tarjeta(monto, numero, cvv):
conexion = ConexionStripe()
resultado = conexion.cobrar(monto, numero, cvv)
return resultado
def procesar_pago_paypal(monto, email):
conexion = ConexionPayPal()
resultado = conexion.ejecutar_pago(monto, email)
return resultado
def procesar_pago_bizum(monto, telefono):
conexion = ConexionBizum()
resultado = conexion.transferir(monto, telefono)
return resultado
# Problema: si añades un nuevo proveedor, tienes que recordar todos los
# sitios donde crearías un objeto de conexión. Y si la interfaz de
# ConexionStripe cambia, buscas los 47 sitios donde está referenciada.
# ── Con Factory: la creación centralizada ──
class ProcesorPago:
"""Factory que centraliza la creación de procesadores."""
_procesadores = {} # registro de procesadores
@classmethod
def registrar(cls, nombre: str, procesador_class):
cls._procesadores[nombre] = procesador_class
@classmethod
def crear(cls, nombre: str) -> "PasarelaPago":
if nombre not in cls._procesadores:
raise ValueError(f"Procesador '{nombre}' no registrado")
return cls._procesadores[nombre]()
# Registro de implementaciones (podría cargarse desde config)
ProcesorPago.registrar("stripe", PasarelaStripe)
ProcesorPago.registrar("paypal", PasarelaPayPal)
ProcesorPago.registrar("bizum", PasarelaBizum)
# Uso: el código de negocio no conoce las implementaciones concretas
procesador = ProcesorPago.crear("stripe")
procesador.cobrar(monto=29.99, datos=datos_pago)
# Añadir un nuevo proveedor: una línea de registro. Nada más.
Los 23 patrones del GoF se dividen en tres familias según el problema que resuelven. Los creacionales abstraen el proceso de instanciación de objetos. Los estructurales describen cómo componer clases y objetos para construir estructuras más grandes. Los de comportamiento definen cómo los objetos colaboran y se distribuyen responsabilidades.
Creacionales: Singleton, Factory y Builder
Los patrones creacionales abstraen y encapsulan el proceso de creación de objetos. En Java, donde los constructores son rígidos y la herencia es la única forma de variar comportamiento, estos patrones son imprescindibles. En Python, son igualmente útiles pero suelen implementarse con menos código.
# ═══════════════════════════════════════════════
# SINGLETON — una sola instancia por proceso
# ═══════════════════════════════════════════════
# Implementación con __new__
class Configuracion:
_instancia = None
def __new__(cls):
if cls._instancia is None:
cls._instancia = super().__new__(cls)
cls._instancia._datos = {}
return cls._instancia
def set(self, clave: str, valor) -> None:
self._datos[clave] = valor
def get(self, clave: str, default=None):
return self._datos.get(clave, default)
c1 = Configuracion()
c2 = Configuracion()
c1.set("debug", True)
print(c2.get("debug")) # True — misma instancia
print(c1 is c2) # True
# ── La forma Python: un módulo ya ES un singleton ──
# config.py
_datos: dict = {}
def set(clave: str, valor) -> None:
_datos[clave] = valor
def get(clave: str, default=None):
return _datos.get(clave, default)
# En cualquier módulo:
# import config
# config.set("debug", True)
# config.get("debug") ← siempre el mismo estado
# ═══════════════════════════════════════════════
# FACTORY METHOD — crear sin clase concreta
# ═══════════════════════════════════════════════
from abc import ABC, abstractmethod
class Notificacion(ABC):
@abstractmethod
def enviar(self, mensaje: str) -> None: ...
class NotificacionEmail(Notificacion):
def enviar(self, mensaje: str) -> None:
print(f"📧 Email: {mensaje}")
class NotificacionSMS(Notificacion):
def enviar(self, mensaje: str) -> None:
print(f"📱 SMS: {mensaje}")
class NotificacionPush(Notificacion):
def enviar(self, mensaje: str) -> None:
print(f"🔔 Push: {mensaje}")
# Factory con registro de clases (más Pythónico que if/elif)
class NotificacionFactory:
_registro: dict[str, type[Notificacion]] = {
"email": NotificacionEmail,
"sms": NotificacionSMS,
"push": NotificacionPush,
}
@classmethod
def crear(cls, canal: str) -> Notificacion:
if canal not in cls._registro:
raise ValueError(f"Canal desconocido: {canal!r}")
return cls._registro[canal]()
@classmethod
def registrar(cls, canal: str, clase: type[Notificacion]) -> None:
"""Extensión sin modificar la Factory — principio Open/Closed."""
cls._registro[canal] = clase
# Uso:
notif = NotificacionFactory.crear("email")
notif.enviar("Tu pedido está listo")
# ── Abstract Factory: familias de objetos relacionados ──
class TemaUI(ABC):
@abstractmethod
def crear_boton(self) -> "Boton": ...
@abstractmethod
def crear_dialogo(self) -> "Dialogo": ...
class TemaOscuro(TemaUI):
def crear_boton(self): return BotonOscuro()
def crear_dialogo(self): return DialogoOscuro()
class TemaClaro(TemaUI):
def crear_boton(self): return BotonClaro()
def crear_dialogo(self): return DialogoClaro()
# El código cliente usa la fábrica sin conocer los productos concretos:
def renderizar_ui(tema: TemaUI):
boton = tema.crear_boton()
dialogo = tema.crear_dialogo()
boton.render()
dialogo.mostrar()
# ═══════════════════════════════════════════════
# BUILDER — construcción paso a paso
# ═══════════════════════════════════════════════
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class ConsultaSQL:
"""Builder para consultas SQL — method chaining."""
_tabla: str = ""
_campos: list[str] = field(default_factory=list)
_filtros: list[str] = field(default_factory=list)
_orden: str = ""
_limite: Optional[int] = None
def tabla(self, nombre: str) -> "ConsultaSQL":
self._tabla = nombre
return self
def campos(self, *nombres: str) -> "ConsultaSQL":
self._campos.extend(nombres)
return self
def donde(self, condicion: str) -> "ConsultaSQL":
self._filtros.append(condicion)
return self
def ordenar_por(self, campo: str) -> "ConsultaSQL":
self._orden = campo
return self
def limite(self, n: int) -> "ConsultaSQL":
self._limite = n
return self
def build(self) -> str:
campos = ", ".join(self._campos) if self._campos else "*"
sql = f"SELECT {campos} FROM {self._tabla}"
if self._filtros:
sql += " WHERE " + " AND ".join(self._filtros)
if self._orden:
sql += f" ORDER BY {self._orden}"
if self._limite:
sql += f" LIMIT {self._limite}"
return sql
# Uso — mucho más legible que un constructor con 8 parámetros:
consulta = (
ConsultaSQL()
.tabla("usuarios")
.campos("nombre", "email", "fecha_alta")
.donde("activo = 1")
.donde("pais = 'ES'")
.ordenar_por("fecha_alta DESC")
.limite(50)
.build()
)
print(consulta)
# SELECT nombre, email, fecha_alta FROM usuarios
# WHERE activo = 1 AND pais = 'ES'
# ORDER BY fecha_alta DESC LIMIT 50
Estructurales: Adapter, Decorator y Facade
Los patrones estructurales describen cómo componer objetos para formar estructuras más grandes. Son especialmente útiles cuando integras código de terceros, cuando necesitas añadir comportamiento sin modificar código existente, o cuando quieres simplificar una API compleja.
# ═══════════════════════════════════════════════
# ADAPTER — interfaces incompatibles → compatible
# ═══════════════════════════════════════════════
# Tienes una API legacy que devuelve XML, y tu sistema espera JSON
class APILegacy:
"""API antigua — no puedes modificarla."""
def obtener_datos(self) -> str:
return 'Ana '
class APIModerna:
"""Interfaz que espera tu sistema."""
def get_usuarios(self) -> list[dict]:
raise NotImplementedError
class AdaptadorXMLaJSON(APIModerna):
"""Adapta la API legacy a la interfaz moderna."""
def __init__(self, api_legacy: APILegacy):
self._legacy = api_legacy
def get_usuarios(self) -> list[dict]:
import xml.etree.ElementTree as ET
xml_str = self._legacy.obtener_datos()
root = ET.fromstring(xml_str)
return [
{"id": int(u.get("id")), "nombre": u.find("nombre").text}
for u in root.findall("usuario")
]
# Código cliente: trabaja con APIModerna sin saber que hay XML por debajo
def mostrar_usuarios(api: APIModerna) -> None:
for user in api.get_usuarios():
print(f" {user['id']}: {user['nombre']}")
adaptador = AdaptadorXMLaJSON(APILegacy())
mostrar_usuarios(adaptador) # funciona transparentemente
# ── Duck typing: cuando Python no necesita el Adapter ──
# Si el objeto ya tiene los métodos necesarios, Python lo acepta
# sin forzar herencia de una clase abstracta. El "adaptador" puede
# ser simplemente un objeto con los métodos correctos.
class ConexionPostgres:
def execute(self, sql: str) -> list: ...
class ConexionSQLite:
def execute(self, sql: str) -> list: ...
# Ambas son compatibles directamente — no necesitas Adapter
# porque Python usa duck typing, no tipado nominal.
# ═══════════════════════════════════════════════
# DECORATOR — añadir responsabilidades dinámicamente
# ═══════════════════════════════════════════════
import functools
import time
from typing import Callable, TypeVar, ParamSpec
P = ParamSpec("P")
T = TypeVar("T")
# Decoradores funcionales (la forma Python)
def cronometrar(fn: Callable[P, T]) -> Callable[P, T]:
"""Mide el tiempo de ejecución de una función."""
@functools.wraps(fn)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
inicio = time.perf_counter()
resultado = fn(*args, **kwargs)
duracion = time.perf_counter() - inicio
print(f"⏱ {fn.__name__}: {duracion:.4f}s")
return resultado
return wrapper
def reintentar(veces: int = 3, espera: float = 1.0):
"""Reintenta la función N veces ante excepciones."""
def decorador(fn: Callable[P, T]) -> Callable[P, T]:
@functools.wraps(fn)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> T:
ultimo_error = None
for intento in range(veces):
try:
return fn(*args, **kwargs)
except Exception as e:
ultimo_error = e
if intento < veces - 1:
time.sleep(espera)
raise ultimo_error
return wrapper
return decorador
def cachear(fn: Callable[P, T]) -> Callable[P, T]:
"""Cache simple en memoria (memoization)."""
cache: dict = {}
@functools.wraps(fn)
def wrapper(*args):
if args not in cache:
cache[args] = fn(*args)
return cache[args]
return wrapper
# Composición de decoradores — se aplican de abajo a arriba
@cronometrar
@reintentar(veces=3, espera=0.5)
@cachear
def obtener_precio(producto_id: int) -> float:
"""Consulta precio en una API externa."""
# ... llamada a API
return 29.99
# ── Decorator de clase (patrón GoF original) ──
from abc import ABC, abstractmethod
class ComponenteTexto(ABC):
@abstractmethod
def renderizar(self) -> str: ...
class TextoPlano(ComponenteTexto):
def __init__(self, texto: str):
self._texto = texto
def renderizar(self) -> str:
return self._texto
class DecoradorTexto(ComponenteTexto, ABC):
def __init__(self, componente: ComponenteTexto):
self._componente = componente
class Negrita(DecoradorTexto):
def renderizar(self) -> str:
return f"{self._componente.renderizar()}"
class Cursiva(DecoradorTexto):
def renderizar(self) -> str:
return f"{self._componente.renderizar()}"
# Composición dinámica de decoradores
texto = Cursiva(Negrita(TextoPlano("Hola mundo")))
print(texto.renderizar()) # Hola mundo
# ═══════════════════════════════════════════════
# FACADE — simplificar API compleja
# ═══════════════════════════════════════════════
# Sin Facade: el cliente gestiona múltiples subsistemas
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from jinja2 import Template
# Código del cliente SIN facade — verbose y acoplado
mensaje = MIMEMultipart()
mensaje["From"] = "no-reply@empresa.com"
mensaje["To"] = "cliente@ejemplo.com"
mensaje["Subject"] = "Tu pedido está listo"
template = Template("Hola {{ nombre }}, tu pedido {{ id }} está listo.")
cuerpo = template.render(nombre="Ana", id="PED-1234")
mensaje.attach(MIMEText(cuerpo, "html"))
with smtplib.SMTP("smtp.empresa.com", 587) as server:
server.starttls()
server.login("user", "pass")
server.send_message(mensaje)
# Con Facade: una interfaz simple sobre el subsistema complejo
class ServicioEmail:
"""Facade sobre SMTP + Jinja2."""
def __init__(self, host: str, puerto: int, usuario: str, password: str):
self._host = host
self._puerto = puerto
self._usuario = usuario
self._password = password
self._templates: dict[str, Template] = {}
def registrar_template(self, nombre: str, html: str) -> None:
self._templates[nombre] = Template(html)
def enviar(
self,
destinatario: str,
asunto: str,
template: str,
contexto: dict = {}
) -> None:
cuerpo = self._templates[template].render(**contexto)
msg = MIMEMultipart()
msg["From"] = self._usuario
msg["To"] = destinatario
msg["Subject"] = asunto
msg.attach(MIMEText(cuerpo, "html"))
with smtplib.SMTP(self._host, self._puerto) as smtp:
smtp.starttls()
smtp.login(self._usuario, self._password)
smtp.send_message(msg)
# Código cliente: limpio, desacoplado, testeable
email = ServicioEmail("smtp.empresa.com", 587, "user", "pass")
email.registrar_template(
"pedido_listo",
"Hola {{ nombre }}, tu pedido {{ id }} está listo."
)
email.enviar(
destinatario="cliente@ejemplo.com",
asunto="Tu pedido está listo",
template="pedido_listo",
contexto={"nombre": "Ana", "id": "PED-1234"}
)
Comportamiento: Observer, Strategy y Command
Los patrones de comportamiento son los más útiles en Python porque el lenguaje ofrece construcciones que los implementan de forma natural. Funciones como objetos de primera clase resuelve el 80% de los casos de Strategy. Callbacks y closures hacen Observer mucho más ligero. Y los callables hacen Command casi invisible.
# ═══════════════════════════════════════════════
# OBSERVER — notificar cambios a múltiples objetos
# ═══════════════════════════════════════════════
from typing import Callable, Any
class EventoBus:
"""Bus de eventos simple y genérico."""
def __init__(self):
self._handlers: dict[str, list[Callable]] = {}
def suscribir(self, evento: str, handler: Callable) -> None:
if evento not in self._handlers:
self._handlers[evento] = []
self._handlers[evento].append(handler)
def desuscribir(self, evento: str, handler: Callable) -> None:
if evento in self._handlers:
self._handlers[evento].remove(handler)
def emitir(self, evento: str, **datos: Any) -> None:
for handler in self._handlers.get(evento, []):
handler(**datos)
# Ejemplo: sistema de pedidos
bus = EventoBus()
# Observadores — funciones normales
def notificar_almacen(pedido_id: str, productos: list) -> None:
print(f"🏭 Almacén: preparar {len(productos)} productos para {pedido_id}")
def notificar_cliente(pedido_id: str, **_) -> None:
print(f"📧 Email confirmación enviado para pedido {pedido_id}")
def actualizar_stock(productos: list, **_) -> None:
print(f"📊 Stock actualizado: {len(productos)} ítems reservados")
# Registro de observadores
bus.suscribir("pedido_creado", notificar_almacen)
bus.suscribir("pedido_creado", notificar_cliente)
bus.suscribir("pedido_creado", actualizar_stock)
# Publicación — un evento, múltiples reacciones
bus.emitir("pedido_creado", pedido_id="PED-001", productos=["SKU-1", "SKU-2"])
# 🏭 Almacén: preparar 2 productos para PED-001
# 📧 Email confirmación enviado para pedido PED-001
# 📊 Stock actualizado: 2 ítems reservados
# ── Observer con __setattr__: observar cambios de atributos ──
class ModeloObservable:
"""Notifica automáticamente cuando cambia cualquier atributo."""
def __init__(self):
self.__dict__["_observadores"] = []
def observar(self, fn: Callable) -> None:
self._observadores.append(fn)
def __setattr__(self, nombre: str, valor: Any) -> None:
antiguo = getattr(self, nombre, None)
super().__setattr__(nombre, valor)
for obs in self._observadores:
obs(nombre, antiguo, valor)
usuario = ModeloObservable()
usuario.observar(lambda campo, ant, nuevo: print(f" {campo}: {ant} → {nuevo}"))
usuario.nombre = "Ana" # nombre: None → Ana
usuario.email = "a@b.com" # email: None → a@b.com
# ═══════════════════════════════════════════════
# STRATEGY — algoritmo intercambiable
# ═══════════════════════════════════════════════
# En Java: interfaz Estrategia + N clases que la implementan
# En Python: funciones como objetos de primera clase
from typing import Callable
# Estrategias como funciones puras
def ordenar_por_nombre(items: list[dict]) -> list[dict]:
return sorted(items, key=lambda x: x["nombre"])
def ordenar_por_precio(items: list[dict]) -> list[dict]:
return sorted(items, key=lambda x: x["precio"])
def ordenar_por_relevancia(items: list[dict]) -> list[dict]:
return sorted(items, key=lambda x: -x.get("puntuacion", 0))
class CatalogoCursos:
def __init__(self, cursos: list[dict]):
self._cursos = cursos
# Estrategia por defecto
self._estrategia: Callable = ordenar_por_nombre
def establecer_orden(self, estrategia: Callable) -> None:
self._estrategia = estrategia
def listar(self) -> list[dict]:
return self._estrategia(self._cursos)
catalogo = CatalogoCursos([
{"nombre": "Python", "precio": 99.0, "puntuacion": 4.8},
{"nombre": "Django", "precio": 149.0, "puntuacion": 4.9},
{"nombre": "FastAPI", "precio": 79.0, "puntuacion": 4.7},
])
catalogo.establecer_orden(ordenar_por_precio)
print(catalogo.listar()) # FastAPI, Python, Django (ascendente)
catalogo.establecer_orden(ordenar_por_relevancia)
print(catalogo.listar()) # Django, Python, FastAPI
# Con lambdas — Strategy en una línea:
catalogo.establecer_orden(lambda items: sorted(items, key=lambda x: x["nombre"][-1]))
# ── Strategy con Protocol (verificación estática) ──
from typing import Protocol
class EstrategiaOrden(Protocol):
def __call__(self, items: list[dict]) -> list[dict]: ...
def aplicar_estrategia(
cursos: list[dict],
estrategia: EstrategiaOrden
) -> list[dict]:
return estrategia(cursos)
# ═══════════════════════════════════════════════
# COMMAND — encapsular operación como objeto
# ═══════════════════════════════════════════════
from dataclasses import dataclass
from typing import Protocol
import shutil
import os
class Comando(Protocol):
def ejecutar(self) -> None: ...
def deshacer(self) -> None: ...
@dataclass
class MoverArchivo:
origen: str
destino: str
def ejecutar(self) -> None:
shutil.move(self.origen, self.destino)
print(f" Movido: {self.origen} → {self.destino}")
def deshacer(self) -> None:
shutil.move(self.destino, self.origen)
print(f" Deshecho: {self.destino} → {self.origen}")
@dataclass
class CrearDirectorio:
ruta: str
def ejecutar(self) -> None:
os.makedirs(self.ruta, exist_ok=True)
print(f" Creado: {self.ruta}")
def deshacer(self) -> None:
os.rmdir(self.ruta)
print(f" Eliminado: {self.ruta}")
class HistorialComandos:
"""Ejecutor con soporte de deshacer (Undo)."""
def __init__(self):
self._historial: list[Comando] = []
def ejecutar(self, comando: Comando) -> None:
comando.ejecutar()
self._historial.append(comando)
def deshacer_ultimo(self) -> None:
if self._historial:
self._historial.pop().deshacer()
def deshacer_todo(self) -> None:
while self._historial:
self._historial.pop().deshacer()
# Macro: secuencia de comandos como un solo comando
historial = HistorialComandos()
historial.ejecutar(CrearDirectorio("/tmp/proyecto"))
historial.ejecutar(MoverArchivo("/tmp/datos.csv", "/tmp/proyecto/datos.csv"))
historial.deshacer_ultimo() # deshace el MoverArchivo
historial.deshacer_todo() # deshace el CrearDirectorio
# ── Con functools.partial: Command ultraligero ──
import functools
def mover_archivo(origen: str, destino: str) -> None:
shutil.move(origen, destino)
# Command = función parcialmente aplicada
comando = functools.partial(mover_archivo, "/tmp/datos.csv", "/tmp/backup/datos.csv")
comando() # ejecutar más tarde
Patrones idiomáticos de Python
Python tiene tres patrones que son específicos del lenguaje (o están mucho más integrados que en otros): Context Manager, Descriptor y Metaclass. No están en el catálogo GoF porque emergieron del propio diseño del lenguaje, pero son patrones de pleno derecho.
# ═══════════════════════════════════════════════
# CONTEXT MANAGER — gestión garantizada de recursos
# ═══════════════════════════════════════════════
# El problema: garantizar liberación de recursos incluso con excepciones
# Sin context manager:
conexion = ConexionBD()
try:
datos = conexion.consultar("SELECT * FROM usuarios")
procesar(datos)
finally:
conexion.cerrar() # olvidar esto = fuga de conexiones
# Con context manager (__enter__ / __exit__)
class ConexionBD:
def __init__(self, url: str):
self._url = url
self._conn = None
def __enter__(self) -> "ConexionBD":
self._conn = conectar(self._url)
return self
def __exit__(self, exc_type, exc_val, exc_tb) -> bool:
if self._conn:
if exc_type is None:
self._conn.commit()
else:
self._conn.rollback()
self._conn.close()
return False # False = propagar la excepción si hubo una
def consultar(self, sql: str) -> list:
return self._conn.execute(sql).fetchall()
with ConexionBD("postgresql://...") as db:
datos = db.consultar("SELECT * FROM usuarios")
# La conexión se cierra garantizadamente, haya o no excepción
# ── Context manager con contextlib (sin clase) ──
from contextlib import contextmanager
@contextmanager
def tempdir():
"""Directorio temporal que se borra al salir."""
import tempfile, shutil
ruta = tempfile.mkdtemp()
try:
yield ruta
finally:
shutil.rmtree(ruta)
with tempdir() as dir_tmp:
# Usar dir_tmp
pass
# El directorio ya no existe
# ═══════════════════════════════════════════════
# DESCRIPTOR — atributos con comportamiento propio
# ═══════════════════════════════════════════════
# El problema: validar atributos en múltiples clases sin repetir código
# Sin descriptor — validación duplicada en cada clase:
class Producto:
def __init__(self, nombre: str, precio: float):
if not nombre:
raise ValueError("nombre no puede estar vacío")
if precio < 0:
raise ValueError("precio no puede ser negativo")
self._nombre = nombre
self._precio = precio
# Con descriptor — la validación vive en UN solo lugar
class CampoTextoNoVacio:
"""Descriptor: string no vacío."""
def __set_name__(self, owner, nombre: str) -> None:
self._nombre = f"_{nombre}"
def __get__(self, obj, tipo=None):
if obj is None:
return self
return getattr(obj, self._nombre, None)
def __set__(self, obj, valor: str) -> None:
if not isinstance(valor, str) or not valor.strip():
raise ValueError(f"{self._nombre[1:]} no puede estar vacío")
setattr(obj, self._nombre, valor.strip())
class CampoPositivo:
"""Descriptor: número positivo."""
def __set_name__(self, owner, nombre: str) -> None:
self._nombre = f"_{nombre}"
def __get__(self, obj, tipo=None):
if obj is None:
return self
return getattr(obj, self._nombre, 0.0)
def __set__(self, obj, valor: float) -> None:
if not isinstance(valor, (int, float)) or valor < 0:
raise ValueError(f"{self._nombre[1:]} debe ser positivo")
setattr(obj, self._nombre, float(valor))
# Reutilizar en cualquier clase — declarativo y sin repetición
class Producto:
nombre = CampoTextoNoVacio()
precio = CampoPositivo()
def __init__(self, nombre: str, precio: float):
self.nombre = nombre # llama a CampoTextoNoVacio.__set__
self.precio = precio # llama a CampoPositivo.__set__
class Curso:
titulo = CampoTextoNoVacio()
duracion = CampoPositivo()
def __init__(self, titulo: str, duracion: float):
self.titulo = titulo
self.duracion = duracion
# Esto es exactamente lo que hacen @property, @staticmethod, @classmethod:
# son descriptores integrados en el lenguaje.
Cuándo NO usar patrones de diseño
El mayor peligro de aprender los patrones GoF es aplicarlos donde no hacen falta. Hay un antipatrón conocido como patternitis: el exceso de patrones que convierte código simple en una maraña de abstracciones sin beneficio real.
# ── Sobreingeniería: cuando el patrón complica sin aportar ──
# MALO: Factory para crear un solo tipo de objeto que nunca va a variar
class CreadorSaludo:
def crear(self, nombre: str) -> "Saludo":
return Saludo(nombre)
class Saludo:
def __init__(self, nombre: str):
self.nombre = nombre
def mostrar(self) -> str:
return f"Hola, {nombre}!"
creador = CreadorSaludo()
saludo = creador.crear("Ana")
saludo.mostrar()
# MEJOR: simplemente...
def saludar(nombre: str) -> str:
return f"Hola, {nombre}!"
# MALO: Observer para un objeto que solo notifica a un receptor
class Modelo:
def __init__(self):
self._observadores = []
def agregar_observador(self, obs):
self._observadores.append(obs)
def cambiar_datos(self, datos):
self._datos = datos
for obs in self._observadores:
obs.actualizar(self)
# Si siempre hay exactamente un receptor y no vas a cambiar eso:
class Modelo:
def __init__(self, callback):
self._callback = callback
def cambiar_datos(self, datos):
self._datos = datos
self._callback(datos)
# ── Señales de que necesitas un patrón ──
# ✓ if/elif anidados para decidir qué objeto crear → Factory
# ✓ múltiples clases notificando a múltiples receptores → Observer
# ✓ copiar y pegar el mismo algoritmo con una variación → Strategy
# ✓ código que usa recursos y puede lanzar excepciones → Context Manager
# ✓ "necesito añadir logging/caché/reintentos a esta función" → Decorator
# ── Señales de que NO necesitas un patrón ──
# ✗ tienes un solo tipo concreto que nunca cambiará → no Factory
# ✗ hay un solo receptor de eventos → no Observer, usa callback
# ✗ el algoritmo no va a cambiar nunca → no Strategy, escríbelo directo
# ✗ el recurso siempre se libera implícitamente → no Context Manager
# La regla de las tres instancias:
# la primera vez que escribes algo, escríbelo directo.
# la segunda vez que lo repites, duplícalo con una nota.
# la tercera vez, refactoriza con el patrón adecuado.
Principios SOLID y su relación con los patrones
Los patrones GoF implementan los principios SOLID de forma concreta. Entender la relación entre ambos ayuda a usar los patrones con criterio en lugar de mecánicamente.
# ── SOLID en Python: los 5 principios con ejemplos ──
# S — Single Responsibility: una clase, una razón para cambiar
# MAL: una clase que hace todo
class ProcesadorPedido:
def calcular_total(self, items): ...
def guardar_en_bd(self, pedido): ...
def enviar_email_confirmacion(self, pedido): ...
def generar_pdf_factura(self, pedido): ...
# BIEN: cada clase tiene una responsabilidad
class CalculadorPrecio:
def calcular(self, items: list) -> float: ...
class RepositorioPedidos:
def guardar(self, pedido) -> None: ...
class ServicioNotificacion:
def enviar_confirmacion(self, pedido) -> None: ...
# O — Open/Closed: abierto a extensión, cerrado a modificación
# En lugar de modificar la Factory, la extiendes registrando nuevas clases
# (ejemplo ya visto en la sección de Factory)
# L — Liskov Substitution: los subtipos deben ser sustituibles
class Ave(ABC):
@abstractmethod
def moverse(self) -> None: ...
class Paloma(Ave):
def moverse(self) -> None:
print("Volando")
class Pinguino(Ave):
def moverse(self) -> None:
print("Nadando") # NO lanza excepciones — sustituye correctamente
# MAL — viola Liskov:
class AveQueNoVuela(Ave):
def moverse(self) -> None:
raise NotImplementedError("No puedo volar") # rompe el contrato
# I — Interface Segregation: interfaces pequeñas y específicas
# Con Protocol en Python — clientes solo dependen de lo que usan
class Legible(Protocol):
def leer(self) -> str: ...
class Escribible(Protocol):
def escribir(self, datos: str) -> None: ...
class Cerrable(Protocol):
def cerrar(self) -> None: ...
# Una función que solo necesita leer no depende de cerrar/escribir
def procesar_datos(fuente: Legible) -> str:
return fuente.leer().strip()
# D — Dependency Inversion: depender de abstracciones, no implementaciones
# MAL: la lógica de negocio depende de MySQL directamente
class ServicioUsuarios:
def __init__(self):
self._bd = ConexionMySQL(host="...", user="...") # acoplamiento fuerte
# BIEN: inyectar la dependencia
class RepositorioUsuarios(Protocol):
def buscar(self, id: int) -> dict | None: ...
def guardar(self, usuario: dict) -> None: ...
class ServicioUsuarios:
def __init__(self, repo: RepositorioUsuarios):
self._repo = repo # acepta cualquier implementación
# Testing: fácil de mockear
class RepoEnMemoria:
def __init__(self): self._datos: dict = {}
def buscar(self, id: int): return self._datos.get(id)
def guardar(self, u: dict): self._datos[u["id"]] = u
servicio = ServicioUsuarios(repo=RepoEnMemoria()) # test sin BD real
❓ Preguntas frecuentes
❓ Preguntas frecuentes sobre Patrones de diseño en Python: GoF, Strategy, Observer y más
Las dudas más comunes respondidas de forma clara y directa.
🎯 ¿Quieres certificarte en Python?
Ciberaula ofrece cursos bonificados de Python con tutor personal, desde nivel básico hasta arquitectura de software y patrones avanzados. Formación subvencionada por FUNDAE.
Ver cursos de Python bonificados →💬 Foro de discusión
¿Tienes dudas sobre Patrones de diseño en Python: GoF, Strategy, Observer y más? Comparte tu pregunta con la comunidad.
Todavía no hay mensajes. ¡Sé el primero en participar!