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
| Scenario | Why Not | Use Instead |
|---|---|---|
| Long-running (> 10 min) | Consumption plan timeout | Container Apps, App Service |
| Stateful workflows | Functions are stateless | Durable Functions |
| WebSockets/gRPC | Not supported | App Service, Container Apps |
| High-throughput, low-latency | Cold starts | Always-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:
- Idempotent handlers (check if already processed)
- Idempotency keys in a database
- 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:
- Configure max delivery count on the queue
- Implement try-catch in handler with specific exception handling
- Dead-letter on permanent failures
- Set up monitoring on dead-letter queue
- Have a process to review and replay dead letters
Summary
| Service | Use Case | Key Consideration |
|---|---|---|
| Service Bus Queue | Point-to-point messaging | Idempotent handlers |
| Service Bus Topic | Pub/sub multiple consumers | Subscription filters |
| Functions (HTTP) | Serverless APIs | Cold start latency |
| Functions (Queue) | Event processing | Poison message handling |
| Durable Functions | Stateful orchestration | Replay behavior |
Next up: Testing Strategies - Unit testing patterns, integration testing with WebApplicationFactory, and mocking strategies.
Part 5 of the C# Interview Prep series.