Publicación: 5 months

Cómo usar el patrón de diseño composición en mi día a día como desarrollador


En nuestros inicios como programadores comenzamos escribiendo código conforme se nos ocurra y esto es totalmente valido, estamos aprendiendo. Sin embargo, a la hora de ir adquiriendo más experiencia, nos empezamos a cuestionar si el código que estoy escribiendo es entendible para otro dev.

Los patrones de diseño son soluciones a problemas recurrentes en el día a día de los devs. Por lo que son un buen instrumento para que nuestro código sea entendible por otros.

Para que un patrón sea entendible fácilmente por otros, generalmente tiene un nombre, descripción del problema, solución, estructura y los participantes. Estos puntos hacen que cualquier patrón de diseño sea fácil de entender por los devs.

Ahora bien, hablemos propiamente del patrón de diseño Composición.

El patrón de diseño Composición permite que los objetos se compongan en estructuras de árbol para representar jerarquías de parte-todo. Este patrón trata de individuos y sus composiciones de forma uniforme.

“Design Patterns: Elements of Reusable Object-Oriented Software" por Erich Gamma, Richard Helm, Ralph Johnson, y John Vlissides (1994)

Para entender una estructura jerárquica podemos pensar en un organigrama organizacional donde existen áreas y roles dentro de ellas.

  • Organización Empresarial:
    • Director General
      • Gerente de Ventas
        • Vendedor 1
        • Vendedor 2
      • Gerente de Marketing
        • Especialista en Redes Sociales
        • Especialista en SEO

Ahora bien, supongamos que necesitamos una aplicación donde nos permita pagar un servicio. El servicio necesita un tipo, un monto, una cuenta de cliente y el tipo de moneda.

Los tipos que existen son Recargas y Paquetes Telcel.
Los tipos de moneda que se aceptan son MXN y USD.

A nivel técnico el flujo es el siguiente

  1. Recibimos el pago del servicio
  2. Validamos el tipo de recarga enviada
  3. Validamos el tipo de moneda enviada
  4. Validamos con un sistema externo si el cliente tiene saldo en su cuenta
  5. Realizamos el pago del servicio usando un servicio de terceros
  6. Sincronizamos la respuesta del pago de servicio

Proceso de pago de servicios


Posible solución

   1:     public Payment paidService(Payment payment) {
   2:         validarTipoServicio(payment);
   3:         validarTipoMoneda(payment);
   4: 
   5:         var saldo = validarSaldoCliente(payment);
   6:         if(payment.getAmount() > saldo) {
   7:             throw new UnprocessableEntityException("No tiene el saldo suficiente para realizar un pago de servicio");
   8:         }
   9:         
   10:         var pagoServicioTerceroRes = realizarPagoServicioTercero(payment);
   11:         
   12:         sincronizarPagoConPagoServicioTerceroRes(pagoServicioTerceroRes);
   13: 
   14:         return payment;
   15:     }

