Unidad de Transacción 1: Deducción en la Cuenta Origen
Para el servicio del banco emisor, definimos un modelo de transferencia y la lógica de compensación. Si la política de la transacción es de recuperación hacia adelante (Forward Recovery), la implementación del método Cancel puede omitirse, aunque es recomendable definirla para estrategias de retroceso.
Modelo de Datos (CuentaOrigenDTO)
using System;
using Wing.Saga.Client;
namespace BancoOrigen.Saga.Unidades
{
[Serializable]
public class CuentaOrigenDTO : UnitModel
{
public string IdentificadorCuenta { get; set; }
public decimal CapitalARetirar { get; set; }
}
}
Implementación del Paso Saga (PasoDeduccionOrigen)
using System.Threading.Tasks;
using Wing.Saga.Client;
namespace BancoOrigen.Saga.Unidades
{
public class PasoDeduccionOrigen : SagaUnit<CuentaOrigenDTO>
{
public override Task<SagaResult> Commit(CuentaOrigenDTO dto, SagaResult resultadoPrevio)
{
var resultadoEjecucion = new SagaResult();
if (CuentaEstatica.SaldoDisponible < dto.CapitalARetirar)
{
resultadoEjecucion.Success = false;
resultadoEjecucion.Msg = "Fondos insuficientes en la cuenta de origen.";
return Task.FromResult(resultadoEjecucion);
}
CuentaEstatica.SaldoDisponible -= dto.CapitalARetirar;
return Task.FromResult(resultadoEjecucion);
}
public override Task<SagaResult> Cancel(CuentaOrigenDTO dto, SagaResult resultadoPrevio)
{
CuentaEstatica.SaldoDisponible += dto.CapitalARetirar;
return Task.FromResult(new SagaResult());
}
}
}
Unidad de Transacción 2: Abono en el Banco Destino
El segundo paso involucra la comunicación HTTP con el microservicio del banco receptor para incrementar el saldo de la cuenta destino.
Modelo de Datos (CreditoDestinoDTO)
using System;
using Wing.Saga.Client;
namespace BancoOrigen.Saga.Unidades
{
[Serializable]
public class CreditoDestinoDTO : UnitModel
{
public string CuentaDestino { get; set; }
public string EntidadReceptora { get; set; }
public decimal MontoAbono { get; set; }
}
}
Implementación del Paso Saga (PasoCreditoDestino)
using System;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using Newtonsoft.Json;
using Wing;
using Wing.Result;
using Wing.Saga.Client;
using Wing.ServiceProvider;
namespace BancoOrigen.Saga.Unidades
{
public class PasoCreditoDestino : SagaUnit<CreditoDestinoDTO>
{
private readonly IServiceFactory _fabricaServicios = App.GetService<IServiceFactory>();
private readonly IHttpClientFactory _fabricaClientesHttp = App.GetService<IHttpClientFactory>();
public override Task<SagaResult> Commit(CreditoDestinoDTO dto, SagaResult resultadoPrevio)
{
return _fabricaServicios.InvokeAsync("Servicio.BancoReceptor", async direccionServidor =>
{
var clienteHttp = _fabricaClientesHttp.CreateClient();
clienteHttp.BaseAddress = new Uri(direccionServidor.ToString());
var contenido = new StringContent(JsonConvert.SerializeObject(dto), Encoding.UTF8, "application/json");
var respuesta = await clienteHttp.PostAsync("/api/RecepcionTransferencia", contenido);
var resultadoSaga = new SagaResult();
if (!respuesta.IsSuccessStatusCode)
{
resultadoSaga.Success = false;
resultadoSaga.Msg = $"Fallo la comunicación con el banco destino. Código HTTP: {(int)respuesta.StatusCode}";
return resultadoSaga;
}
var cuerpoRespuesta = await respuesta.Content.ReadAsStringAsync();
var apiResult = JsonConvert.DeserializeObject<ApiResult<bool>>(cuerpoRespuesta);
resultadoSaga.Success = apiResult.Code == ResultType.Success && apiResult.Data;
resultadoSaga.Msg = apiResult.Msg;
return resultadoSaga;
});
}
public override Task<SagaResult> Cancel(CreditoDestinoDTO dto, SagaResult resultadoPrevio)
{
throw new NotImplementedException();
}
}
}
Orquestación de la Transacción Distribuida
Ambas unidades se encadenan secuencialmente en el controlador de la API del banco origen para formar la transacción Saga completa.
using System;
using Microsoft.AspNetCore.Mvc;
using Wing.Persistence.Saga;
using Wing.Saga.Client;
using BancoOrigen.Saga.Unidades;
namespace BancoOrigen.Controladores
{
[ApiController]
[Route("api/[controller]")]
public class TransferenciasController : ControllerBase
{
[HttpGet("{monto}")]
public bool EjecutarTransferencia(decimal monto)
{
if (monto <= 0)
{
throw new ArgumentException("El monto a transferir debe ser mayor a cero.");
}
var resultado = Saga.Start("Transferencia Interbancaria", new SagaOptions { TranPolicy = TranPolicy.Forward })
.Then(new PasoDeduccionOrigen(), new CuentaOrigenDTO
{
Name = "Deducción en cuenta origen",
IdentificadorCuenta = CuentaEstatica.NumeroCuenta,
CapitalARetirar = monto
})
.Then(new PasoCreditoDestino(), new CreditoDestinoDTO
{
Name = "Abono en cuenta destino",
CuentaDestino = "555444333",
MontoAbono = monto,
EntidadReceptora = "Banco Receptor"
})
.End();
if (!resultado.Success)
{
throw new Exception(resultado.Msg);
}
return resultado.Success;
}
}
}
Microservicio Receptor
El banco destino expone un endpoint para recibir los fondos. Para demostrar la capacidad de recuperación del patrón Saga, se incluye un mecanismo de simulación de fallos que puede ser modificado en tiempo de ejecución.
using System;
using Microsoft.AspNetCore.Mvc;
using BancoReceptor.Modelos;
namespace BancoReceptor.Controladores
{
[ApiController]
[Route("api/[controller]")]
public class RecepcionTransferenciaController : ControllerBase
{
private static bool _simularError = true;
[HttpPost]
public bool RecibirAbono(CreditoDestinoDTO dto)
{
if (dto.CuentaDestino != CuentaEstatica.NumeroCuenta)
{
throw new Exception("La cuenta destino no existe en este banco.");
}
if (_simularError)
{
throw new Exception("Error de procesamiento simulado en el banco receptor.");
}
CuentaEstatica.SaldoDisponible += dto.MontoAbono;
return true;
}
[HttpGet("configurar-exito/{estado}")]
public bool CambiarEstadoSimulacion(int estado)
{
_simularError = estado != 1;
return !_simularError;
}
}
}
Flujo de Ejecución y Recuperación
Al iniciar la transferencia desde el banco de origen, la primera unidad de resta se ejecuta con éxito. Si el servicio del banco receptor falla (tal como lo dicta la bandera de simulación), la transacción global queda en estado pendiente. Debido a la configuración TranPolicy.Forward, el coordinador reintentará la ejecución del paso fallido periódicamente. Una vez que se modifica la bandera de simulación hacia el éxito mediante el endpoint correspondiente, el siguiente reintento del coordinador procesará el abono exitosamente, completando así la transacción distribuida sin intervención manual sobre el saldo ya deducido.