Bobby Encoded
PostsAbout
PostsAbout

© 2026 Bobby Jose

← Back to Blog

Azure Integration Patterns: Service Bus and Functions

February 12, 2025 · 6 min read

C#, Azure, Microservices, Interview Prep

Introduction

Cloud integration is expected knowledge for senior .NET developers. Azure Service Bus and Functions come up frequently because they're core building blocks for distributed systems. This post covers the patterns and anti-patterns that separate production-ready code from tutorials.


Azure Service Bus — Patterns and Anti-Patterns

TL;DR

Know the difference between queues and topics, handle poison messages properly, and understand sessions for ordering guarantees.

The Real Explanation

// Queue: Point-to-point (one consumer)
// Topic: Pub/sub (multiple subscribers)

// Sending messages
await using var client = new ServiceBusClient(connectionString);
await using var sender = client.CreateSender("orders");

var message = new ServiceBusMessage(JsonSerializer.SerializeToUtf8Bytes(order))
{
    MessageId = order.OrderId.ToString(),
    ContentType = "application/json",
    Subject = "OrderCreated",
    SessionId = order.CustomerId.ToString(),  // For ordered processing
    ApplicationProperties =
    {
        ["OrderType"] = order.Type,
        ["Priority"] = order.IsPriority ? "High" : "Normal"
    }
};

await sender.SendMessageAsync(message);

Processing with Error Handling

await using var processor = client.CreateProcessor("orders", new ServiceBusProcessorOptions
{
    MaxConcurrentCalls = 10,
    AutoCompleteMessages = false,  // Manual completion for reliability
    MaxAutoLockRenewalDuration = TimeSpan.FromMinutes(10)
});

processor.ProcessMessageAsync += async args =>
{
    try
    {
        var order = JsonSerializer.Deserialize<Order>(args.Message.Body.ToArray());
        await ProcessOrderAsync(order);
        await args.CompleteMessageAsync(args.Message);  // Success
    }
    catch (TransientException)
    {
        // Don't complete - message will be retried after lock expires
        throw;
    }
    catch (PermanentException ex)
    {
        await args.DeadLetterMessageAsync(args.Message,
            deadLetterReason: "ProcessingFailed",
            deadLetterErrorDescription: ex.Message);
    }
};

processor.ProcessErrorAsync += args =>
{
    logger.LogError(args.Exception, "Service Bus error");
    return Task.CompletedTask;
};

await processor.StartProcessingAsync();

The Gotcha

// Session ordering is per-session, not global!
// Messages with different SessionIds process in parallel

// The "at least once" gotcha
// Even with PeekLock, you can process a message twice:
// 1. Process message
// 2. Call CompleteAsync
// 3. Network fails - message not completed
// 4. Lock expires, message reappears
// 5. Process again!

// Fix: Make handlers idempotent
public async Task HandleOrderAsync(Order order)
{
    // Check if already processed
    if (await orderRepo.ExistsAsync(order.OrderId))
        return;  // Already processed, skip

    await orderRepo.CreateAsync(order);
}

I learned the idempotency lesson the hard way when we had a network blip during order processing. Customers received duplicate shipments. Now every message handler starts with a duplicate check.

Retry and Dead Letter Patterns

// Configure retry with exponential backoff
public class OrderProcessor
{
    private static readonly int MaxRetries = 3;

    public async Task ProcessWithRetryAsync(ServiceBusReceivedMessage message)
    {
        var deliveryCount = message.DeliveryCount;

        if (deliveryCount > MaxRetries)
        {
            // Move to dead letter queue with context
            await receiver.DeadLetterMessageAsync(message,
                new Dictionary<string, object>
                {
                    ["OriginalMessageId"] = message.MessageId,
                    ["FailedAt"] = DateTime.UtcNow,
                    ["RetryCount"] = deliveryCount
                });
            return;
        }

        // Exponential backoff built into Service Bus
        // Each retry waits longer based on queue configuration
    }
}

Azure Functions — Beyond Hello World

TL;DR

Isolated worker model is the future, understand cold starts, and know when Azure Functions isn't the right choice.

The Real Explanation

// Modern isolated worker model (.NET 8+)
var host = new HostBuilder()
    .ConfigureFunctionsWorkerDefaults()
    .ConfigureServices(services =>
    {
        services.AddSingleton<IMyService, MyService>();
        services.AddHttpClient();
    })
    .Build();

await host.RunAsync();

// HTTP Trigger with DI
public class OrderFunctions
{
    private readonly IOrderService _orderService;
    private readonly ILogger<OrderFunctions> _logger;

    public OrderFunctions(IOrderService orderService, ILogger<OrderFunctions> logger)
    {
        _orderService = orderService;
        _logger = logger;
    }

