Azure Native Development: Benefits Program SMS Scheduler

Industry

Government / Public Benefits

Technologies

Azure, .NET, Durable Functions, Service Bus, Blazor

Challenge

HITRUST certification, 10x scalability requirements

Results

100,000+ SMS capacity, HITRUST certification achieved

Executive Summary

This case study details the successful modernization of a critical benefits program SMS scheduling system from an outdated PHP/MySQL implementation to a robust Azure-based .NET solution. The transformation delivered enhanced security, scalability, and compliance with HITRUST certification requirements, enabling the organization to efficiently manage campaigns of over 100,000 SMS messages quarterly while eliminating previous technical limitations.

Challenge

A government benefits notification program relied on an aging SMS scheduling system built with PHP 5.6 and MySQL to inform beneficiaries about program updates, eligibility verifications, and important deadlines. As the program expanded, this legacy system encountered numerous limitations that threatened the organization's ability to meet SLAs:

Solution Architecture

Solution Architecture Diagram

After requirements gathering, I designed and implemented a modern cloud-native solution leveraging Microsoft Azure and the current .NET technologies with these key components:

Front-End Interface

Message Processing and Scheduling

Orchestration and Workflow

Execution and Reporting

Technical Implementation Details

Note: The following code samples are illustrative examples that demonstrate the implementation concepts, not actual source code from the project.

SMS Scheduling and Delivery Process

The system processes SMS campaigns through several orchestrated steps:

1. Campaign Scheduling: A primary durable function acts as a scheduler that identifies campaigns due to start based on metadata stored in Azure SQL Database.


[FunctionName(nameof(CampaignSchedulerOrchestrator))]
public static async Task RunCampaignScheduler(
    [OrchestrationTrigger] IDurableOrchestrationContext context,
    ILogger log)
{
    // Get current time (with perpetual time handling for Durable Functions)
    var currentTime = context.CurrentUtcDateTime;
    
    // Check for scheduled campaigns
    var dueCampaigns = await context.CallActivityAsync>(
        nameof(ActiveCampaignFunction), 
        currentTime);
    
    // Process each due campaign
    foreach (var campaign in dueCampaigns)
    {
         var numberOfMessagesToSend = await context.CallActivityAsync(nameof(TotalCountFunction), campaign);
         var numberOfBlocks = (int)Math.Ceiling(numberOfMessagesToSend / (decimal)1000);

                for (int i = 0; i < numberOfBlocks; i++)
                {
                    await context.CallActivityAsync(nameof(SchedulerFunction), new CampaignBlock() 
                    { 
                        ActiveCampaign = campaign, 
                        Index = i 
                    });
                }
    }
    
    // Schedule next check using durable timer
    var nextCheckTime = currentTime.AddMinutes(5);
    await context.CreateTimer(nextCheckTime, CancellationToken.None);
    
    // Restart orchestration (eternal orchestration pattern)
    context.ContinueAsNew(null);
}

            

2. Message Sending: When a campaign is due to start, a service bus triggered function begins receiving the scheduled messages off the topic:


[FunctionName(nameof(InitiateSMSFunction))]
public async Task Run([ServiceBusTrigger("sms", "all", Connection = "sbConnection")]string json)
{
    var message = JsonConvert.Deserialize(json);
    
    // The cancelledService has a 5 minute memory cache. Meaning meaning every 5 minutes it checks if it needs to reply that a campaign is cancelled (and once cancelled, that's held in memory for the duration of the run)
    var isCampaignCancelled = await RetryPolicy.ExecuteAsync(() => cancelledService.IsCampaignCancelled(message.CampaignId));

    if (!isCampaignCancelled)
    {
        var messageIdentifier = await sendingService.Send(message);
        log.LogInformation("message sent: Sid {0}", messageIdentifier);
    }
    else 
    {
        log.LogInformation("campaign Id: {0} cancelled", message.CampaignId);
    }
}

            

3. Status Tracking: Real-time status updates are received via Twilio webhooks:


[FunctionName(nameof(WebhookListener))]
public static async Task ProcessSmsStatus(
    [HttpTrigger(AuthorizationLevel.Anonymous, "post")] HttpRequest req,
    [Sql("SMS_Status", ConnectionStringSetting = "SqlConnectionString")] IAsyncCollector statusCollector,
    ILogger log)
{
    if(!validator.IsValidWebhook(req))
        return;
            
    var requestBody = await new StreamReader(req.Body).ReadToEndAsync();
    
    var statusRecord = new SmsStatusRecord
    {
        MessageSid = req.Form["MessageSid"],
        Status = req.Form["MessageStatus"],
        ReceivedAt = DateTime.UtcNow,
        ErrorCode = formData.ContainsKey("ErrorCode") ? formData["ErrorCode"] : null
    };
    
    await statusCollector.AddAsync(statusRecord);
}

            

Reporting System

The reporting system uses Durable Functions with AppendBlob storage to compile comprehensive campaign data efficiently:


[FunctionName(nameof(GenerateCampaignReport))]
public static async Task RunReportOrchestrator(
    [OrchestrationTrigger] IDurableOrchestrationContext context)
{
    var reportRequest = context.GetInput();
    
    // Generate a unique report ID
    var reportId = context.NewGuid().ToString();
    
    // Initialize blob for report
    await context.CallActivityAsync(
        nameof(InitializeReportBlob), 
        new BlobInitRequest(reportId, reportRequest.ReportFormat));
    
    // Process campaign data in chunks to avoid memory issues
    foreach (var campaignId in reportRequest.CampaignIds)
    {
        // Get total message count for campaign to plan chunking
        var messageCount = await context.CallActivityAsync(
            nameof(GetCampaignMessageCount), 
            campaignId);
        
        int chunkSize = 5000;
        int chunks = (messageCount + chunkSize - 1) / chunkSize;
        
        for (int i = 0; i < chunks; i++)
        {
            await context.CallActivityWithRetryAsync(
                nameof(AppendCampaignDataChunk),
                new RetryOptions(TimeSpan.FromSeconds(5), 3),
                new ChunkRequest(reportId, campaignId, i, chunkSize));
        }
    }
    
    // Finalize the report
    var reportUrl = await context.CallActivityAsync(
        nameof(FinalizeReport), 
        reportId);
    
    return new ReportResult { ReportId = reportId, DownloadUrl = reportUrl };
}

[FunctionName(nameof(AppendCampaignDataChunk))]
public static async Task AppendCampaignDataChunk(
    [ActivityTrigger] ChunkRequest request,
    [Blob("reports", Connection = "BlobConnection")] BlobContainerClient containerClient,
    ILogger log)
{
    // Get campaign data chunk from database
    var dataChunk = await GetCampaignDataChunkFromDatabase(
        request.CampaignId, 
        request.ChunkIndex, 
        request.ChunkSize);
    
    // Format chunk data based on report format
    var formattedData = FormatDataChunk(dataChunk, request.ReportFormat);
    
    // Append to the blob
    var appendBlobClient = containerClient.GetAppendBlobClient($"{request.ReportId}.txt");
    using (var stream = new MemoryStream(Encoding.UTF8.GetBytes(formattedData)))
    {
        await appendBlobClient.AppendBlockAsync(stream);
    }
}

            

Results and Business Impact

The modernized system delivered significant technical and business improvements:

The project demonstrates how thoughtful architecture decisions and modern cloud technologies can transform legacy systems into scalable, secure platforms that deliver substantial business value while meeting stringent compliance requirements.