Refactoring Towards Resilience: Process Manager Solution

Other posts in this series:

In the last post, we examined all of our coordination options as well as our process coupling to design a solution for our order processor. In my experience, it's this part of design async workflows that's by far the hardest. The code behind building these workflows is quite simple, but getting to that code takes asking tough questions and getting real answers from the business about how they want to handle the coupling and coordination options. There's no right or wrong in the answers, just tradeoffs.

To implement our process manager that will handle coordination/choreography with the external services, I'm going with NServiceBus. My biggest reason to do so is that NServiceBus, instead of being a heavyweight broker, acts as an implementor of most of the patterns listed in the Enterprise Integration Patterns catalog, and for nearly all my business cases I don't want to implement those patterns myself. As a refresher, our final design picture looks like:

We've already gone over the API/Task generation side, the final part is to build the process manager, the Stripe payment gateway, and event handlers (including SendGrid).

In terms of project structure, I still include Stripe as part of my overall order "service" boundary, so I have no qualms including it in the same solution as my process manager. With that in mind, let's look first at our order process manager, implemented as an NServiceBus saga.

Initial Order Submit

From the last post, we saw that the button click on the UI would create an order, but defer to backend processing for actual payments. Our process manager responds to the front-end command to start the order processing:

public async Task Handle(ProcessOrderCommand message, 
    IMessageHandlerContext context) {
    var order = await _db.Orders.FindAsync(message.OrderId);

    await context.Send(new ProcessPaymentCommand
    {
        OrderId = order.Id,
        Amount = order.Total
    });
}

When we receive the command to process the order, we send a command to our Stripe processor from our Saga, defined as:

public class OrderAcceptanceSaga : Saga<OrderAcceptanceData>,
    IAmStartedByMessages<ProcessOrderCommand>,
    IHandleMessages<ProcessPaymentResult>
{
    private readonly OrdersContext _db;

    public OrderAcceptanceSaga(OrdersContext db)
    {
        _db = db;
    }
    protected override void ConfigureHowToFindSaga(
        SagaPropertyMapper<OrderAcceptanceData> mapper)
    {
        mapper.ConfigureMapping<ProcessOrderCommand>(m => m.OrderId);
    }

It doesn't seem like much in our process, we just turn around and send a command to Stripe, but that means that our front end has successfully recorded the order. With our initial command sent, let's check out our Stripe side.

Stripe processing

On the Stripe side, we said that payments are an Order service concern, which means I'm happy letting payments be a command. Between services, I prefer events, and internal to a service, commands are fine (events are fine too, I just prefer to coordinate/orchestrate inside a service).

We can implement a fairly straightforward Stripe handler, using a Stripe API NuGet package to help with the communication side:

public async Task Handle(ProcessPaymentCommand message, 
    IMessageHandlerContext context)
{
    var order = await _db.Orders.FindAsync(message.OrderId);

    var myCharge = new StripeChargeCreateOptions
    {
        Amount = Convert.ToInt32(order.Total * 100),
        Currency = "usd",
        Description = message.OrderId.ToString(),
        SourceCard = new SourceCard
        {
            /* get securely from order */
            Number = "4242424242424242",
            ExpirationYear = "2022",
            ExpirationMonth = "10",
        },
    };

    var requestOptions = new StripeRequestOptions
    {
        IdempotencyKey = message.OrderId.ToString()
    };

    var chargeService = new StripeChargeService();

    try
    {
        await chargeService.CreateAsync(myCharge, requestOptions);

        await context.Reply(new ProcessPaymentResult {Success = true});
    }
    catch (StripeException)
    {
        await context.Reply(new ProcessPaymentResult {Success = false});
    }
}

Most of this is fairly standard Stripe pieces, but the most important part is that when we call the Stripe API, we track success/failure and return a result appropriately. Additionally, we pass in the idempotency key based on the order ID so that if something goes completely wonky here and our message retries, we don't charge the customer twice.

We could get quite a bit more complicated here, looking at retries and the like but this is good enough for now and at least fulfills our goal of not accidentally charging the customer twice, or charging them and losing that information.

Handling the Stripe response

Back in our Saga, we need to handle the response from Stripe and perform any downstream actions. Now since we have this issue of the order successfully getting received but payment failing, we need to track that. I've handled this just by including a simple flag on the order and publishing a separate message:

public async Task Handle(ProcessPaymentResult message, 
    IMessageHandlerContext context)
{
    var order = await _db.Orders.FindAsync(Data.OrderId);

    if (message.Success)
    {
        order.PaymentSucceeded = true;

        await context.Publish(new OrderAcceptedEvent
        {
            OrderId = Data.OrderId,
            CustomerName = order.CustomerName,
            CustomerEmail = order.CustomerEmail
        });
    }
    else
    {
        order.PaymentSucceeded = false;

        await context.Publish(new OrderPaymentFailedEvent
        {
            OrderId = Data.OrderId
        });
    }

    await _db.SaveChangesAsync();

    MarkAsComplete();
}

Depending on the success or failure of the payment, I mark the order as payment succeeded and publish out a requisite event. Not that complicated, but this decoupling of the process from Stripe itself means that when I notify downstream systems, I'm only doing so after successfully processing the Stripe call (but not that the Stripe call itself was successful).

SendGrid event subscriber

Finally, our publishing of the OrderAcceptedEvent means we can build a subscriber to then send out the email to the customer that their order was successfully processed. Again, I'll use a NuGet package for the SendGrid API to do so:

public class OrderAcceptedHandler 
    : IHandleMessages<OrderAcceptedEvent>
{
    public Task Handle(OrderAcceptedEvent message, 
        IMessageHandlerContext context)
    {
        var apiKey = Environment.GetEnvironmentVariable("MY_RAD_SENDGRID_KEY");
        var client = new SendGridClient(apiKey);
        var msg = new SendGridMessage();

        msg.SetFrom(new EmailAddress("no-reply@my-awesome-store.com", "No Reply"));
        msg.AddTo(new EmailAddress(message.CustomerEmail, message.CustomerName));
        msg.SetTemplateId("0123abcd-fedc-abcd-9876-0123456789ab");
        msg.AddSubstitution("-name-", message.CustomerName);
        msg.AddSubstitution("-order-id-", message.OrderId.ToString());

        return client.SendEmailAsync(msg);
    }
}

Again, not too much excitement here, I'm just sending an email. The interesting part is the email sending is now temporally decoupled from my ordering process. In fact, email notifications are just another subscriber so we can easily imagine this sort of communication living not in the ordering service but perhaps a CRM service instead.

Wrapping it up

Our process we designed so far is pretty simple, just decoupling a few external processes from a button click. With an NServiceBus Saga in place to act as a process manager, our possibilities for more complex logic around the order acceptance process grow. We can retry payments, do more complicated order acceptance checks like fraud detection or address verification.

Regardless, we've addressed our initial problems in the distributed disaster we created earlier. It took quite a few more lines of code and more moving pieces, but that's always been my experience. Resilience is a feature, and one that has to be carefully considered and designed.