An earlier article, “Update me, please”, shows how to design notification infrastructure in a distributed system. It’s a basic design that I’ve successfully used myself multiple times. It comes with a limitation, though. Let’s have a look at the following scenario:

An area manager is responsible for a geographical area and all the retail shops in the assigned area. Area managers are interested in receiving notifications. For example, when one retail shop performs one or more actions they care about, or a specific event occurs, e.g., a retail shop runs out of stock or places a fulfillment order for a particular item.

In the presented scenario, an area manager might supervise tens of retail shops. If we were to design the notification infrastructure as explained in “Update me, please,” we’d end up spamming the poor manager. They subscribed to what they were interested in, and nothing more. However, the system design was causing a notification message for every meaningful event. Such a design with tens of shops can easily lead to hundreds, if not thousands, of notification messages.

Digests

Near real-time notifications might be helpful in some scenarios. In many cases, though, we can get along with summaries. A daily or a weekly digest with a list of things that happened since the last digest is more than enough to satisfy our desire to be updated.

We can design a notification infrastructure supporting digest-type subscriptions on top of what we already have. Let’s start by extending the subscription concept to include the type of grouping we need:

public record Subscription(string Id, NotificationFormat Format, NotificationFrequency Frequency);

With the NotificationFrequency enumeration that looks like:

enum NotificationFrequency
{
   Immediate,
   DailyDigest,
   WeeklyDigest
}

At this point, things get tricky. In “Update me, please”, we concluded that the responsibility for handling events, through regular message handlers, to turn into notifications belongs to business services. Those handlers use IT/Ops facilities, like those presented in INotificationService, and are hosted by the notification infrastructure managed by IT/Ops.

Dealing with digests is not the responsibility of a business service. They take care of formatting the notification and nothing else. To minimize the impact of the digest feature on business services, we need to “hide” the feature in the notification infrastructure. Luckily our design allows that. The last bit of the presented notification handler is something like:

var notifications = subscriptions.Select(s => new Notification()
{
    Subscription = s,
    Content = formattedNotifications[s.Format]
});
await notificationService.Dispatch(notifications);

At dispatch time, the notification service will inspect incoming notifications, and based on the NotificationFrequency value, will decide how to handle them: Send immediately or store for later delivery.

How do I subscribe?

Before looking at the “store for later delivery” details, we need to discuss something we have neglected so far: how does someone subscribe to a notification?

The first thing we need is a notifications database. The system needs to present a list of available notifications. We can build such a database in many ways. One of the many options is by using reflection. The business service that owns the events users can subscribe to can decorate those events using something like the following:

[Notification( 
   Id: "procurement/purchase-order-created",
   Name: "Purchase order created",
   Description: "Raised when a retail shop creates a purchase order.")]
interface IPurchaseOrderCreated
{
    string OrderId{ get; }
    string SupplierId{ get; }
    string DepartmentId{ get; }
}

Event classes or interfaces are deployed to the notification infrastructure alongside the notification handler. Notification infrastructure hosts can, at startup time, scan their deployment directories looking for types decorated with the presented attribute. Inspected types and attributes details constitute the notifications database—it’s up to the notification infrastructure to decide how to store it. Once the notifications database is ready, the system can present users with a list of subscribable notifications. At subscribe time, users select the frequency for each of the subscribed notifications. When choosing to receive a digest, they can input digest details, if not previously done—for example, a weekly digest, every Friday at 10 AM. If one isn’t already running, saving their subscriptions settings will start a new digest notification saga.

More information about sagas are available in the official NServiceBus documentation and the online saga tutorial.

The first thing we need is a message to kick off the saga:

class StartWeeklyDigest
{
   public string SubscriberId{ get; set; }
   public DateTime FirstOccurrence{ get; set; }
}

When starting a digest, we need the subscriber and the schedule settings. In this case, the first occurrence users like to have the digest delivered. From there it’ll be once every week. Your mileage might vary, depending on the scheduling options offered to users.

