Cambias la biblioteca de pagos y empiezan a aparecer errores en el proceso de compra, las facturas y los correos. Al parecer, media tienda conocía los detalles del proveedor. El cambio de librería acaba convertido en una visita guiada por el proyecto.
Las dependencias y los contratos ayudan a entender cómo hemos llegado ahí y qué podemos hacer para contener mejor esos cambios.
Qué es una dependencia
Una dependencia aparece cuando una parte necesita otra para definirse o funcionar. Puede necesitar una clase, un formato de datos, una tabla o un servicio externo.
El proceso de compra depende de alguna capacidad para cobrar. Eso tiene sentido. La pregunta es cuánto necesita conocer de la solución concreta que la proporciona.
Si construye las peticiones del proveedor, interpreta sus códigos y devuelve sus objetos al resto de la tienda, muchos detalles externos quedan repartidos. Cada consumidor puede acabar dependiendo de ellos.
Podemos concentrar esa traducción en una integración y ofrecer al proceso de compra una operación que represente lo que necesita hacer. La relación sigue existiendo, pero queda más delimitada.
Los parámetros solo cuentan una parte
Considera esta llamada ilustrativa en Java; presupone un colaborador y unas variables ya definidos:
paymentGateway.charge(amountInCents, currency, attemptKey);
Parece fácil de utilizar. Antes de llamarla convendría aclarar algunas cosas:
- ¿El importe se expresa en euros o en céntimos?
- ¿Qué monedas admite?
- ¿Cómo se distingue un rechazo de una interrupción de red?
- ¿La respuesta confirma un cobro completado o una solicitud aceptada?
- ¿Qué ocurre si repetimos la operación?
El contrato reúne las expectativas sobre entradas, resultados, errores y efectos observables. Una firma compatible puede esconder comportamientos muy diferentes.
Si el proceso de compra interpreta «aceptado» como «pagado», podría confirmar un pedido antes de conocer el resultado del cobro. Ese desacuerdo existe aunque todos los tipos encajen perfectamente.
Antes, después y durante
Podemos ordenar esas expectativas con tres ideas:
- Una precondición debe cumplirse antes de ejecutar la operación. Para solicitar un envío, el pedido debe estar preparado.
- Una postcondición describe lo que se garantiza al terminar correctamente. Una solicitud de envío confirmada debe quedar asociada a su referencia de expedición.
- Una invariante es una condición que debe mantenerse en los estados válidos del modelo. En nuestra tienda simplificada, todo pedido enviado debe tener una referencia de expedición.
Impedir que un pedido cancelado pase a enviado es una regla de transición: limita los cambios permitidos entre estados. Está relacionada con las invariantes, pero responde a otra pregunta. Una comprueba si el estado es válido; la otra, si podemos llegar hasta él desde el estado anterior.
Estas condiciones pueden reflejarse en tipos, validaciones, documentación y pruebas. Lo importante es que quien utiliza la operación conozca sus obligaciones y pueda confiar en sus garantías.
También hay que decidir qué significa fallar. Un rechazo explícito del proveedor permite tomar una decisión distinta de una conexión interrumpida cuyo resultado desconocemos.
Repetir una solicitud sin duplicar sus efectos
Imagina que el proveedor ha cobrado, pero la respuesta no llega a la tienda. Si repetimos la petición sin ninguna protección, podemos cobrar dos veces.
La idempotencia permite que repetir una operación con la misma identidad lógica mantenga el mismo efecto previsto que ejecutarla una vez. En este caso podemos asociar una clave al intento de pago.
paymentGateway.charge(amountInCents, currency, attemptKey);
La tienda conserva esa clave y los mismos datos al reintentar. El mecanismo que procesa el cobro debe reconocerlos para evitar duplicados, también si llegan peticiones concurrentes. Añadir el parámetro por sí solo no proporciona esa garantía.
El contrato necesita aclarar cuánto dura esa protección y qué sucede si llega la misma clave con datos diferentes. Son detalles que importan cuando el sistema deja de seguir el recorrido feliz.
El diagrama de secuencia se lee de arriba abajo. Las líneas verticales representan a los participantes y las flechas muestran sus mensajes. La primera petición llega a cobrarse; lo que se pierde es la respuesta. El reintento conserva la clave attempt-42, de modo que el proveedor reconoce la operación y devuelve su resultado anterior.
La clave identifica el intento lógico, no cada petición de red. Crear otra en cada reintento anularía esta protección. También habrá que conservarla de forma duradera si queremos recuperarnos tras un reinicio. attempt-42 es una etiqueta didáctica: en producción necesitamos claves únicas dentro del ámbito que defina el proveedor.
El diagrama representa un proveedor que conserva el resultado del cobro. Las condiciones concretas pueden variar según la API que estemos utilizando.
Elegir la dirección del conocimiento
La regla que calcula una promoción puede recibir los datos de la compra sin conocer el framework web o la conexión a la base de datos. Esa decisión facilita entender y comprobar el cálculo de forma aislada.
Para los pagos podemos hacer algo parecido: el proceso de compra utiliza un contrato de cobro y una integración se ocupa de traducirlo al proveedor. En este esquema, las flechas representan dependencias de código:
Proceso de compra ──→ Contrato de cobro ←── Integración con el proveedor
La ejecución seguirá llegando al proveedor. El esquema expresa qué conoce cada pieza del código. Si cambian los formatos de la biblioteca externa, intentaremos concentrar los ajustes en la integración.
Si el nuevo proveedor ofrece garantías distintas, quizá debamos revisar también el contrato y sus consumidores. Una separación ayuda a contener los detalles que realmente pueden mantenerse dentro.
Proporcionar los colaboradores desde fuera
La inyección de dependencias consiste en entregar a una pieza los colaboradores que necesita, por ejemplo mediante el constructor. Así podemos construir el proceso de compra con el componente de cobro adecuado.
Es una técnica de construcción. Podemos inyectar una clase concreta y seguir dependiendo de todos sus detalles. Elegir la dirección de las dependencias y proporcionar los objetos son decisiones relacionadas, pero diferentes.
Representar la incertidumbre en el código
Una firma puede hacer explícito que el cobro tiene más de dos resultados:
enum PaymentOutcome {
CONFIRMED, DECLINED, UNKNOWN
}
interface PaymentGateway {
PaymentOutcome charge(long amountInCents, String currency, String attemptKey);
}
enum PaymentStatus {
PAID, DECLINED, PENDING_RECONCILIATION;
static PaymentStatus from(PaymentOutcome outcome) {
return switch (outcome) {
case CONFIRMED -> PAID;
case DECLINED -> DECLINED;
case UNKNOWN -> PENDING_RECONCILIATION;
};
}
}
Para este ejemplo, charge() admite un importe positivo en céntimos de euro, la moneda EUR y una clave no vacía. CONFIRMED significa que el cobro está confirmado, no solo que la petición se ha aceptado; DECLINED representa un rechazo explícito sin cobro. UNKNOWN indica que todavía no conocemos el resultado y necesitamos consultar o reconciliar la operación. La integración traduce las respuestas del proveedor a esos significados y siempre devuelve un resultado no nulo.
Una interrupción de red puede haber ocurrido antes o después del cobro. Por eso UNKNOWN se convierte en PENDING_RECONCILIATION: el pedido queda pendiente de aclaración. Tratarlo como rechazado y empezar otro intento con una clave nueva podría duplicar el pago.
Conectar las piezas sin un framework
Esta clase utiliza los tipos anteriores y recibe el componente de cobro por el constructor. Valida las entradas antes de delegar:
import java.util.Objects;
final class ChargePayment {
private final PaymentGateway paymentGateway;
ChargePayment(PaymentGateway paymentGateway) {
this.paymentGateway = Objects.requireNonNull(paymentGateway);
}
PaymentStatus charge(long amountInCents, String currency, String attemptKey) {
if (amountInCents <= 0) {
throw new IllegalArgumentException("Amount must be positive.");
}
if (!"EUR".equals(currency)) {
throw new IllegalArgumentException("Only EUR is supported.");
}
if (attemptKey == null || attemptKey.isBlank()) {
throw new IllegalArgumentException("Attempt key must not be blank.");
}
PaymentOutcome outcome = Objects.requireNonNull(
paymentGateway.charge(amountInCents, currency, attemptKey));
return PaymentStatus.from(outcome);
}
}
Limitar la moneda a euros permite fijar una unidad sin introducir aquí un modelo monetario completo: 10000 céntimos son 100 euros. Los errores de entrada producen IllegalArgumentException. Si una implementación devuelve null, incumple el contrato y el ejemplo lo detecta con NullPointerException; eso no equivale a un resultado de pago desconocido.
Podemos montar una comprobación con un sustituto que simule incertidumbre. Este fragmento va dentro de un método y necesita los tipos anteriores:
PaymentGateway uncertainGateway = (amountInCents, currency, attemptKey) ->
PaymentOutcome.UNKNOWN;
ChargePayment payment = new ChargePayment(uncertainGateway);
PaymentStatus status = payment.charge(10000, "EUR", "attempt-42");
status vale PENDING_RECONCILIATION. La expresión lambda proporciona una implementación pequeña de PaymentGateway; ChargePayment la utiliza igual que utilizaría una integración real. Así podemos comprobar la decisión sin contactar con un proveedor. Este sustituto no implementa cobros ni demuestra idempotencia: esa garantía requiere comprobar la integración que realmente procesa la operación.
En producción, la integración debe distinguir los fallos de comunicación previstos de los errores de programación. Capturar cualquier excepción y devolver UNKNOWN escondería defectos que necesitamos corregir. Tampoco conviene añadir un bucle de reintentos sin acordar antes las condiciones, los plazos y la conservación de la clave.
El ejemplo devuelve un estado orientativo para mostrar la decisión. Guardarlo, evitar transiciones incompatibles y resolver incidencias concurrentes requieren su propia coordinación. Este contrato tampoco expresa todavía la referencia del proveedor o un motivo de rechazo; podríamos añadir un objeto de resultado si el proceso necesitara esos datos.
Al revisar una colaboración, merece la pena comprobar qué información cruza el límite, qué comportamiento se promete y cómo se representan los fallos. Un contrato claro deja menos suposiciones repartidas por la aplicación y hace que el siguiente cambio de biblioteca resulte más previsible.