    [Function("CreateOrder")]
    public async Task<HttpResponseData> CreateOrder(
        [HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequestData req)
    {
        var order = await req.ReadFromJsonAsync<Order>();

        var result = await _orderService.CreateAsync(order);

        var response = req.CreateResponse(HttpStatusCode.Created);
        await response.WriteAsJsonAsync(result);
        return response;
    }

    [Function("ProcessOrder")]
    public async Task ProcessOrder(
        [ServiceBusTrigger("orders", Connection = "ServiceBusConnection")] Order order)
    {
        _logger.LogInformation("Processing order {OrderId}", order.Id);
        await _orderService.ProcessAsync(order);
    }
}

The Gotcha

// Cold start in Consumption plan
// First request can take 1-3 seconds while function loads

// BAD: Heavy initialization in constructor
public class SlowFunction
{
    private readonly ExpensiveClient _client;

    public SlowFunction()
    {
        _client = new ExpensiveClient();  // Slow!
        _client.Initialize();  // Even slower!
    }
}

// GOOD: Lazy initialization
public class FastFunction
{
    private static readonly Lazy<ExpensiveClient> _client =
        new(() => new ExpensiveClient().Initialize());

    public FastFunction() { }  // Fast constructor

    [Function("DoWork")]
    public async Task DoWork([HttpTrigger] HttpRequestData req)
    {
        var client = _client.Value;  // Initialize on first use, reuse after
    }
}

Mitigating Cold Starts

Options: Premium plan (pre-warmed instances), keep-alive pings, or accepting the latency for infrequent operations.

When NOT to Use Functions

ScenarioWhy NotUse Instead
Long-running (> 10 min)Consumption plan timeoutContainer Apps, App Service
Stateful workflowsFunctions are statelessDurable Functions
WebSockets/gRPCNot supportedApp Service, Container Apps
High-throughput, low-latencyCold startsAlways-on App Service

Durable Functions for Orchestration

When you need stateful workflows:

// Orchestrator - coordinates the workflow
[Function("OrderProcessingOrchestrator")]
public static async Task<OrderResult> RunOrchestrator(
    [OrchestrationTrigger] TaskOrchestrationContext context)
{
    var order = context.GetInput<Order>();

    // Activities run in sequence - state persisted between each
    var validated = await context.CallActivityAsync<bool>("ValidateOrder", order);

    if (!validated)
        return new OrderResult { Success = false, Reason = "Validation failed" };

    // Fan-out: Process items in parallel
    var tasks = order.Items.Select(item =>
        context.CallActivityAsync<ItemResult>("ProcessItem", item));
    var results = await Task.WhenAll(tasks);

    // Wait for external event (human approval for large orders)
    if (order.Total > 10000)
    {
        var approved = await context.WaitForExternalEvent<bool>("ApprovalReceived",
            timeout: TimeSpan.FromHours(24));

        if (!approved)
            return new OrderResult { Success = false, Reason = "Not approved" };
    }

    await context.CallActivityAsync("FinalizeOrder", order);

    return new OrderResult { Success = true, OrderId = order.Id };
}

// Activity - actual work
[Function("ValidateOrder")]
public static async Task<bool> ValidateOrder(
    [ActivityTrigger] Order order,
    FunctionContext context)
{
    // Validation logic here
    return order.Items.Any() && order.Total > 0;
}

Interview Questions

Q: How do you ensure exactly-once processing with Service Bus?

Answer: You can't guarantee exactly-once with any message broker. Service Bus guarantees at-least-once delivery. Ensure exactly-once behavior through:

  1. Idempotent handlers (check if already processed)
  2. Idempotency keys in a database
  3. Using transactions where possible

Q: When would you choose Azure Functions over App Service?

Answer:

  • Functions: Event-driven, short-lived, scales to zero, pay-per-execution
  • App Service: Long-running, needs WebSockets, consistent traffic, always-on

Functions are great for event processing (queue triggers, timers), API endpoints with sporadic traffic, and glue code between services.

Q: How do you handle poison messages?

Answer:

  1. Configure max delivery count on the queue
  2. Implement try-catch in handler with specific exception handling
  3. Dead-letter on permanent failures
  4. Set up monitoring on dead-letter queue
  5. Have a process to review and replay dead letters

Summary

ServiceUse CaseKey Consideration
Service Bus QueuePoint-to-point messagingIdempotent handlers
Service Bus TopicPub/sub multiple consumersSubscription filters
Functions (HTTP)Serverless APIsCold start latency
Functions (Queue)Event processingPoison message handling
Durable FunctionsStateful orchestrationReplay behavior

Next up: Testing Strategies - Unit testing patterns, integration testing with WebApplicationFactory, and mocking strategies.


Part 5 of the C# Interview Prep series.

← Previous

Testing in .NET: Unit Tests, Mocking, and Integration Testing

Next →

Entity Framework Core: Performance and Production Gotchas