Supercharged Sagas - Creating Your First NServiceBus Saga

August 30, 2024#Software Development
Series: NServiceBus
Article
Author image.

Kyle McMaster, Senior Consultant

This post is part of a series on NServiceBus. In this article, we will implement the happy path of our example saga introduced in the previous article. In this path, a contributor will go through a workflow to become verified in our system. Be sure to check out the sequence diagrams in the previous article if you’d like a refresher on where we are going with this process. Along the way we’ll look at how NServiceBus enables us to define and orchestrate our saga efficiently. All the code for this series can be found in the NServiceBusTutorial repository on GitHub.

Starting the Saga In An Event Handler 🟢

The first step to kicking off our process is to start the saga when a ContributorCreatedEvent is published. The StartContributorVerificationCommand will contain the ContributorId so the saga can track the contributor. This is a constraint of our system that no two ContributorVerificationSagas should be running per contributor at any given moment. The following is the updated ContributorCreatedEventHandler that sends the StartContributorVerificationCommand to start the saga.

public class StartContributorVerificationCommand : ICommand
{
  public int ContributorId { get; init; }
}

public class ContributorCreatedEventHandler(ILogger<ContributorCreatedEventHandler> logger) 
  : IHandleMessages<ContributorCreatedEvent>
{
  public async Task Handle(ContributorCreatedEvent message, IMessageHandlerContext context)
  {
    // Other concerns abbreviated for simplicity
    await context.Send(new StartContributorVerificationCommand
    {
      ContributorId = message.ContributorId
    });
  }
}

But wait?!? 🚩 We haven’t even defined our saga yet! Let’s do that now.

Defining Our Saga 🔨

In the previous article, we introduced the concept of a saga and how it can be used to manage the state of a long-running process. We also introduced the example saga we will be implementing in this article. The example saga is a simple process that orchestrates sending a message to a new contributor and waiting for a response for a certain amount of time. If the contributor responds within the time limit, the saga will complete successfully. This is the happy path of our saga and what we will focus on implementing in this article.

To define our saga, we need to create a class ContributorVerificationSaga that inherits from Saga<T> where T is the type of the saga data. The saga data is the state of the saga and is used to store information about the saga. In our example, we will create a class called ContributorVerificationSagaData that inherits from ContainSagaData. The saga class will also implement a few other interfaces like IAmStartedByMessages<T>, IHandleMessages<T>, and IHandleTimeouts<T> which we will discuss as we define those messages and their handlers later.

public class ContributorVerificationSaga : Saga<ContributorVerificationSagaData>,
  IAmStartedByMessages<StartContributorVerificationCommand>,
  IHandleMessages<ContributorVerifiedEvent>,
  IHandleTimeouts<ContributorVerificationSagaTimeout>
{
    // Handlers, mappings, and other methods will be defined here
}

public class ContributorVerificationSagaData : ContainSagaData
{
  public int ContributorId { get; set; }
}

Since we’ve already demonstrated how we will send the start message for the saga with the StartContributorVerificationCommand, let’s move on to configuring our saga to process this message and define our handler. First we need to configure the StartContributorVerificationCommand message to map to an instance of our saga. We do this by overriding the ConfigureHowToFindSaga method in our saga class. This method is used to map incoming messages to an existing saga instance or to create a new saga instance if one does not exist. In our case, we will map the ContributorId in the StartContributorVerificationCommand to the ContributorId in the saga data. This will allow us to use our ContributorId as our unique identifier for the saga across our system. The following is the implementation of the ConfigureHowToFindSaga method in our ContributorVerificationSaga class.

protected override void ConfigureHowToFindSaga(SagaPropertyMapper<ContributorVerificationSagaData> mapper)
{
  mapper.MapSaga(data => data.ContributorId)
    .ToMessage<StartContributorVerificationCommand>(message => message.ContributorId)
    // other mappings here
}

We can see the ContributorId in the StartContributorVerificationCommand is mapped to the ContributorId in the saga data. It’s important to note that sagas do have their own Id but it is often hard to pass around that Id from various applications. It’s easier to use a domain entity Id as the mapping for a saga since in most systems that Id could be the same across service boundaries (or able to be retrieved if needed). Now let’s take a look at the handler for the StartContributorVerificationCommand.

public async Task Handle(StartContributorVerificationCommand message, IMessageHandlerContext context)
{
  var verifyContributorCommand = new VerifyContributorCommand { ContributorId = message.ContributorId };
  await context.Send(verifyContributorCommand);

  // Saga timeouts covered in next article
  var timeout = new ContributorVerificationSagaTimeout { ContributorId = message.ContributorId };
  await RequestTimeout(context, DateTime.UtcNow.AddHours(24), timeout);
}