The required data are stored in the saga data. We keep the scheduling settings too because the system could use a subsequent StartWeeklyDigest message to update the digest scheduling. By storing current settings, we can check if the configuration needs to change.

class WeeklyDigestData : ContainsSagaData
{
   public string SubscriberId{ get; set; }
   public DateTime FirstOccurrence{ get; set; }
}

class WeeklyDigestSaga : 
   Saga<WeeklyDigestData>,
   IAmStartedBy<StartWeeklyDigest>
{
    protected override void ConfigureHowToFindSaga(SagaPropertyMapper<WeeklyDigestData> mapper)
    {
        mapper.ConfigureMapping<StartWeeklyDigest>(m => m.SubscriberId)
            .ToSaga(s => s.SubscriberId);
    }

    public Task Handle(StartWeeklyDigest message, IMessageHandlerContext context)
    {
        Data.FirstOccurrence = message.FirstOccurrence;
        return RequestTimeout(context, Data.FirstOccurrence, new DispatchWeeklyDigest(){ WhenDue = Data.FirstOccurrence });
    }
}

The DispatchWeeklyDigest timeout message is an elementary class with a WhenDue property used to calculate the next due date. To handle a timeout, we need to change the saga definition in the following way:

class WeeklyDigestSaga : 
   Saga<WeeklyDigestData>,
   IAmStartedBy<StartWeeklyDigest>,
   IHandleTimeouts<DispatchWeeklyDigest>

Which forces the implementation of the following method:

public Task Timeout( DispatchWeeklyDigest message, IMessageHandlerContext context)
{
    //query the notifications database for notifications to dispatch
    //or send a message to offload the task to a separate handler

    //schedule regular weekly deliveries
    var next = message.WhenDue.AddDays(7);
    return return RequestTimeout(context, next, new DispatchWeeklyDigest(){ WhenDue = next });
}

Nothing fancy, when handling the timeout, the saga selects and deletes, or marks them as dispatched, notifications to deliver from the database where they were stored previously. Finally, a timeout is requested for the next due date.

A note about offloading delivery to a separate handler

In the above snippet, I left this comment:

query the notifications database for notifications to dispatch or send a message to offload the task to a separate handler

Picking an option might not be easy. The official documentation states the following:

The main reason for avoiding accessing data from external resources is possible contention and inconsistency of saga state. Depending on persister, the saga state is retrieved and persisted with either pessimistic or optimistic locking. If the transaction takes too long, it’s possible another message will come in that correlates to the same saga instance. It might be processed on a different (concurrent) thread (or perhaps a scaled out endpoint) and it will either fail immediately (pessimistic locking) or while trying to persist the state (optimistic locking). In both cases the message will be retried.

The presented reason is a technical and infrastructure reason. I have a preference based on the single responsibility principle (SRP). It’s not the role of the saga to dispatch notifications; its function is to decide to deliver.

A note about notifications formats

In “Update me, please” to retrieve formatted notifications, we use the following approach:

var request = new HttpRequestMessage(HttpMethod.Get, requestUri);
request.Headers.Add("Accept", $"NotificationFormat/{format}");

var response = await client.SendAsync(request);
var formattedNotification = await response.Content.ReadAsStringAsync();

The composition API returns a formatted response. What if we wanted a different format if the notification should be immediately dispatched or included in a digest? A digest is a sort of summary, and thus we might need a summary style notification. If this is the case, we could tweak the composition API to return multiple styles—for example, a full and a formatted summary notification.

Conclusion

Sagas are a multi-purpose tool. We can use them to build long-running business processes, such as handling the lifecycle of an order, or we can use them for recurring things delivering a digest to subscribers. Sagas are probably a good fit whenever a business process we’re designing is long-running or needs to deal with time. If you’d like to know more about the relationship between sagas and the passage of time, I delivered a webinar on the topic.


Photo by Pau Casals on Unsplash