Curso de Patrones de Diseño y SOLID en Python

Clases abstractas y principio abierto-cerrado

Curso de Patrones de Diseño y SOLID en Python

Contenido del curso

Principios SOLID

Patrones de Diseño

Clases abstractas y principio abierto-cerrado

Resumen

El principio abierto-cerrado establece que tu código debe estar abierto a la extensión y cerrado a la modificación. En la práctica, eso significa que puedes añadir nuevas funcionalidades, como una pasarela de pagos distinta, sin tocar la lógica que ya funciona. Si trabajas con Python y quieres escribir código mantenible, este principio es uno de los pilares de SOLID que más te va a ahorrar dolores de cabeza.

¿Qué es el principio abierto-cerrado y por qué importa?

La idea es simple: tu base de código va a cambiar siempre, pero los cambios no deberían romper lo que ya está probado. En lugar de modificar una clase existente cada vez que aparece un requerimiento nuevo, la extiendes mediante polimorfismo, clases abstractas o interfaces.

¿Qué significa abierto a extensión y cerrado a modificación? Significa que puedes agregar comportamiento nuevo creando clases que hereden de una abstracción, sin alterar el código que ya usa esa abstracción.

En el caso del procesador de pagos, aparece un requerimiento típico [01:10]: integrar una nueva pasarela además de Stripe. Si tu servicio depende directamente de StripePaymentProcessor, cada nueva pasarela te obliga a reescribir el servicio. Con una abstracción intermedia, no.

¿Cómo preparar los datos con Pydantic antes de aplicar el principio?

Antes de tocar la arquitectura, conviene tipar bien los datos. Aquí entra Pydantic, la librería más usada en el ecosistema de Python para representar y validar datos [01:45].

En el refactor se reemplazaron los diccionarios por modelos de Pydantic:

  • PaymentData con un amount entero y un source string.
  • CustomerData con name y un ContactInfo anidado.
  • ContactInfo con email y phone como Optional[str].

El beneficio es doble. Tienes validación automática y, dentro de los métodos, accedes a los datos como atributos customer_data.name en lugar de claves de diccionario. Eso le da forma clara a las firmas de métodos como CustomerValidator, que ahora declara customer_data: CustomerData.

¿Cómo aplicar el principio abierto-cerrado con clases abstractas en Python?

La estrategia es crear una abstracción de la que dependa el servicio, y luego implementaciones concretas que hereden de ella. En el código se aplica a dos piezas: el procesador de pagos y el notificador.

¿Cómo crear una clase abstracta para el procesador de pagos?

Python trae el módulo abc para esto. El proceso es directo [04:20]:

  1. Importar ABC y abstractmethod desde abc.
  2. Crear class PaymentProcessor(ABC) con la firma de process_transaction decorada con @abstractmethod, sin lógica.
  3. Hacer que StripePaymentProcessor herede de PaymentProcessor e implemente process_transaction.

La clase abstracta define la forma, la clase concreta define la lógica. Esa separación es la que te permite extender sin modificar.

¿Cuál es la diferencia entre una clase abstracta y su implementación? La abstracta declara qué métodos deben existir y con qué firma; la implementación contiene el código real que se ejecuta. El servicio depende de la abstracta, no de la concreta.

¿Cómo inyectar la dependencia en el servicio sin acoplarlo?

El PaymentService deja de declarar payment_processor: StripePaymentProcessor y pasa a declarar payment_processor: PaymentProcessor. Para que la data class siga teniendo un valor por defecto, se usa field(default_factory=StripePaymentProcessor), importando field desde dataclasses [07:30].

Con eso logras dos cosas a la vez:

  • El servicio depende de una abstracción, no de Stripe.
  • Sigue funcionando sin configuración explícita gracias al default factory.

Si mañana llega PayPal, creas PayPalPaymentProcessor(PaymentProcessor) y lo inyectas. El servicio ni se entera.

¿Cómo extender el notificador a email y SMS sin romper el servicio?

El notificador originalmente mezclaba el envío por email y por teléfono en la misma clase. La solución sigue el mismo patrón [09:15]:

  • Crear una clase abstracta Notifier(ABC) con un método send_confirmation(customer_data: CustomerData) decorado con @abstractmethod.
  • Renombrar la clase existente a EmailNotifier(Notifier) y dejar solo la lógica de envío por correo.
  • Crear SMSNotifier(Notifier) que extrae el número de teléfono del CustomerData y envía el mensaje.

Un detalle importante: dentro de EmailNotifier ya no tiene sentido validar si el email existe en ContactInfo, porque la clase asume que ese canal está disponible. Aun así, como email es Optional[str], conviene una condición mínima para evitar errores de tipado.

Después, el PaymentService declara notifier: Notifier = field(default_factory=EmailNotifier). Por defecto notifica por correo, pero acepta cualquier implementación de Notifier.

¿Cómo cambiar el comportamiento del servicio sin tocar su código?

En el bloque de uso al final del archivo [13:40], el servicio se construye así:

python sms_notifier = SMSNotifier() service = PaymentService(notifier=sms_notifier)

Con eso le dices al servicio que todas las notificaciones se envíen por mensaje de texto. Si no pasas el parámetro, usa el EmailNotifier por defecto. Al ejecutar el código se ven tres escenarios:

  1. Cliente con email pero sin teléfono: el SMS sale con None como destino.
  2. Cliente con teléfono: el SMS llega correctamente al número.
  3. Tarjeta declinada: el procesador devuelve el error sin notificar.

Luego, eliminando el notifier=sms_notifier, el servicio vuelve a notificar por correo sin que hayas modificado una sola línea de PaymentService. Ahí está el principio aplicado: abierto a extensión, cerrado a modificación.

¿De qué otra forma aplicarías el principio abierto-cerrado a este código? ¿Qué mejoras le harías al diseño del servicio? Déjalo en los comentarios.