Cómo usar clases abstractas en Python

Clase 6 de 27Curso de Patrones de Diseño y SOLID en Python

Resumen

Aplicar el principio abierto cerrado con Python es más simple de lo que parece: abre tu código a la extensión y ciérralo a la modificación usando modelos tipados con Pydantic y clases abstractas para comportamientos como el procesador de pagos y el notificador. Así se añade una nueva pasarela o un canal de notificación sin tocar la base.

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

El objetivo es incorporar una nueva pasarela de pagos sin reescribir lógica existente. Para ello, se reorganiza el proyecto y se tipan los datos con Pydantic. Luego, se define la forma del procesador de pagos mediante una clase abstracta, y las implementaciones concretas heredan esa forma.

  • Nuevo requerimiento: agregar una pasarela de pagos adicional.
  • Carpeta dedicada al principio open close con “antes” y “después”.
  • Migración de estructuras a modelos Pydantic para validación y tipado.

Ejemplo de modelos con Pydantic y acceso por atributos en vez de diccionario:

from pydantic import BaseModel
from typing import Optional

class ContactInfo(BaseModel):
    email: Optional[str]
    phone: Optional[str]

class CustomerData(BaseModel):
    name: int  # según el ejemplo, tipado como entero.
    contactInfo: ContactInfo

class PaymentData(BaseModel):
    amount: int
    source: str

¿Qué valida Pydantic y cómo cambia el acceso a datos?

  • Define forma y tipos: por ejemplo, amount entero y source string.
  • Maneja opcionales: email y phone pueden ser None o str.
  • Facilita validaciones: ahora se usa customerData.name en lugar de claves de diccionario.

¿Qué cambios habilitan la extensión sin modificar el servicio?

El paso clave es depender de una abstracción. Se crea la clase abstracta del procesador de pagos con from abc import ABC, abstractmethod. La implementación concreta (por ejemplo, Stripe) hereda y define la lógica. Luego, el servicio depende de la abstracción, no de la clase concreta. Con data classes y field(default_factory=...) se fijan implementaciones por defecto sin acoplar el código.

from abc import ABC, abstractmethod
from dataclasses import dataclass, field

class PaymentProcessor(ABC):
    @abstractmethod
    def processTransaction(self, paymentData: PaymentData) -> None:
        ...

class StripePaymentProcessor(PaymentProcessor):
    def processTransaction(self, paymentData: PaymentData) -> None:
        # lógica real de Stripe aquí.
        ...

@dataclass
class PaymentService:
    paymentProcessor: PaymentProcessor = field(default_factory=StripePaymentProcessor)
    # notifier se define más abajo.

    def pay(self, paymentData: PaymentData, customerData: CustomerData) -> None:
        self.paymentProcessor.processTransaction(paymentData)
        self.notifier.sendConfirmation(customerData)
  • Dependencia de abstracciones: el servicio ya no acopla a StripePaymentProcessor.
  • Uso de polimorfismo: nuevas pasarelas implementan PaymentProcessor sin tocar el servicio.
  • field(default_factory=StripePaymentProcessor) crea la instancia por defecto y evita dependencias rígidas.

¿Cómo configurar y probar notificaciones por email y SMS?

Se repite el patrón con un notificador: una clase abstracta define la firma de sendConfirmation y dos implementaciones (EmailNotifier y SMSNotifier) envían por distintos canales. Se refactoriza para no validar lo que la implementación ya garantiza (por ejemplo, en EmailNotifier se asume email disponible y se manejan casos vacíos con una condición simple).

¿Cómo se abstrae el notificador?

class Notifier(ABC):
    @abstractmethod
    def sendConfirmation(self, customerData: CustomerData) -> None:
        ...

class EmailNotifier(Notifier):
    def sendConfirmation(self, customerData: CustomerData) -> None:
        email = customerData.contactInfo.email or ""
        if not email:
            # evitar error cuando email es None o vacío.
            return
        # enviar correo a email.
        ...

class SMSNotifier(Notifier):
    def sendConfirmation(self, customerData: CustomerData) -> None:
        phone = customerData.contactInfo.phone
        # enviar SMS simulado a phone.
        ...

Integración en el servicio con notificador por defecto y configuración flexible:

@dataclass
class PaymentService:
    paymentProcessor: PaymentProcessor = field(default_factory=StripePaymentProcessor)
    notifier: Notifier = field(default_factory=EmailNotifier)

    def pay(self, paymentData: PaymentData, customerData: CustomerData) -> None:
        self.paymentProcessor.processTransaction(paymentData)
        self.notifier.sendConfirmation(customerData)

# Cambiar comportamiento sin modificar la base de código:
service = PaymentService(notifier=SMSNotifier())

¿Qué comportamiento se observa al ejecutar?

  • Al forzar SMSNotifier, no se envía SMS si falta teléfono.
  • Si hay teléfono, el SMS sale “enviado” al número definido.
  • Sin pasar notificador, usa el predeterminado: correo electrónico.

En conjunto, se aplicó el principio abierto cerrado con: modelos Pydantic tipados, clases abstractas (PaymentProcessor, Notifier), implementaciones concretas (StripePaymentProcessor, EmailNotifier, SMSNotifier), y default factory en data classes para configurar por defecto. Comparte en comentarios: ¿de qué otra forma aplicarías este principio aquí y qué mejoras propondrías al diseño?