Esta solución parece bastante fácil de entender, sin embargo, conforme vaya creciendo iremos perdiendo el foco. Supongamos que el día de mañana se nos pide insertar logs, ya que al ser un proceso que maneja dinero es super importante trazar el camino por donde pasa el pago.

   1:     public Payment paidService(Payment payment) {
               log.info("inicio del pago “ + payment);

               log.info("inicio de validarTipoServicio  “ + payment);
   2:          validarTipoServicio(payment);
               log.info("fin de validarTipoServicio  “ + payment);
               log.info("inicio de validarTipoMoneda  “ + payment);
   3:          validarTipoMoneda(payment);
               log.info("fin de validarTipoMoneda  “ + payment);
   4: 
               log.info("inicio de validarSaldoCliente  “ + payment);
   5:          var saldo = validarSaldoCliente(payment);
               log.info("fin de validarSaldoCliente  “ + payment);
   6:          if(payment.getAmount() > saldo) {
   7:             throw new UnprocessableEntityException("No tiene el saldo suficiente para realizar un pago de servicio");
   8:          }
   9:         
               log.info("inicio de realizarPagoServicioTercero  “ + payment);
   10:         var pagoServicioTerceroRes = realizarPagoServicioTercero(payment);
               log.info("fin de realizarPagoServicioTercero  “ + payment);
   11:         
               log.info("fin de sincronizarPagoConPagoServicioTerceroRes  “ + payment);
   12:         sincronizarPagoConPagoServicioTerceroRes(pagoServicioTerceroRes);
               log.info("inicio de sincronizarPagoConPagoServicioTerceroRes  “ + payment);
   13: 
               log.info("fin del pago “ + payment);
   14:         return payment;
   15:     }

El proceso de paidServer se empieza a ensuciar poco a poco conforme nuevos requerimientos. La siguiente semana se nos pide agregar un requerimiento. Una vez que se realiza el pago del servicio usando un servicio de terceros se nos solicita notificar al servicio de saldos el estatus del pago.

   1:     public Payment paidService(Payment payment) {
               log.info("inicio del pago “ + payment);

               log.info("inicio de validarTipoServicio  “ + payment);
   2:          validarTipoServicio(payment);
               log.info("fin de validarTipoServicio  “ + payment);
               log.info("inicio de validarTipoMoneda  “ + payment);
   3:          validarTipoMoneda(payment);
               log.info("fin de validarTipoMoneda  “ + payment);
   4: 
               log.info("inicio de validarSaldoCliente  “ + payment);
   5:          var saldo = validarSaldoCliente(payment);
               log.info("fin de validarSaldoCliente  “ + payment);
   6:          if(payment.getAmount() > saldo) {
   7:             throw new UnprocessableEntityException("No tiene el saldo suficiente para realizar un pago de servicio");
   8:          }
   9:         
               log.info("inicio de realizarPagoServicioTercero  “ + payment);
   10:         var pagoServicioTerceroRes = realizarPagoServicioTercero(payment);
               log.info("fin de realizarPagoServicioTercero  “ + payment);

               log.info("inicio de notificaciónPagoAlServicioSaldos  “ + pagoServicioTerceroRes);
               var pagoServicioTerceroRes = notificaciónPagoAlServicioSaldos(pagoServicioTerceroRes);        
               log.info("fin de notificaciónPagoAlServicioSaldos  “ + pagoServicioTerceroRes);
   11:         
               log.info("fin de sincronizarPagoConPagoServicioTerceroRes  “ + payment);
   12:         sincronizarPagoConPagoServicioTerceroRes(pagoServicioTerceroRes);
               log.info("inicio de sincronizarPagoConPagoServicioTerceroRes  “ + payment);
   13: 
               log.info("fin del pago “ + payment);
   14:         return payment;
   15:     }

Conforme el código vaya creciendo será más difícil de interpretarlo. Por lo que surge la necesidad de pensar en un nuevo enfoque para estructurar el código. ¿El patrón composición se adaptaría a nuestro problema? 

Si estructuramos nuestro proceso jerárquicamente quedaría de la siguiente manera.

paidService
  • validarTipoServicio
  • validarTipoMoneda
  • validarSaldoCliente
  • realizarPagoServicioTercero
  • notificaciónPagoAlServicioSaldos
  • sincronizarPagoConPagoServicioTerceroRes
stopService
  • validarTipoServicio
  • validarTipoMoneda
  • validarSaldoCliente
  • detenerServicioUsandoServicioTercero

Sumado a esto, tenemos que varios subprocesos comparten la data, ya sea del pago o de la respuesta propia del subproceso.

Para implementar este patrón necesitamos tres tipos de clases.

Component. Define una interfaz para los objetos que pueden ser compuestos. Declara una interfaz para acceder y manejar los hijos de los objetos compuestos.
Leaf. Representa los objetos que no tienen hijos. Una Leaf realiza el comportamiento definido en la interfaz Component.
Composite. Define un comportamiento para los componentes que tienen hijos y almacena dichos hijos. Implementa las operaciones relacionadas con los hijos en la interfaz Component.

Autor: Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides, Design Patterns: Elements of Reusable Object-Oriented Software (1994).

Siguiendo la definición, tendríamos.

// Component
interface PaymentServiceOperation { 
  Object execute(); 
}

// Composite
class PaidProcess {
    private static final Logger logger = LoggerFactory.getLogger(PaidServiceProcess.class);

    private final List<AbstractPaymentOperation> operations = new ArrayList<>();

    public void addOperation(AbstractPaymentOperation operation) {
        operations.add(operation);
    }

    public void execute(Payment payment, Object data) {
        logger.info("Starting paid service process for payment ID: {}", payment.getId());
        try {
            for (AbstractPaymentOperation operation : operations) {
                logger.info("Executing operation: {}", operation.getClass().getName());
                operation.setPayment(payment);
                data = operation.execute(data);
            }
        } finally {
            paymentRepository.save(payment);
           logger.info("Api apiRequest saved: {}", payment.getId());
        }
    }
}

// Leaf
class ValidarTipoServicio extends AbstractPaymentOperation {
    @Override
    public Object execute(Object data) {
        return new Object();
    }
}

class ValidarTipoMoneda extends AbstractPaymentOperation {
    @Override
    public Object execute(Object data) {
        return new Object();
    }
}

class ValidarSaldoCliente extends AbstractPaymentOperation {
    @Override
    public Object execute(Object data) {
        return new Object();
    }
}

class RealizarPagoServicioTercero extends AbstractPaymentOperation {
    @Override
    public Object execute(Object data) {
        return new Object();
    }
}

class NotificacionPagoAlServicioSaldos extends AbstractPaymentOperation {
    @Override
    public Object execute(Object data) {
        return new Object();
    }
}

class SincronizarPagoConPagoServicioTerceroRes extends AbstractPaymentOperation {
    @Override
    public Object execute(Object data) {
        if (!(data instanceof PagoServicioTerceroDto)) {
           throw new IllegalArgumentException("Invalid data type for SincronizarPagoConPagoServicioTerceroRes");
        }
        return new Object();
    }
}

// Esta clase es extra, la ocupamos para mantener accesible el Payment a través de todo el proceso
abstract class AbstractPaymentOperation implements PaymentServiceOperation {
    protected Payment payment;
}

Ahora pongamos esto en acción.

public Payment paidService(Payment payment) {
  var paidProcess = new PaidProcess();
  paidProcess.addOperation(new ValidarTipoServicio());
  paidProcess.addOperation(new ValidarTipoMoneda());
  paidProcess.addOperation(new ValidarSaldoCliente());
  paidProcess.addOperation(new RealizarPagoServicioTercero());
  paidProcess.addOperation(new NotificacionPagoAlServicioSaldos());
  paidProcess.addOperation(new SincronizarPagoConPagoServicioTerceroRes());

  // Si es necesario inicializar el proceso con algún valor.
  paidProcess.execute(payment, null);

  return payment;
}

Al implementar este patrón, el proceso luce más limpio y fácil de entender por cualquier dev que no tenga contexto sobre el proceso. Sin embargo, hay que ser cautelosos al estar agregando capas de abstracción a nuestras aplicaciones, a futuro pueden ser difíciles de mantener.

Ventajas

  • Proceso fácil de entender.
  • Cada que agreguemos una nueva operación se loguear cuando inicio, cuando se ejecuta y cuando finaliza. Ver clase PaidProcess
  • Crear una nueva operación basta con implementar la interfaz y agregarla en el orden deseado
  • Podemos validar la data obligatoria para cada operación. Ver operación SincronizarPagoConPagoServicioTerceroRes
  • La clase PaidProcess (Composite) gestiona todo el ciclo de vida de cada una de las operaciones, por lo tanto, agregar funcionalidad impactara a todas.
  • Al tener separadas las clases por roles diferentes, las pruebas se vuelven más fáciles.
  • Al crear un nuevo proceso podemos reutilizar las operaciones.

Desventajas

  • Se agrega una nueva capa de abstracción.
  • Al tener más archivos, saltar entre cada implementación, puede hacernos perder el hilo de donde estamos.
  • Difícil de entender para personas nuevas en el desarrollo 

¿Ves alguna otra ventaja o desventaja?