El mundo del desarrollo de software ha evolucionado considerablemente en las últimas décadas gracias a dos pilares fundamentales: los patrones de diseño, que llevan 30 años organizando el caos del código, y los principios SOLID, que llevan 20 años haciendo la vida de los programadores más sencilla. Juntos, permiten construir sistemas más mantenibles, escalables y flexibles. En este curso, aprenderemos a aplicar ambos conceptos mientras desarrollamos un procesador de pagos en Python, mejorando el código a lo largo del proceso para crear una solución robusta y eficiente.
Los patrones de diseño y los principios SOLID ofrecen soluciones probadas a problemas comunes en el desarrollo de software. Aunque llevan décadas en uso, siguen siendo esenciales porque:
- 🔧 Mejoran la mantenibilidad del código.
- 📈 Aumentan la flexibilidad y escalabilidad.
- 🧪 Facilitan la implementación de pruebas unitarias e integración.
- 🚀 Optimizan el rendimiento.
- 🧠 Mejoran la experiencia de desarrollo, haciéndola más clara y ordenada.
A lo largo del curso, trabajaremos sobre una base de código inicial que no cumple con ninguno de los principios SOLID ni sigue patrones de diseño. Esto nos permitirá ver, de manera práctica, cómo transformar el código paso a paso. Usaremos Python para construir un sistema de procesamiento de pagos, aplicando mejoras incrementales entre cada lección, las cuales servirán como retos para que los estudiantes reflexionen y discutan en la sección de comentarios.
Contar con un entorno de desarrollo adecuado que incluya:
- 🖊️ Un editor de código
- 📦 Un manejador de paquetes como
PIP,UVoPoetry - 🧪 Un entorno virtual para gestionar dependencias
- 🌐 GIT y GitHub para el manejo del repositorio del proyecto
El principio de responsabilidad única es uno de los pilares de la construcción de software, establecido por Robert C. Martin. Este principio indica que una clase o función debe tener solo una razón para cambiar, lo que mejora la mantenibilidad y reduce la complejidad. Implementarlo no solo facilita las pruebas unitarias, sino que también incrementa la reutilización del código y disminuye el acoplamiento, promoviendo un sistema más cohesivo y fácil de escalar.
-
✅ Responsabilidad única: Una clase o función debe encargarse de una sola tarea, evitando que maneje múltiples aspectos del sistema.
-
🔁 Razón para cambiar: El código debe tener una única causa para ser modificado, asegurando que los cambios sean claros, aislados y controlables.
-
🧹 Mantenimiento más sencillo: Al dividir responsabilidades, el código se vuelve más fácil de entender, modificar y extender.
-
♻️ Reutilización del código: Las funciones con una única responsabilidad pueden utilizarse en diversos contextos sin necesidad de duplicación.
-
🧪 Pruebas unitarias más simples: Las funciones con responsabilidades bien definidas son más fáciles de testear, reduciendo el esfuerzo en el desarrollo. ###🧭 ¿Cómo saber cuándo aplicar el principio de responsabilidad única?
-
🔄 Múltiples razones para cambiar: Si una clase o función tiene varias razones para ser modificada, probablemente esté manejando demasiadas responsabilidades.
-
🧱 Alta complejidad y difícil mantenimiento: Dificultades para agregar funcionalidades o corregir errores indican que no se está aplicando correctamente este principio.
-
🔬 Pruebas complicadas: Si realizar pruebas unitarias requiere mucha configuración o dependencias, es señal de que hay responsabilidades mal distribuidas.
-
📋 Duplicación de código: Si una misma funcionalidad (como una validación) está repetida, conviene extraerla en una única función reutilizable.
Cuando se identifica la duplicación de código o responsabilidades mal distribuidas, el enfoque ideal es reorganizar ese código en una función específica que pueda ser reutilizada en todos los puntos necesarios. Esto no solo reduce el trabajo redundante, sino que también asegura que los cambios futuros se realicen en un solo lugar.
Construir un procesador de pagos es un desafío común en la industria del desarrollo de software. En este curso lo abordaremos aplicando los principios SOLID y patrones de diseño. Partiremos de un código básico, que iremos refactorizando paso a paso para ajustarlo a las buenas prácticas a medida que avanzamos.
El código comienza con una clase simple que incluye un único método llamado processTransaction. Este método recibe dos parámetros:
-
🧑 customerData
-
💰 paymentData
Dentro del método, se realizan tres validaciones básicas:
-
✅ Verificación de que el cliente tiene nombre
-
📞 Verificación de que tiene información de contacto
-
💳 Comprobación de que tiene una fuente de pago válida
El procesamiento del pago se gestiona mediante una integración con Stripe:
-
🔐 Se utiliza la clave API de Stripe, almacenada en variables de entorno.
-
💸 Se crea un cargo basado en la cantidad y los datos recibidos.
-
🛡️ El método maneja errores con un bloque
try-except, enviando una notificación si el pago falla.
El código incluye mecanismos para notificar al cliente:
-
✉️ Correo electrónico
-
📲 Mensaje de texto
-
💡 Dado que no hay un servidor SMTP configurado, los ejemplos de envío de correos están comentados como referencia. En cuanto a los mensajes de texto, se utiliza un mock que simula una pasarela de envío SMS.
Al finalizar el proceso, el método registra los detalles en un archivo llamado transactions.log, incluyendo:
-
🧑 Nombre del cliente
-
💲 Monto cobrado
-
✅ Estado final del pago
-
📁 Estos registros son útiles para futuras consultas o auditorías.
El código es flexible y permite modificar la información para probar distintos escenarios:
-
🧾 Cambiar el nombre del cliente
-
💸 Variar la cantidad a cobrar
-
💳 Probar diferentes métodos de pago, incluyendo tokens de prueba de Stripe
Stripe proporciona varios números de prueba y tokens que permiten simular transacciones en distintas condiciones.
Una parte esencial del código es la configuración de variables de entorno:
-
⚙️ Se gestionan con el módulo
.envde Python -
🔐 Permiten cargar la clave API de Stripe desde un archivo
.env, garantizando seguridad y evitando exponer la clave directamente en el código.
El principio de responsabilidad única es uno de los pilares de la construcción de software, establecido por Robert C. Martin. Este principio indica que una clase o función debe tener solo una razón para cambiar, lo que mejora la mantenibilidad y reduce la complejidad.
Implementarlo no solo facilita las pruebas unitarias 🧪, sino que también incrementa la reutilización del código 🔁 y disminuye el acoplamiento 🔗, promoviendo un sistema más cohesivo y fácil de escalar 📈.
- ✅ Responsabilidad única: Una clase o función debe encargarse de una única tarea, lo que evita que se ocupe de múltiples aspectos.
- 🔄 Razón para cambiar: El código debe tener una única causa para ser modificado, asegurando que los cambios sean claros y controlados.
- 🧹 Mantener el código: Al dividir responsabilidades, el código se vuelve más fácil de mantener y más económico de modificar.
- 🔄 Reutilización del código: Las funciones con una única responsabilidad pueden ser utilizadas en diferentes contextos sin necesidad de duplicar código.
- 🧪 Pruebas unitarias más simples: Las funciones con responsabilidades bien definidas son más fáciles de probar, reduciendo la carga de trabajo en el desarrollo.
⚠️ Múltiples razones para cambiar: Si una clase o función tiene varias razones para ser modificada, es un buen indicio de que tiene más responsabilidades de las que debería.- 🚧 Alta complejidad y difícil mantenimiento: Si se encuentra complicado añadir nuevas funcionalidades o corregir errores, es probable que la falta de una clara definición de responsabilidades esté afectando el código.
- 🧪 Dificultad para realizar pruebas unitarias: Si preparar una prueba implica mucho trabajo o configurar demasiados elementos, es señal de que el principio no se está siguiendo correctamente.
- 🧬 Duplicación de código: Si una funcionalidad, como una validación, está replicada en varios lugares, se debería extraer a una única función y reutilizarla donde sea necesario.
Cuando se identifica la duplicación de código o responsabilidades mal distribuidas, el enfoque ideal es reorganizar ese código en una función específica que pueda ser reutilizada en todos los puntos necesarios.
Esto no solo reduce el trabajo redundante ♻️, sino que también asegura que los cambios futuros se realicen en un solo lugar 📍.
El acoplamiento se refiere al grado de dependencia entre módulos o clases. Es decir, cuánto sabe o necesita una clase de otra para funcionar.
-
Acoplamiento fuerte (tight coupling): Las clases están muy interconectadas. Si cambias una, probablemente debas cambiar otra. ❌
-
Acoplamiento débil (loose coupling): Las clases están más independientes. Pueden cambiar o reutilizarse sin afectar a otras. ✅
🛠️ Ejemplo
class EmailSender:
def send_email(self, to, subject):
print(f"Enviando email a {to} con asunto {subject}")
class UserNotifier:
def __init__(self):
self.sender = EmailSender() # Acoplamiento fuerte
def notify(self, user):
self.sender.send_email(user.email, "Bienvenido")En este caso, UserNotifier depende directamente de EmailSender, lo cual dificulta pruebas y cambios.
💡 Solución: pasar EmailSender como dependencia (inyección) para lograr acoplamiento débil.
🧠 ¿Qué es? La cohesión describe qué tan bien están relacionadas las responsabilidades internas de una clase o módulo. Una clase con alta cohesión hace una sola cosa y la hace bien. 🎯
-
Alta cohesión: todas las funciones están estrechamente relacionadas y sirven a un mismo propósito. ✅
-
Baja cohesión: la clase tiene múltiples responsabilidades mezcladas (violando SRP). ❌
🛠️ Ejemplo
class Usuario:
def guardar_en_db(self): ...
def enviar_email_bienvenida(self): ...
def calcular_impuestos(self): ... # ¿qué hace esto aquí?Aquí hay baja cohesión: métodos que no deberían estar en la misma clase.
Concepto Meta ideal
-
🔗 Acoplamiento Débil (Loose)
-
🧩 Cohesión Alta (High)
-
➡️ Alta cohesión + Bajo acoplamiento = Código mantenible, reutilizable y testeable.
Para aplicar el principio de responsabilidad única (SRP) sobre un procesador de pagos en Python, se debe organizar el código para que cada clase o método tenga una única responsabilidad. A continuación, te explico cómo refactorizar el código original, identificando responsabilidades y dividiéndolo en componentes más manejables.
El código inicial contenía varias responsabilidades dentro de una sola clase llamada PaymentProcessor. Estas incluían:
- ✅ Validación de datos del cliente
- 💳 Validación de datos de pago
- 🔌 Procesamiento del pago con Stripe
- ✉️ Envío de notificaciones (email o SMS)
- 📝 Registro de la transacción en logs
Este enfoque viola el principio SRP, ya que una sola clase está encargada de múltiples tareas, dificultando el mantenimiento y la escalabilidad del sistema.
El primer paso fue identificar las distintas responsabilidades dentro de la clase. Se encontraron cuatro bloques clave:
- 👤 Validación de datos del cliente
- 💳 Validación de datos del pago
- 💸 Procesamiento del pago
- 📬 Notificación y logging de transacciones
Se creó una clase CustomerValidation con el método validate, encargada exclusivamente de validar datos del cliente, como nombre y contacto.
🔄 El código fue extraído desde PaymentProcessor y reubicado aquí.
De forma similar, se creó una clase PaymentDataValidator encargada de validar los datos del pago, como la fuente de pago (ej. tarjeta o token válido).
El procesamiento del pago, que involucra la interacción con la API de Stripe, se ubicó en una nueva clase: StripePaymentProcessor.
💼 Esta clase solo procesa pagos, cumpliendo con SRP.
Se creó una clase Notifier, encargada de enviar notificaciones (email o SMS).
💬 Esto aísla esta lógica del resto del código, facilitando futuras modificaciones.
Se añadió una clase TransactionLogger, dedicada a registrar la información de las transacciones en un archivo de log.
🗃️ Así se centraliza el manejo de logs, mejorando la organización.
Se integraron todas las clases en una nueva entidad llamada PaymentService, encargada de:
- Orquestar la validación de datos
- Coordinar el procesamiento de pagos
- Enviar notificaciones
- Registrar transacciones
✅ Esto permite una coordinación clara y respetuosa del SRP.
Cada clase maneja sus propias excepciones:
- 🚫 Las validaciones lanzan errores específicos si los datos son inválidos.
- 🔁 Se agregó un bloque
try-excepten el flujo principal para capturar fallos en el procesamiento de pagos. ⚠️ Esto permite una gestión de errores clara, controlada y localizada.
El principio abierto-cerrado (Open-Closed Principle) es clave para mantener la flexibilidad y estabilidad en el desarrollo de software, permitiendo que el código sea ampliado sin ser modificado. Este principio garantiza que el software pueda evolucionar de manera eficiente sin afectar las funcionalidades ya probadas, lo que es fundamental en un entorno de cambio constante como el de las empresas tecnológicas.
El principio abierto-cerrado establece que el software debe estar:
- 🟢 Abierto para su extensión, pero
- 🔴 Cerrado para su modificación.
Esto significa que es posible añadir nuevas funcionalidades sin alterar el código existente, lo que ayuda a evitar errores y mantiene la estabilidad del sistema.
-
🔧 Extensión sin modificación: Se agregan nuevas características utilizando mecanismos como interfaces, abstracciones o polimorfismos. En lenguajes como Python, estas herramientas son parte del propio lenguaje y permiten ampliar comportamientos sin alterar la base de código original.
-
🛡️ Cerrado para modificación: Protege el código validado, encapsulando las funcionalidades y asegurando que las nuevas extensiones no rompan lo que ya está en uso.
- ✅ Menos errores: Al no tocar el código existente, se minimizan los errores derivados de cambios imprevistos.
- 🚀 Actualizaciones más rápidas: La extensión del software se vuelve más ágil, lo que es crucial cuando hay cambios constantes de requisitos en las empresas.
- 🧱 Estabilidad del sistema: El código probado y validado permanece inalterado, lo que facilita el desarrollo de nuevas funcionalidades sin riesgos.
Este principio es útil cuando necesitas añadir nuevas funcionalidades sin afectar el código existente.
Un ejemplo común es:
- ➕ La integración de una nueva pasarela de pagos en un sistema ya funcional
- 📩 Agregar un método de notificación sin cambiar las implementaciones actuales
También es recomendable en contextos donde los requisitos del sistema cambian frecuentemente, como en la construcción de plataformas de pago o servicios con muchas interacciones externas.
Reflexiona sobre cómo usarías este principio al:
- Agregar nuevas pasarelas de pago al sistema que estás desarrollando
- Recordar momentos en los que has extendido funcionalidades sin modificar la base de código
Este enfoque te permitirá adaptarte rápidamente a las necesidades cambiantes de la empresa mientras mantienes la estabilidad del sistema.
El principio de abierto-cerrado promueve la creación de código que permita extender funcionalidades sin modificar el comportamiento original.
A continuación, veremos cómo se puede aplicar este principio en el contexto de un procesador de pagos que debe añadir nuevas pasarelas sin cambiar el código existente.
La clave para implementar el principio abierto-cerrado en un sistema de pagos es diseñar el código de manera que pueda admitir nuevas pasarelas sin modificar la estructura existente.
Esto se logra utilizando clases abstractas o interfaces que actúan como intermediarios. Así, los procesadores de pagos específicos, como Stripe, pueden heredar de estas clases abstractas y añadir su propia lógica sin afectar el código original.
Para incorporar una nueva pasarela de pagos en este caso:
- 📁 Se creó una nueva carpeta con ejemplos de antes y después de aplicar el principio abierto-cerrado.
- 🔄 Los datos del cliente y el pago se refactorizaron utilizando Pydantic, que es la librería más popular en Python para validaciones de datos.
- 📊 Se introdujeron modelos de datos tipados para
CustomerDatayPaymentData, con campos claros como montos (enteros) y fuentes de pago (cadenas de texto).
Pydantic permite definir modelos de datos que facilitan la validación y tipado.
Por ejemplo, el modelo CustomerData contiene atributos como name (nombre del cliente) y contact_info (información de contacto), que incluye campos opcionales como teléfono o correo electrónico.
Esto hace que la manipulación de datos sea más clara y segura ✅.
- Primero, se define una clase abstracta que representará un procesador de pagos.
- Esta clase no contiene lógica interna, sino que se utiliza para definir la firma del método principal,
processTransaction. - Los procesadores de pagos como Stripe implementan esta clase abstracta, heredando su forma y añadiendo la lógica específica para procesar las transacciones.
🔹 Se define una clase abstracta PaymentProcessor que incluye el método processTransaction.
🔹 Stripe, por ejemplo, hereda de esta clase abstracta para implementar su propia lógica de procesamiento.
🔹 El servicio de pagos ya no depende de la implementación concreta de Stripe, sino que interactúa con la clase abstracta, lo que facilita añadir nuevas pasarelas sin tocar el código base 🧩.
Se siguió una estrategia similar para las notificaciones.
Al igual que con los procesadores de pagos, se creó una clase abstracta Notifier que define la firma del método SendConfirmation.
Esto permite crear diferentes implementaciones, como un notificador por correo electrónico 📧 o un notificador por SMS 📱, sin afectar la estructura del código original.
🔸 Se introdujo un EmailNotifier y un SMSNotifier, ambos heredando de la clase abstracta Notifier.
🔸 El código decide dinámicamente qué tipo de notificación enviar según los datos del cliente, ya sea por correo o por SMS.
Al final, el servicio de pagos se extendió para que permitiera enviar notificaciones vía SMS sin cambiar su base de código.
Esto se logró creando una nueva clase SMSNotifier que también hereda de Notifier.
El código puede cambiar entre el envío de correos o mensajes de texto con solo ajustar la implementación del servicio, cumpliendo así con el principio abierto-cerrado ✅.
Es clave para garantizar la coherencia y la interoperabilidad en sistemas orientados a objetos. Propone que las subclases deben ser intercambiables con sus clases base sin alterar el comportamiento esperado. Esto evita problemas inesperados y asegura que las clases que implementen una interfaz o hereden de otra puedan utilizarse de manera consistente, facilitando la reutilización del código y reduciendo errores en tiempo de ejecución.
Este principio, propuesto por Barbara Liskov, establece que las subclases deben ser sustituibles por sus clases base sin afectar el comportamiento del programa. Es esencial para asegurar que el sistema se mantenga coherente y funcione correctamente cuando se emplean clases derivadas.
Las subclases deben respetar el contrato de la clase base. Esto significa que:
- ❌ No se puede cambiar la firma de los métodos.
⚠️ No se deben agregar nuevos atributos que afecten la funcionalidad de los métodos existentes.- 🔗 La interfaz y los tipos deben mantenerse compatibles.
El LSP ayuda a evitar errores como:
- ❗ Excepciones inesperadas cuando se requieren parámetros adicionales no previstos en la clase base.
- 🚫 Cambios en el tipo de retorno de los métodos que interrumpen la compatibilidad entre clases.
- 🔄 Reutilización del código: Las clases que cumplen con LSP pueden ser utilizadas en diferentes contextos sin modificaciones.
- 🤝 Compatibilidad de interfaces: Facilita que las clases puedan interactuar de forma coherente sin errores inesperados.
- 🧱 Reducción de errores en tiempo de ejecución: El código se mantiene predecible y coherente, disminuyendo la posibilidad de fallos imprevistos.
Es necesario aplicarlo cuando:
⚠️ Hay violación de precondiciones o poscondiciones, es decir, cuando los parámetros o el tipo de retorno de los métodos cambian.- 🛑 Se presentan excepciones inesperadas al usar subclases, lo que indica que no se puede hacer una sustitución sencilla entre ellas.
nos permite escribir código flexible y reutilizable, pero también requiere que todas las clases o protocolos cumplan con una firma coherente. En esta clase, hemos aplicado este principio en Python reemplazando las clases abstractas con protocolos y detectando un bug deliberado que rompe este principio. Vamos a analizar cómo lo resolvimos y cómo aseguramos que las clases de notificación sean intercambiables sin modificar el código base.
- 🔁 Se sustituyeron las clases abstractas por protocolos en Python.
- 🧩 Los protocolos actúan de manera similar a las interfaces en otros lenguajes de programación.
- 📤 En este caso, el
Notifiery elPaymentProcessorfueron convertidos en protocolos. - 📚 Los métodos dentro de los protocolos fueron documentados usando docstrings en formato NumPy para mejorar la claridad.
- 💣 Se introdujo un bug a propósito al cambiar la clase
SMSNotifier. - ❌ El bug hizo que el método
SendConfirmationno cumpliera con la firma requerida, ya que estaba aceptando un parámetro adicional:SMSGateway. - 🔄 Esto provocaba que no fuera intercambiable con la clase
EmailNotifier, lo que viola el principio de sustitución de Liskov. - 🕵️♂️ Para detectarlo, se utilizó el debugger y un análisis de la firma del método.
- 🧷 Mantener la consistencia en las firmas de los métodos entre clases hijas y protocolos es crucial.
- 🚫 Es fundamental evitar introducir parámetros adicionales o cambiar las firmas de los métodos, ya que esto rompe la intercambiabilidad de las clases.
Es clave en la construcción de software flexible y modular. Su enfoque evita que las clases dependan de interfaces que no necesitan, promoviendo la cohesión y disminuyendo el acoplamiento. Este principio es esencial cuando trabajamos con dispositivos multifuncionales, como impresoras y escáneres, ya que cada dispositivo solo debe implementar lo que realmente usa.
🧠 ¿Qué es el ISP? El Principio de Segregación de Interfaces dice:
"Una clase no debe ser forzada a depender de métodos que no utiliza."
Dicho más fácil: si tienes una interfaz (o clase base) con muchos métodos, y hay clases hijas que solo usan algunos, ¡divídela!. Las clases deben implementar solo lo que realmente necesitan.
Este principio dice que los clientes no deben depender de interfaces que no utilizan. En el caso de una impresora multifuncional, por ejemplo, esta debería implementar interfaces para imprimir y escanear por separado. Si solo imprime, no necesita la capacidad de escaneo, y viceversa.
- 🎯 Mejora la cohesión y reduce el acoplamiento: Al separar los comportamientos, las clases son más especializadas y enfocadas en una tarea concreta.
- 🔁 Reutilización de componentes: Las interfaces segregadas permiten reutilizar partes del código sin tener que implementar todos los comportamientos en una misma clase.
- 🧱 Aislamiento de cambios: Si una interfaz, como la de impresión, cambia, las demás (como la de escaneo) no se ven afectadas.
- 🧪 Facilidad para realizar pruebas unitarias: Al tener interfaces pequeñas y específicas, es más sencillo probar cada comportamiento de manera aislada.
- 📏 Interfaces con demasiados métodos irrelevantes: Si una interfaz contiene muchos métodos que no son necesarios para todas las clases, es el momento de dividirla.
- 🚫 Clases que no usan todos los métodos: Cuando una clase no necesita todos los métodos de una interfaz, esto indica que es necesario implementar el ISP.
- 🧨 Cambios que afectan a muchas clases: Si al modificar una interfaz varias clases se ven afectadas, es un claro signo de que el principio de segregación es necesario.
Este principio es útil en sistemas donde ciertos módulos o clases tienen funcionalidades diversas. Para el procesador de pagos, ¿cómo segmentarías las interfaces para que cada clase implemente solo lo que necesita? Por ejemplo, podrías separar el procesamiento de tarjetas y la gestión de transacciones en interfaces diferentes, garantizando que los cambios en una parte del sistema no afecten otras funcionalidades.
es clave para mantener el código limpio y flexible en sistemas complejos como un procesador de pagos. En este caso, se abordaron varias mejoras dentro del procesador de pagos que incluyen la creación de métodos específicos para reembolsos 💸 y recurrencias 🔁, junto con la correcta segregación de las interfaces según las capacidades de cada procesador de pago.
✅ Se agregaron dos nuevos métodos: reembolso y creación de recurrencias.
✅ Se implementó un segundo procesador de pagos, el procesador offline, que simula pagos en efectivo 💵. Sin embargo, este procesador no puede realizar reembolsos ni crear recurrencias.
✅ El método processTransaction fue modificado para no depender del atributo de Stripe, ya que ahora hay múltiples procesadores.
El procesador de pagos offline implementaba métodos que no podía usar, como los reembolsos y las recurrencias, lo que violaba el principio de segregación de interfaces.
Este principio establece que una clase no debe depender de métodos que no puede implementar o usar ❌.
🔧 Se crearon dos nuevos protocolos:
RefundPaymentProtocolpara reembolsos 💸RecurringPaymentProtocolpara recurrencias 🔁
🧩 Estos protocolos definen exclusivamente los métodos para esas acciones, eliminando la necesidad de que procesadores como el offline implementen métodos que no necesitan.
💼 Los procesadores que pueden realizar todas las acciones, como Stripe, ahora implementan los tres protocolos: uno para cobros, otro para reembolsos y otro para recurrencias.
- Se agregaron atributos opcionales para los procesadores de reembolsos y de recurrencias (
RefundProcessoryRecurringProcessor), permitiendo que cada tipo de procesador maneje solamente las acciones que le corresponden. - Se implementaron validaciones para evitar excepciones en caso de que un procesador no soporte ciertas acciones.
🚨 Si el procesador no soporta reembolsos o recurrencias, se lanza una excepción con un mensaje claro.
- ✅ Stripe ahora maneja los tres protocolos, permitiendo cobrar, hacer reembolsos y gestionar pagos recurrentes.
- ✅ El procesador offline, al no manejar reembolsos ni recurrencias, ya no tiene que implementar esos métodos, cumpliendo con el principio de segregación de interfaces 🎯.
Es uno de los pilares en la construcción de software robusto, ya que busca disminuir la dependencia entre módulos de alto y bajo nivel, mejorando la flexibilidad y testabilidad del código. Este principio establece que tanto los módulos de alto como de bajo nivel deben depender de abstracciones, no de implementaciones concretas.
La definición formal indica que los módulos de alto nivel, que contienen la lógica de negocio, no deben depender de los módulos de bajo nivel, que gestionan los detalles de implementación. Ambos deben depender de abstracciones. Esto garantiza que los detalles de implementación dependan de las abstracciones y no al revés. Así, se facilita el cambio de implementaciones sin afectar al sistema principal.
Un ejemplo claro es un gestor de notificaciones con una interfaz que define el método enviar mensaje. Este gestor es un módulo de alto nivel que no depende de cómo se implementa el envío de mensajes. Las clases de bajo nivel, como el notificador por email 📧 o por SMS 📱, implementan esa interfaz, y el gestor puede cambiar de una a otra sin modificar su código. Esto muestra cómo el principio reduce el acoplamiento y facilita el mantenimiento 🛠️.
- 🔗 Modularidad: Al abstraer las implementaciones, las clases de alto nivel se mantienen independientes de los detalles.
- 🔄 Flexibilidad: Cambiar algoritmos o implementaciones es sencillo, ya que el sistema depende de interfaces y no de clases específicas.
- 🧪 Testabilidad: Facilita el uso de mocks en pruebas unitarias, simulando comportamientos sin depender de entornos complejos, como bases de datos.
- Cuando hay alto acoplamiento entre módulos de alto y bajo nivel, dificultando el mantenimiento 🔧.
- Si se presentan problemas para cambiar implementaciones sin afectar el resto del sistema
⚠️ . - Al realizar pruebas unitarias complicadas por la dependencia directa de implementaciones concretas 🧪.
- Cuando se dificulta la reutilización de componentes o el escalado del sistema 📈.
Son una herramienta poderosa en el desarrollo de software que permite resolver problemas comunes de una manera eficiente y reutilizable. En esta introducción, exploraremos qué son, por qué existen y cuáles son los principales tipos de patrones, como los creacionales, estructurales y de comportamiento, junto con ejemplos prácticos que te permitirán comprender su utilidad en la creación de software de calidad.
Los patrones de diseño son soluciones reutilizables para problemas comunes en el desarrollo de software. Imagina los patrones de diseño como recetas culinarias 🍳: cada patrón es como una receta que se aplica en condiciones específicas para resolver un problema recurrente en la programación.
Surgieron del libro "Design Patterns", escrito por cuatro autores conocidos como "La Banda de los Cuatro" 👨👨👦👦. Estos autores clasificaron 23 patrones en tres categorías distintas: creacionales, estructurales y de comportamiento.
Al igual que los principios SOLID, los patrones de diseño facilitan la creación de un código más mantenible y reutilizable 💾, y ayudan a establecer un lenguaje común entre los desarrolladores 🗣️.
Los patrones creacionales se centran en la creación de instancias de objetos, especialmente cuando una clase es compleja de instanciar debido a sus múltiples atributos o condiciones previas a su creación.
Hay cinco patrones de diseño creacionales destacados:
- 🔁 Singleton: Garantiza que una clase tenga solo una instancia y proporciona un punto de acceso global a ella.
- 🏭 Factory Method: Define una interfaz para crear un objeto, pero deja que las subclases decidan qué clase instanciar.
- 🧱 Abstract Factory: Ofrece una interfaz para crear familias de objetos relacionados sin especificar sus clases concretas.
- 🧑🍳 Builder: Separa la construcción de un objeto complejo de su representación, permitiendo crear diferentes tipos de objetos con el mismo proceso.
- 🧬 Prototype: Permite copiar instancias existentes sin depender de sus clases.
Los patrones de diseño estructurales están enfocados en la composición efectiva y escalable de clases y objetos. Proveen soluciones para estructurar tus clases de manera que maximicen la eficiencia y escalabilidad 🚀.
Los siete patrones estructurales clave son:
- 🔌 Adapter: Permite que interfaces incompatibles trabajen juntas.
- 🌉 Bridge: Desacopla una abstracción de su implementación para que ambas puedan variar independientemente.
- 🌳 Composite: Permite componer objetos en estructuras de árbol para representar jerarquías parte-todo.
- 🎨 Decorator: Añade nuevas funcionalidades a un objeto de manera dinámica.
- 🏛️ Facade: Simplifica la interfaz de un conjunto de clases.
- 🪶 Flyweight: Usa compartición para soportar de manera eficiente grandes cantidades de objetos.
- 👥 Proxy: Proporciona un sustituto o marcador de posición para controlar el acceso a otros objetos.
Los patrones de diseño de comportamiento abordan la comunicación y asignación de responsabilidades entre objetos 🧠, facilitando la interacción entre clases de distintas naturalezas.
Entre los 11 patrones de comportamiento propuestos, algunos de los más destacados son:
- 👁️ Observer: Permite a un objeto notificar a otros objetos sobre cambios en su estado.
- 🧠 Strategy: Define una familia de algoritmos, encapsula cada uno, y los hace intercambiables.
- 🧾 Command: Encapsula una solicitud como un objeto, permitiendo parametrizar a los clientes con diferentes solicitudes.
- 🔁 Iterator: Proporciona una manera de acceder secuencialmente a elementos de un objeto agregado sin exponer su representación subyacente.
- 🗣️ Mediator: Define un objeto que encapsula cómo interactúan un conjunto de objetos.
- 🔄 State: Permite a un objeto alterar su comportamiento cuando su estado interno cambia.
- 🧭 Visitor: Permite definir nuevas operaciones sobre objetos de una estructura sin cambiar las clases de los elementos sobre los cuales opera.
En proyectos reales, no es necesario usar todos los patrones de diseño 📦. La clave está en seleccionar aquellos que mejor se adapten al contexto específico del proyecto 🎯.
En este curso, se verán algunos de los patrones más utilizados en la industria 🏭, aplicables al tipo de software que desarrollaremos, y serán clave para resolver problemas comunes de manera eficiente ✅.
El patrón de diseño Strategy es una herramienta clave en el desarrollo de software, permitiendo cambiar dinámicamente entre diferentes algoritmos o estrategias para resolver un problema, sin alterar la estructura del programa. Este patrón es ideal para situaciones donde múltiples soluciones son viables, adaptándose al contexto en tiempo de ejecución, como lo ejemplifica el procesamiento de pagos 💳.
Este patrón de comportamiento facilita el intercambio de algoritmos que resuelven el mismo problema de distintas formas 🔄. Es útil en situaciones donde diferentes estrategias pueden ser aplicadas según el contexto, permitiendo que el programa sea flexible y adaptable sin modificar su estructura central 🏗️.
El patrón Strategy permite la modificación de la estrategia mediante métodos que cambian la clase o el algoritmo que se está utilizando 🔧. En el ejemplo presentado, se utiliza el método SetProcessor, que permite al servicio de pagos intercambiar entre diferentes procesadores de pago durante la ejecución del programa 🔁.
- Se define una interfaz o protocolo que las diferentes estrategias deben implementar 📑.
- La clase de alto nivel, en este caso
PaymentService, no depende de las implementaciones concretas, sino de la interfaz 🧩. - Las estrategias concretas implementan esta interfaz, lo que permite la inyección de la estrategia adecuada según el contexto 💡.
- Un método como
SetProcessorfacilita la selección y aplicación de la estrategia durante la ejecución 🔄.
La elección de la estrategia adecuada puede hacerse a través de una función externa o clase que analice las condiciones del problema y determine cuál es la mejor solución 🧮. Esta selección no tiene que estar dentro de la clase de alto nivel, permitiendo una mayor modularidad y escalabilidad en el sistema 📦.
- Flexibilidad para intercambiar algoritmos sin cambiar la lógica central 🔄.
- Desacopla las clases de alto nivel de las implementaciones específicas 🔓.
- Mejora la mantenibilidad y escalabilidad del código 🚀.
El Factory Pattern es uno de los patrones de diseño más importantes en el desarrollo de software, y su principal característica es su capacidad para crear objetos sin necesidad de especificar la clase exacta 🧱. Esto se logra mediante el uso de interfaces, abstracciones o protocolos. Este patrón encapsula la lógica de creación, centralizando las decisiones sobre qué clases crear y cómo hacerlo, permitiendo así una mayor flexibilidad y escalabilidad en el sistema 🚀.
La esencia del Factory Pattern reside en la creación de una clase llamada Factory, la cual contiene un método que instancia y devuelve objetos basados en ciertos parámetros 🧩. Al aplicar este patrón, el lugar donde se toman las decisiones de creación se centraliza, lo que facilita mantener y escalar el sistema 📈.
📌 Por ejemplo, en un diagrama de clases se podría visualizar una clase de alto nivel llamada PaymentService, que interactúa con PaymentFactory. Esta última tiene un método GetProcessor que decide cuál de los procesadores de pago usar, basándose en un tipo específico pasado como parámetro. Así, PaymentFactory centraliza la lógica de decidir cuál procesador utilizar, permitiendo fácilmente integrar nuevos procesadores en el futuro 💡.
El Factory Pattern se convierte en una excelente opción en los siguientes escenarios:
- 🔁 Cuando hay múltiples clases que comparten una interfaz común. Esto es especialmente útil en sistemas donde se requiere el uso de distintas estrategias para resolver problemáticas similares.
- 🧠 Cuando la creación de objetos requiere una compleja lógica de decisión. Si hay muchas variables para decidir qué clase seleccionar, este patrón ayuda a simplificar el proceso y establecer un control centralizado.
✨ Además, funciona muy bien junto al patrón Strategy, facilitando la selección de estrategias adecuadas para cada caso de uso.
Para implementar el Factory Pattern, sigue estos pasos:
- 📐 Define interfaces comunes: Asegúrate de que todas las posibles clases a instanciar implementen una interfaz o abstracción común.
- 🏗️ Crea la clase Factory: Desarrolla una clase que tenga un método encargado de recibir parámetros. Este método debe determinar qué clase instanciar.
- 🧪 Utiliza el Factory en el código cliente: Cada vez que necesites crear una instancia, utiliza el Factory para obtener el objeto correcto.
Así, al integrar el Factory Pattern, logramos crear aplicaciones más flexibles y escalables, permitiendo modificar e integrar nuevas funcionalidades sin alterar la estructura fundamental del sistema 💼.
- 🧊 Abstracción: Crea objetos sin especificar la clase exacta, utilizando interfaces o abstracciones.
- 📦 Encapsulamiento: Centraliza la lógica de creación de objetos, simplificando la instancia de clases.
- 🔧 Flexibilidad: Permite añadir nuevos tipos de objetos sin modificar el código existente.
- ✅ Cuando hay múltiples clases que comparten una interfaz.
- ✅ Cuando la creación de objetos requiere lógica compleja.
- Crea una clase
Factorycon un método que instancie objetos basados en parámetros. - Usa el
Factorydonde se requieran instancias, facilitando cambios futuros 🔄.
El Patrón Decorador es un patrón de diseño estructural que permite añadir responsabilidades de manera dinámica a objetos. Este patrón es especialmente útil cuando necesitamos extender la funcionalidad de un objeto sin alterar su estructura original. A menudo, es necesario agregar lógica adicional sin modificar el comportamiento ya existente, lo cual es precisamente la esencia de lo que el Patrón Decorador ofrece.
Este patrón permite una composición flexible de comportamientos adicionales. Si tienes múltiples decoradores, puedes combinarlos para incrementar la lógica sobre un comportamiento base. Así, actúa como una forma modular y flexible para que los desarrolladores adapten las funcionalidades de su aplicación a diferentes situaciones sin tener que reescribir el código base.
El Patrón Decorador es útil cuando necesitas combinar múltiples comportamientos de forma modular. Imagina que tienes un comportamiento deseado y, alrededor de él, necesitas otro comportamiento; en este caso, el patrón te permite componerlos. Además, es ideal para situaciones en las que se necesita añadir funcionalidades en tiempo de ejecución debido a una condición específica en el código o contexto.
Implementar el Patrón Decorador puede parecer complejo al principio, pero se puede simplificar siguiendo estos cinco pasos:
-
🧱 Definir una interfaz o clase abstracta: Esta describe el comportamiento del objeto base, el cual tiene el comportamiento principal.
-
🛠️ Implementar la interfaz en clases concretas: Aquí, el comportamiento se abstrae hacia una interfaz y luego se implementa en varias clases concretas.
-
🧵 Crear una clase decoradora abstracta: Esta clase también implementa la interfaz definida en el primer paso y actúa como base para los decoradores.
-
🎯 Implementar decoradores concretos: Los decoradores concretos extienden la clase decoradora y contienen el comportamiento adicional que se desea implementar.
-
🧬 Envolver objetos básicos con decoradores: Finalmente, los objetos básicos se envuelven con los decoradores creados, permitiendo la composición de funcionalidades.
Este proceso se convierte en algo más intuitivo cuando se lo aplica en código, permitiendo visualizar claramente cómo cada componente interactúa.
Supongamos que tenemos un sistema para enviar notificaciones, y queremos añadir distintas formas de notificación de forma dinámica. A continuación, te mostramos una implementación básica del Patrón Decorador en Python:
# Interfaz para la notificación
class Notificacion:
def enviar(self):
pass
# Implementación concreta de la notificación básica
class NotificacionBasica(Notificacion):
def enviar(self):
return "Notificación enviada"
# Clase decoradora abstracta
class DecoradorNotificacion(Notificacion):
def __init__(self, notificacion):
self.notificacion = notificacion
def enviar(self):
return self.notificacion.enviar()
# Decorador para añadir un mensaje en el log
class NotificacionConLog(DecoradorNotificacion):
def enviar(self):
resultado = super().enviar()
return f"{resultado} con registro en el log"
# Uso del patrón decorador
notificacion = NotificacionBasica()
notificacion_con_log = NotificacionConLog(notificacion)
print(notificacion_con_log.enviar()) # Salida: "Notificación enviada con registro en el log"En este ejemplo, hemos implementado una notificación básica y posteriormente añadido un decorador para incluir un registro en el log, demostrando así cómo el Patrón Decorador puede ser utilizado para modularizar y extender funcionalidades. 💡
El Builder Pattern es un patrón de diseño creacional que se utiliza para construir objetos complejos paso a paso. Es especialmente útil cuando se desea separar el proceso de construcción de la representación final del objeto.
Una de sus principales ventajas es que proporciona una interfaz fluida para construir objetos de manera progresiva. Esto facilita la creación de diferentes variantes del mismo objeto sin modificar su estructura interna.
💡 Por ejemplo, si trabajamos con un objeto tipo
Product, el Builder puede permitir construirlo parte por parte (parte A,parte B,parte C), y luego usar un métodobuild()para obtener el objeto final.
Este patrón es ideal cuando:
- 🔁 La creación de un objeto requiere múltiples pasos.
- 🤹♂️ Hay mucha lógica en el proceso de construcción.
- 🎭 Necesitamos separar la construcción del objeto de su representación final.
Así evitamos tener constructores con demasiados parámetros o lógica embebida difícil de mantener.
Para usar el patrón Builder, sigue estos pasos:
- Crear una clase Builder que maneje el proceso de creación del objeto.
- Definir métodos para cada atributo que se debe configurar.
- Agregar un método
build()que devuelva el objeto completamente armado.
Esto proporciona control y claridad, especialmente útil en objetos con múltiples configuraciones posibles.
# Clase Producto
class Pizza:
def __init__(self):
self.ingredientes = []
def __str__(self):
return f"Pizza con: {', '.join(self.ingredientes)}"
# Builder para Pizza
class PizzaBuilder:
def __init__(self):
self.pizza = Pizza()
def agregar_queso(self):
self.pizza.ingredientes.append("queso")
return self
def agregar_pepperoni(self):
self.pizza.ingredientes.append("pepperoni")
return self
def agregar_champiñones(self):
self.pizza.ingredientes.append("champiñones")
return self
def build(self):
return self.pizza
# Cliente
pizza_builder = PizzaBuilder()
pizza = pizza_builder.agregar_queso().agregar_pepperoni().build()
print(pizza) # Salida: Pizza con: queso, pepperoniCon este patrón logramos una construcción modular, legible y escalable, ideal para objetos con muchas configuraciones opcionales 🍕
Profundizar en el Observer Pattern es descubrir un patrón de diseño que proporciona una solución eficiente para la gestión de notificaciones de eventos en un sistema. Este patrón es esencial en arquitecturas donde se necesita implementar servicios de suscripción y notificación, y por su simplicidad y eficacia, es ampliamente utilizado en la industria del software.
El patrón se compone de tres entidades principales:
-
🔧 Clase Manager
Encargada de manejar la dinámica de agregar, remover y notificar a todos los listeners suscritos.Métodos principales:
- ✅ Agregar listeners: Permite suscribir objetos interesados en eventos.
- ❌ Remover listeners: Retira aquellos que ya no necesitan notificaciones.
- 📢 Notificar a listeners: Informa a todos los suscriptores cuando ocurre un evento.
-
🧩 Interfaz Listener
Define un métodoupdate(evento)que será invocado cada vez que ocurra un evento. -
📦 Clases que implementan Listener
Son servicios o componentes que necesitan recibir notificaciones. Por ejemplo, un servicio de email o login, ambos implementando el métodoupdate.
Este patrón es ideal cuando:
- 🔄 Un cambio en un objeto debe notificarse a múltiples objetos.
- 🔔 Se necesita un sistema de eventos estructurado.
- 🔗 Se requieren mecanismos de suscripción y notificación flexibles y desacoplados.
Su versatilidad lo convierte en una herramienta poderosa para evitar dependencias rígidas entre componentes.
Sigue estos 3 pasos básicos:
-
Definir la interfaz Listener
- Declara el método que manejará las notificaciones (
update).
- Declara el método que manejará las notificaciones (
-
Crear la clase Manager
- Implementa métodos para agregar, remover y notificar a los listeners.
-
Usar el Manager en la clase principal
- Incluye el Manager como atributo en una clase de alto nivel, como
PaymentService, que llama anotify_all()cuando se requiere comunicar un evento.
- Incluye el Manager como atributo en una clase de alto nivel, como
El uso del Observer Pattern facilita la comunicación eficaz y la gestión de eventos en un sistema. Su estructura modular y flexible:
- 🧩 Promueve la separación de responsabilidades.
- 🔧 Mejora la mantenibilidad.
- 📡 Permite que los componentes interactúen sin estar estrechamente acoplados.
¡Una excelente herramienta para proyectos que necesitan escalabilidad y flexibilidad desde la arquitectura! 🚀
El Chain of Responsibility es un patrón de diseño de comportamiento diseñado para gestionar solicitudes a través de una cadena de manejadores, cada uno con una responsabilidad única. Esta cadena permite que distintas condiciones se apliquen a una solicitud en su paso por los diferentes manejadores.
Es ampliamente utilizado en sistemas que requieren pasos de validación, como servicios de pago, autenticación o autorizaciones.
Este patrón es ideal en situaciones donde se necesita:
- ✅ Procesar solicitudes en una serie de pasos definidos, aumentando la modularidad.
- ✅ Implementar sistemas de validación de datos que demandan flexibilidad.
- ✅ Crear estructuras para autenticación, autorización o validación condicional antes de ejecutar una acción.
Para implementar este patrón, sigue estos pasos:
-
Define una interfaz o clase abstracta para los manejadores
Esta será la base para todos los componentes que participen en la cadena. -
Implementa cada manejador heredando de la clase base
Cada manejador tendrá su lógica de validación específica. -
Configura la cadena de manejadores
Establece el orden en que se procesan las solicitudes a través de los manejadores. -
Envía la solicitud al primer manejador
Este podrá procesarla, rechazarla o delegarla al siguiente manejador.
class Validator:
def set_next(self, handler):
self.next_handler = handler
return handler
def validate(self, data):
if self.next_handler:
return self.next_handler.validate(data)
return True
class MontoValidator(Validator):
def validate(self, data):
if data["monto"] < 1000:
print("✅ Monto válido")
return super().validate(data)
print("❌ Monto no válido")
return False
class TarjetaValidator(Validator):
def validate(self, data):
if data["tarjeta"] == "VISA":
print("✅ Tarjeta válida")
return super().validate(data)
print("❌ Tarjeta no válida")
return False
class FraudeValidator(Validator):
def validate(self, data):
if not data.get("fraud", False):
print("✅ No se detectó fraude")
return super().validate(data)
print("❌ Posible fraude detectado")
return False
# Configuramos la cadena
monto_validator = MontoValidator()
tarjeta_validator = TarjetaValidator()
fraude_validator = FraudeValidator()
monto_validator.set_next(tarjeta_validator).set_next(fraude_validator)
# Simulamos una solicitud
solicitud = {"monto": 500, "tarjeta": "VISA", "fraud": False}
if monto_validator.validate(solicitud):
print("🎉 Solicitud aprobada")
else:
print("🚫 Solicitud rechazada")
# 📘 Relación de Flechas y Líneas en Diagramas de Clases UML
En los **diagramas de clases UML**, las **líneas y flechas** representan diferentes tipos de relaciones entre clases. A continuación te explico los más comunes:
---
## 🔹 Línea continua con flecha abierta – **Herencia (Generalización)**
- **Símbolo:** `────────▷`
- **Significa:** Una clase hereda de otra (subclase → superclase).
- **Ejemplo:**
`Perro ───────▷ Animal`
`Perro` hereda de `Animal`.
---
## 🔹 Línea continua sin flecha – **Asociación**
- **Símbolo:** `────────────`
- **Significa:** Una clase tiene una relación directa con otra (usa o contiene).
- **Ejemplo:**
`Pedido ───────── Cliente`
`Pedido` está asociado a `Cliente`.
---
## 🔹 Línea continua con rombo blanco – **Agregación**
- **Símbolo:** `◊────────────`
- **Significa:** Una clase contiene a otra, pero las partes pueden existir de forma independiente.
- **Ejemplo:**
`Departamento ◊────────── Empleado`
Un `Departamento` tiene `Empleados`, pero estos pueden existir sin él.
---
## 🔹 Línea continua con rombo negro – **Composición**
- **Símbolo:** `◆────────────`
- **Significa:** Una clase contiene a otra y las partes no pueden existir por separado.
- **Ejemplo:**
`Casa ◆────────── Habitación`
Las `Habitaciones` existen solo dentro de una `Casa`.
---
## 🔹 Línea de guiones con flecha abierta – **Dependencia**
- **Símbolo:** `- - - - - -▷`
- **Significa:** Una clase depende temporalmente de otra (por ejemplo, como parámetro o variable local).
- **Ejemplo:**
`Servicio - - - - -▷ Logger`
`Servicio` usa a `Logger` sin contenerlo directamente.
---
## 🔹 Línea de guiones con flecha cerrada – **Realización**
- **Símbolo:** `- - - - -▷` con triángulo vacío
- **Significa:** Una clase implementa una interfaz.
- **Ejemplo:**
`Usuario - - - -▷ Autenticable`
`Usuario` implementa la interfaz `Autenticable`.
---
> ✅ Estos símbolos te ayudan a interpretar y diseñar diagramas de clase de forma clara y profesional.