The StartContributorVerificationCommand handler does a lot of the workload for the saga by kicking off our two paths in our process. First, we send the VerifyContributorCommand to verify the contributor. This command will be handled by the Worker application. We also request that a Timeout be registered for the saga. Since our use case is relatively simple, this timeout helps track whether the contributor does not respond within the 24 hour time limit and can be registered as soon as the saga begins. In the next article in the series, we will define the ContributorVerificationSagaTimeout class, cover how saga timeouts work, and some of the different strategies for managing them. For now, you can simply think of this as kicking off a timer that will go off if the saga does not complete before time runs out.

As we continue along our happy path, let’s take a look at the VerifyContributorCommand and its handler.

Contributor Verification ✔️

The VerifyContributorCommandHandler is responsible for sending a simple greeting to the new contributor and supplying them with a link to verify their account. This handler calls a INotificationService which simulates sending a text message to the contributor. The following is the implementation of the VerifyContributorCommand handler.

public class VerifyContributorCommand : ICommand
{
  public int ContributorId { get; set; }
}

public class VerifyContributorCommandHandler(INotificationService _notificationService, IRepository<Contributor> _repository)
    : IHandleMessages<VerifyContributorCommand>
{
  public async Task Handle(VerifyContributorCommand message, IMessageHandlerContext context)
  {
    var contributor = await _repository.SingleOrDefaultAsync(new ContributorByIdSpec(message.ContributorId), context.CancellationToken);
    if (contributor is null)
    {
      throw new InvalidOperationException($"Contributor with Id {message.ContributorId} not found.");
    }
    string textMessage = "Welcome contributor! Please verify your phone number here: https://NimblePros.com/verify/123";
    await _notificationService.SendSmsAsync(contributor.PhoneNumber.Number, textMessage);
  }
}

Now we wait for the contributor to verify their account. ⏳

Contributor Interaction 📱

Assuming the contributor has received the text message and clicked the link to verify their account, an HTTP request will be made to our Web application. This request will be handled by the Verify endpoint and mark the contributor as verified in our system.

public class Verify(IMessageSession _messageSession, IRepository<Contributor> _repository)
  : Endpoint<UpdateContributorRequest, UpdateContributorResponse>
{
  // FastEndpoint configuration abbreviated for simplicity 
  
  public override async Task HandleAsync(
    UpdateContributorRequest request,
    CancellationToken cancellationToken)
  {
    var contributor = await _repository.GetByIdAsync(request.ContributorId, cancellationToken);
    if (contributor is null)
    {
      await SendNotFoundAsync(cancellationToken);
      return;
    }

    contributor.Verify();
    await _repository.SaveChangesAsync(cancellationToken);
    var verifiedEvent = new ContributorVerifiedEvent { ContributorId = contributor.Id };
    await _messageSession.Publish(verifiedEvent, cancellationToken);
  }
}

Completing the Saga 🏁

The final step in our workflow is to handle the ContributorVerifiedEvent that is published by the Verify endpoint. This event will be handled by the ContributorVerificationSaga. Since our verification happy path has been executed successfully, we can call the aptly named method, MarkAsComplete(), to mark the saga as completed. The following is the implementation of the ContributorVerifiedEvent handler.

public class ContributorVerifiedEvent : IEvent
{
  public int ContributorId { get; set; }
}

public Task Handle(ContributorVerifiedEvent message, IMessageHandlerContext context)
{
  MarkAsComplete();
  return Task.CompletedTask;
}

Since the ContributorVerificationSaga is now handling an additional message, we’ll also need to update the ConfigureHowToFindSaga method to map the ContributorId in the ContributorVerifiedEvent to the ContributorId in the saga data. The following is the updated saga class definition and ConfigureHowToFindSaga method for all the messages we’ve defined so far.

public class ContributorVerificationSaga : Saga<ContributorVerificationSagaData>,
  IAmStartedByMessages<StartContributorVerificationCommand>,
  IHandleMessages<ContributorVerifiedEvent>
{
  protected override void ConfigureHowToFindSaga(SagaPropertyMapper<ContributorVerificationSagaData> mapper)
  {
    mapper.MapSaga(data => data.ContributorId)
      .ToMessage<StartContributorVerificationCommand>(message => message.ContributorId)
      .ToMessage<ContributorVerifiedEvent>(message => message.ContributorId);
  }
  // ...
}

And that’s it! We’ve successfully implemented the happy path of our ContributorVerificationSaga.

Conclusion 🎉

As usual, all the code necessary to run this saga is available in the NServiceBusTutorial repository on GitHub. In the next article in the series, we will cover how to handle timeouts in sagas when the contributor decides not to answer the text message. Stay tuned!

Additional Resources 📚


Copyright © 2024 NimblePros - All Rights Reserved