In-Depth Guide · 12 Sections · C# Detail
Azure for .NET In Depth
Table Storage, Service Bus and Azure Functions — with production-grade C#: IOptions configuration, generic repositories, typed message contracts, IAsyncEnumerable queries, primary-constructor services and full isolated-worker Functions DI. Copy-paste .NET 9 code.
Table Storage + C#
Service Bus + C#
Azure Functions
Repositories & DI
Prerequisite: ASP.NET Core basics — Minimal API.
For on-prem messaging with RabbitMQ, see MassTransit & RabbitMQ (MassTransit also supports Azure Service Bus as a transport).
For relational data on Azure, pair this with the T-SQL Deep-Dive (Azure SQL uses the same T-SQL).
Microsoft Azure gives you managed services so you don't run your own message brokers, schedulers, or NoSQL clusters. For .NET teams, three services show up constantly: Table Storage for cheap structured data, Service Bus for reliable messaging, and Azure Functions for serverless event handlers.
1.1
Which service for what?
// Table Storage — fast key/value NoSQL, pennies per GB
// User sessions, device telemetry, audit logs
// NOT for complex JOINs (use Azure SQL / Cosmos)
// Service Bus — enterprise messaging, FIFO queues, pub/sub topics
// Order events, integration between microservices
// Compare: RabbitMQ on-prem, MassTransit abstracts both
// Azure Functions — run code on triggers (HTTP, timer, queue message)
// Webhooks, lightweight ETL, reactive processors
// Compare: Hangfire for in-app scheduling
1.2
Modern NuGet packages
dotnet add package Azure.Data.Tables
dotnet add package Azure.Messaging.ServiceBus
dotnet add package Azure.Identity // Managed Identity / DefaultAzureCredential
dotnet add package Microsoft.Azure.Functions.Worker
dotnet add package Microsoft.Azure.Functions.Worker.Extensions.ServiceBus
dotnet add package Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore
1.3
Solution layout — how to organise C# projects
OrderApp.sln
├── OrderApp.Api // Minimal API — references Infrastructure
├── OrderApp.Functions // Azure Functions isolated worker
├── OrderApp.Contracts // records: OrderPlacedMessage, DTOs
├── OrderApp.Domain // entities, IOrderRepository (SQL)
└── OrderApp.Infrastructure // Azure SDK wrappers
├── Azure/
│ ├── AzureOptions.cs
│ ├── ServiceCollectionExtensions.cs
│ ├── Tables/TableRepository<T>.cs
│ └── ServiceBus/ServiceBusPublisher.cs
└── Messaging/OrderMessageHandler.cs
// Rule: API and Functions reference Infrastructure + Contracts
// Never reference Azure SDK types in endpoint signatures — use your abstractions
Connection strings work for local dev. Production should use Managed Identity + DefaultAzureCredential — no secrets in code or config files. Wrap all Azure settings in a typed AzureOptions class and register clients through extension methods so every project shares the same C# wiring.
2.1
Connection string (dev) vs Managed Identity (prod)
// appsettings.Development.json — local only
{
"Azure": {
"StorageAccount": "DefaultEndpointsProtocol=https;AccountName=...;AccountKey=...",
"ServiceBus": "Endpoint=sb://mybus.servicebus.windows.net/;SharedAccessKeyName=..."
}
}
// Production — Managed Identity on App Service / Function App
// Enable "System assigned managed identity" in Azure Portal
// Grant roles: Storage Table Data Contributor, Azure Service Bus Data Sender/Receiver
var credential = new DefaultAzureCredential();
// Table Storage
var tableService = new TableServiceClient(
new Uri("https://myaccount.table.core.windows.net"),
credential);
// Service Bus
await using var busClient = new ServiceBusClient(
"mybus.servicebus.windows.net", // namespace FQDN, no protocol
credential);
2.2
AzureOptions — typed, validated configuration
// OrderApp.Infrastructure/Azure/AzureOptions.cs
public sealed class AzureOptions
{
public const string SectionName = "Azure";
public string TableEndpoint { get; init; } = "";
public string ServiceBusNamespace { get; init; } = "";
public string? StorageConnectionString { get; init; } // dev only
public string? ServiceBusConnectionString { get; init; } // dev only
public bool UseManagedIdentity =>
string.IsNullOrWhiteSpace(StorageConnectionString)
&& string.IsNullOrWhiteSpace(ServiceBusConnectionString);
}
// Validate at startup — fail fast
builder.Services.AddOptions<AzureOptions>()
.Bind(builder.Configuration.GetSection(AzureOptions.SectionName))
.Validate(o => o.UseManagedIdentity
? !string.IsNullOrWhiteSpace(o.TableEndpoint)
&& !string.IsNullOrWhiteSpace(o.ServiceBusNamespace)
: !string.IsNullOrWhiteSpace(o.StorageConnectionString)
&& !string.IsNullOrWhiteSpace(o.ServiceBusConnectionString),
"Azure section is incomplete")
.ValidateOnStart();
2.3
AddAzureServices() — one extension for all clients
// ServiceCollectionExtensions.cs
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddAzureServices(
this IServiceCollection services)
{
services.AddSingleton(sp =>
{
var opts = sp.GetRequiredService<IOptions<AzureOptions>>().Value;
if (opts.UseManagedIdentity)
return new TableServiceClient(new Uri(opts.TableEndpoint),
new DefaultAzureCredential());
return new TableServiceClient(opts.StorageConnectionString!);
});
services.AddSingleton(sp =>
{
var opts = sp.GetRequiredService<IOptions<AzureOptions>>().Value;
if (opts.UseManagedIdentity)
return new ServiceBusClient(opts.ServiceBusNamespace,
new DefaultAzureCredential());
return new ServiceBusClient(opts.ServiceBusConnectionString!);
});
services.AddScoped(typeof(ITableRepository<>), typeof(TableRepository<>));
services.AddSingleton(typeof(IServiceBusPublisher<>), typeof(ServiceBusPublisher<>));
return services;
}
}
// Program.cs — one line
builder.Services.AddAzureServices();
Watch Out — never commit connection strings
Storage account keys and Service Bus SAS keys grant full access. Use User Secrets locally, Key Vault or Managed Identity in Azure. Rotate keys if they ever leak.
Azure Table Storage (now part of Azure Storage Tables) is a NoSQL key/value store. Every entity has a PartitionKey + RowKey — together they form the unique key. Design partition keys for even distribution and fast point lookups.
3.1
Record entity + factory methods (C# 12)
// OrderApp.Contracts/Tables/AuditLogEntry.cs
public sealed record AuditLogEntry : ITableEntity
{
public required string PartitionKey { get; set; }
public required string RowKey { get; set; }
public DateTimeOffset? Timestamp { get; set; }
public ETag ETag { get; set; }
public required string Event { get; init; }
public required Guid OrderId { get; init; }
public required decimal Total { get; init; }
// Factory — keeps key design in one place
public static AuditLogEntry ForOrder(Guid orderId, string evt, decimal total) =>
new()
{
PartitionKey = orderId.ToString("N"),
RowKey = DateTime.UtcNow.Ticks.ToString("d19"), // newest first
Event = evt,
OrderId = orderId,
Total = total
};
}
// PartitionKey design examples:
// User sessions → PartitionKey = UserId, RowKey = SessionId
// IoT telemetry → PartitionKey = DeviceId, RowKey = inverted ticks
// Orders by day → PartitionKey = "2026-07-06", RowKey = OrderId
3.2
Table vs SQL vs Cosmos
Table Storage is cheapest and simplest for key-based access. Use Azure SQL when you need JOINs, transactions across entities, and reporting. Use Cosmos DB when you need global distribution and multi-model at scale.
Don't scatter raw TableClient calls through your app. A generic ITableRepository<T> wraps the SDK, exposes IAsyncEnumerable for paging, and keeps table names in one place.
4.1
ITableRepository<T> — generic wrapper
public interface ITableRepository<T> where T : class, ITableEntity, new()
{
Task UpsertAsync(T entity, CancellationToken ct = default);
Task<T?> GetAsync(string partitionKey, string rowKey, CancellationToken ct = default);
IAsyncEnumerable<T> QueryAsync(string? filter = null, CancellationToken ct = default);
Task DeleteAsync(string partitionKey, string rowKey, CancellationToken ct = default);
}
public sealed class TableRepository<T>(TableServiceClient tables, string tableName)
: ITableRepository<T> where T : class, ITableEntity, new()
{
private readonly TableClient _client = tables.GetTableClient(tableName);
public async Task UpsertAsync(T entity, CancellationToken ct = default)
{
await _client.CreateIfNotExistsAsync(ct);
await _client.UpsertEntityAsync(entity, cancellationToken: ct);
}
public async Task<T?> GetAsync(string pk, string rk, CancellationToken ct = default)
{
try
{
var resp = await _client.GetEntityAsync<T>(pk, rk, cancellationToken: ct);
return resp.Value;
}
catch (RequestFailedException ex) when (ex.Status == 404) { return null; }
}
public async IAsyncEnumerable<T> QueryAsync(
string? filter = null, [EnumeratorCancellation] CancellationToken ct = default)
{
await foreach (var page in _client
.QueryAsync<T>(filter, cancellationToken: ct)
.AsPages(pageSizeHint: 100))
{
foreach (var entity in page.Values)
yield return entity;
}
}
}
4.2
Use the repository from a service
public sealed class AuditService(ITableRepository<AuditLogEntry> audit)
{
public Task LogOrderPlacedAsync(Guid orderId, decimal total, CancellationToken ct) =>
audit.UpsertAsync(AuditLogEntry.ForOrder(orderId, "Placed", total), ct);
public async Task<IReadOnlyList<AuditLogEntry>> GetOrderHistoryAsync(
Guid orderId, CancellationToken ct)
{
var filter = $"PartitionKey eq '{orderId:N}'";
var list = new List<AuditLogEntry>();
await foreach (var entry in audit.QueryAsync(filter, ct))
list.Add(entry);
return list;
}
}
// Register with a named table
services.AddScoped<ITableRepository<AuditLogEntry>>(sp =>
new TableRepository<AuditLogEntry>(
sp.GetRequiredService<TableServiceClient>(), "AuditLog"));
4.3
Batch transactions & optimistic concurrency
// Batch — up to 100 entities, SAME PartitionKey only
var batch = new List<TableTransactionAction>();
batch.Add(new TableTransactionAction(
TableTransactionActionType.UpsertReplace, session1));
batch.Add(new TableTransactionAction(
TableTransactionActionType.UpsertReplace, session2));
await tableClient.SubmitTransactionAsync(batch);
// ETag for optimistic concurrency — prevent lost updates
var existing = await tableClient.GetEntityAsync<UserSession>(pk, rk);
existing.Value.Device = "Mobile";
await tableClient.UpdateEntityAsync(
existing.Value,
existing.Value.ETag,
TableUpdateMode.Replace); // throws if someone else updated first
A queue gives point-to-point messaging: one sender, one consumer (or competing consumers). Define message contracts as C# record types with source-generated JSON — no magic strings in your publishers or handlers.
5.1
Typed message contracts + source-generated JSON
// OrderApp.Contracts/Messages/OrderPlacedMessage.cs
public sealed record OrderPlacedMessage(Guid OrderId, int CustomerId, decimal Total);
[JsonSerializable(typeof(OrderPlacedMessage))]
[JsonSerializable(typeof(OrderShippedMessage))]
internal partial class OrderMessageContext : JsonSerializerContext { }
// IServiceBusPublisher<T> — typed send
public interface IServiceBusPublisher<T> where T : class
{
Task PublishAsync(T message, IDictionary<string, object>? props = null,
CancellationToken ct = default);
}
public sealed class ServiceBusPublisher<T>(ServiceBusClient bus, string queueOrTopic)
: IServiceBusPublisher<T> where T : class
{
private readonly ServiceBusSender _sender = bus.CreateSender(queueOrTopic);
public async Task PublishAsync(T message, IDictionary<string, object>? props = null,
CancellationToken ct = default)
{
var json = JsonSerializer.Serialize(message, OrderMessageContext.Default.OrderPlacedMessage);
var sbMsg = new ServiceBusMessage(json)
{
MessageId = Guid.NewGuid().ToString(),
ContentType = "application/json"
};
sbMsg.ApplicationProperties["MessageType"] = typeof(T).Name;
if (props is not null)
foreach (var (k, v) in props) sbMsg.ApplicationProperties[k] = v;
await _sender.SendMessageAsync(sbMsg, ct);
}
public async ValueTask DisposeAsync() => await _sender.DisposeAsync();
}
5.2
Enqueue from Minimal API — inject the abstraction
app.MapPost("/orders", async (
CreateOrderDto dto,
AppDbContext db,
IServiceBusPublisher<OrderPlacedMessage> publisher,
CancellationToken ct) =>
{
var order = new Order { CustomerId = dto.CustomerId, Total = dto.Total };
db.Orders.Add(order);
await db.SaveChangesAsync(ct);
await publisher.PublishAsync(
new OrderPlacedMessage(order.Id, order.CustomerId, order.Total),
new Dictionary<string, object> { ["EventType"] = "OrderPlaced" },
ct);
return Results.Created($"/orders/{order.Id}", order);
});
// Register publisher for the "orders" queue
services.AddSingleton<IServiceBusPublisher<OrderPlacedMessage>>(sp =>
new ServiceBusPublisher<OrderPlacedMessage>(
sp.GetRequiredService<ServiceBusClient>(), "orders"));
5.3
Receive (peek-lock) — low-level SDK
// Prefer IMessageHandler<T> in production — this shows the raw SDK
var receiver = busClient.CreateReceiver("orders");
var received = await receiver.ReceiveMessageAsync(TimeSpan.FromSeconds(30));
if (received is not null)
{
var msg = JsonSerializer.Deserialize<OrderPlacedMessage>(
received.Body.ToString(), OrderMessageContext.Default.OrderPlacedMessage)!;
await ProcessOrderAsync(msg);
await receiver.CompleteMessageAsync(received);
// Or: await receiver.AbandonMessageAsync(received);
// Or: await receiver.DeadLetterMessageAsync(received, "Bad JSON");
}
await receiver.DisposeAsync();
Topics enable pub/sub: one publisher, many subscribers each with their own subscription. Use filters on subscriptions to route message subsets — like RabbitMQ exchanges with bindings.
6.1
Publish to topic, subscribe with filters
// Publish to topic "order-events"
await using var sender = busClient.CreateSender("order-events");
var msg = new ServiceBusMessage("""{"orderId":42,"status":"Placed"}""");
msg.ApplicationProperties["Status"] = "Placed";
await sender.SendMessageAsync(msg);
// Subscription "inventory-svc" — SQL filter rule (Azure Portal or code)
// Rule: Status = 'Placed'
// Subscription "email-svc" — different filter
// Rule: Status IN ('Placed','Shipped')
// Each subscription receives only matching messages
var receiver = busClient.CreateReceiver("order-events", "inventory-svc");
var m = await receiver.ReceiveMessageAsync();
// Process inventory update...
6.2
Queue vs topic — quick pick
// QUEUE — one consumer pool processes each message once
// "Process this order" → single worker service
// TOPIC — fan-out to many independent subscribers
// "OrderPlaced" → Inventory, Email, Analytics each get a copy
// MassTransit on Azure Service Bus:
builder.Services.AddMassTransit(x =>
{
x.UsingAzureServiceBus((ctx, cfg) =>
{
cfg.Host(builder.Configuration["Azure:ServiceBusConnection"]);
cfg.ConfigureEndpoints(ctx);
});
});
// Same consumer code as RabbitMQ — swap transport only
7.1
ServiceBusProcessor (hosted in ASP.NET Core)
public class OrderProcessorService(ServiceBusClient bus,
IServiceScopeFactory scopes,
ILogger<OrderProcessorService> log)
: BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
var processor = bus.CreateProcessor("orders", new ServiceBusProcessorOptions
{
MaxConcurrentCalls = 4,
AutoCompleteMessages = false
});
processor.ProcessMessageAsync += async args =>
{
try
{
using var scope = scopes.CreateScope();
var handler = scope.ServiceProvider
.GetRequiredService<IMessageHandler<OrderPlacedMessage>>();
await handler.HandleAsync(args.Message, args.CancellationToken);
await args.CompleteMessageAsync(args.Message);
}
catch (Exception ex)
{
log.LogError(ex, "Failed processing {Id}", args.Message.MessageId);
await args.AbandonMessageAsync(args.Message);
}
};
processor.ProcessErrorAsync += args =>
{
log.LogError(args.Exception, "Service Bus error");
return Task.CompletedTask;
};
await processor.StartProcessingAsync(ct);
try { await Task.Delay(Timeout.Infinite, ct); }
finally { await processor.StopProcessingAsync(ct); }
}
}
7.2
Dead-letter queue & idempotency
After max delivery attempts, messages move to the dead-letter sub-queue. Inspect them in Azure Portal or with a receiver on orders/$DeadLetterQueue. Handlers must be idempotent — Service Bus delivers at-least-once.
// Check ProcessedMessages table before acting (same pattern as MassTransit)
if (await db.ProcessedMessages.AnyAsync(m => m.MessageId == args.Message.MessageId))
{
await args.CompleteMessageAsync(args.Message);
return;
}
// ... process ...
db.ProcessedMessages.Add(new ProcessedMessage { MessageId = args.Message.MessageId });
await db.SaveChangesAsync();
The sections above introduced individual pieces. This section shows how they compose into a clean infrastructure layer your API, worker, and Functions projects all share — without leaking Azure SDK types into domain code.
8.1
IMessageHandler<T> — typed consumer
public interface IMessageHandler<T> where T : class
{
Task HandleAsync(ServiceBusReceivedMessage raw, CancellationToken ct = default);
}
public abstract class MessageHandlerBase<T> : IMessageHandler<T> where T : class
{
public async Task HandleAsync(ServiceBusReceivedMessage raw, CancellationToken ct = default)
{
var message = JsonSerializer.Deserialize<T>(
raw.Body.ToString(), OrderMessageContext.Default.GetTypeInfo(typeof(T)))!;
await HandleCoreAsync(message, raw, ct);
}
protected abstract Task HandleCoreAsync(T message,
ServiceBusReceivedMessage raw, CancellationToken ct);
}
// Concrete handler — primary constructor, scoped in DI
public sealed class OrderPlacedHandler(
IOrderService orders,
ILogger<OrderPlacedHandler> log)
: MessageHandlerBase<OrderPlacedMessage>
{
protected override async Task HandleCoreAsync(
OrderPlacedMessage msg, ServiceBusReceivedMessage raw, CancellationToken ct)
{
log.LogInformation("Fulfilling order {Id}", msg.OrderId);
await orders.FulfillAsync(msg.OrderId, ct);
}
}
8.2
ServiceBusProcessorHost — reusable BackgroundService
public sealed class ServiceBusProcessorHost<T>(
ServiceBusClient bus,
IServiceScopeFactory scopes,
ILogger<ServiceBusProcessorHost<T>> log,
string queueOrTopic,
string? subscription = null)
: BackgroundService where T : class
{
protected override async Task ExecuteAsync(CancellationToken ct)
{
var processor = subscription is null
? bus.CreateProcessor(queueOrTopic, new() { AutoCompleteMessages = false })
: bus.CreateProcessor(queueOrTopic, subscription,
new() { AutoCompleteMessages = false });
processor.ProcessMessageAsync += async args =>
{
try
{
using var scope = scopes.CreateScope();
var handler = scope.ServiceProvider
.GetRequiredService<IMessageHandler<T>>();
await handler.HandleAsync(args.Message, args.CancellationToken);
await args.CompleteMessageAsync(args.Message, ct);
}
catch (Exception ex)
{
log.LogError(ex, "Handler failed for {Id}", args.Message.MessageId);
await args.AbandonMessageAsync(args.Message, cancellationToken: ct);
}
};
await processor.StartProcessingAsync(ct);
try { await Task.Delay(Timeout.Infinite, ct); }
finally { await processor.StopProcessingAsync(ct); }
}
}
// Program.cs
builder.Services.AddScoped<IMessageHandler<OrderPlacedMessage>, OrderPlacedHandler>();
builder.Services.AddHostedService(sp =>
new ServiceBusProcessorHost<OrderPlacedMessage>(
sp.GetRequiredService<ServiceBusClient>(),
sp.GetRequiredService<IServiceScopeFactory>(),
sp.GetRequiredService<ILogger<ServiceBusProcessorHost<OrderPlacedMessage>>>(),
"orders"));
8.3
Full DI registration — API + worker share Infrastructure
// OrderApp.Api/Program.cs
builder.Services.AddAzureServices();
builder.Services.AddDbContext<AppDbContext>(o =>
o.UseSqlServer(builder.Configuration.GetConnectionString("Sql")));
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddScoped<IMessageHandler<OrderPlacedMessage>, OrderPlacedHandler>();
builder.Services.AddScoped<ITableRepository<AuditLogEntry>>(sp =>
new TableRepository<AuditLogEntry>(
sp.GetRequiredService<TableServiceClient>(), "AuditLog"));
builder.Services.AddSingleton<IServiceBusPublisher<OrderPlacedMessage>>(sp =>
new ServiceBusPublisher<OrderPlacedMessage>(
sp.GetRequiredService<ServiceBusClient>(), "orders"));
// Optional: run processor in same API process (small apps)
// builder.Services.AddHostedService<ServiceBusProcessorHost<OrderPlacedMessage>>();
// Larger apps: separate OrderApp.Worker project with identical AddAzureServices()
C# Tip — IAsyncDisposable senders
ServiceBusSender is expensive to create per message. Register publishers as singletons (one sender per queue/topic). For one-off scripts, use await using var sender = bus.CreateSender("orders");.
Azure Functions runs your code when something happens — HTTP request, timer, queue message. The isolated worker model (.NET 8+) runs in a separate process from the host, giving you full control over DI and middleware — like a mini ASP.NET Core app.
9.1
Create & configure a function app
// dotnet new func --name OrderFunctions --worker-runtime dotnet-isolated
// Program.cs — reuse the same Infrastructure extensions
var host = new HostBuilder()
.ConfigureFunctionsWebApplication()
.ConfigureServices((ctx, services) =>
{
services.Configure<AzureOptions>(ctx.Configuration.GetSection(AzureOptions.SectionName));
services.AddAzureServices();
services.AddScoped<IMessageHandler<OrderPlacedMessage>, OrderPlacedHandler>();
services.AddScoped<ITableRepository<AuditLogEntry>>(sp =>
new TableRepository<AuditLogEntry>(
sp.GetRequiredService<TableServiceClient>(), "AuditLog"));
services.AddDbContext<AppDbContext>(opt =>
opt.UseSqlServer(ctx.Configuration.GetConnectionString("Sql")));
})
.Build();
host.Run();
// host.json — logging, concurrency
// local.settings.json — secrets for func start (never commit)
9.2
HTTP trigger function
public class HelloFunction(ILogger<HelloFunction> log)
{
[Function("Hello")]
public IActionResult Run(
[HttpTrigger(AuthorizationLevel.Function, "get", Route = "hello")] HttpRequest req)
{
log.LogInformation("Hello triggered");
return new OkObjectResult(new { message = "Hello from Azure Functions!" });
}
}
// Deploy: func azure functionapp publish OrderFunctions
// Or: GitHub Actions / Azure DevOps pipeline → zip deploy
10.1
Service Bus trigger — inject IMessageHandler<T>
public class ProcessOrderFunction(
IMessageHandler<OrderPlacedMessage> handler,
ILogger<ProcessOrderFunction> log)
{
[Function("ProcessOrder")]
public async Task Run(
[ServiceBusTrigger("orders", Connection = "ServiceBusConnection")]
ServiceBusReceivedMessage message,
FunctionContext context)
{
log.LogInformation("Processing {Id}", message.MessageId);
await handler.HandleAsync(message, context.CancellationToken);
}
}
// Timer trigger — cron-style (NCRONTAB: {second} {minute} {hour} {day} {month} {day-of-week})
[Function("NightlyReport")]
public async Task RunReport(
[TimerTrigger("0 0 6 * * *")] TimerInfo timer) // 06:00 UTC daily
{
await _reports.GenerateDailyAsync();
}
10.2
Audit function — ITableRepository<T> + trigger
public class AuditFunction(
ITableRepository<AuditLogEntry> audit,
ILogger<AuditFunction> log)
{
[Function("AuditOrder")]
public async Task Run(
[ServiceBusTrigger("orders", Connection = "ServiceBusConnection")]
ServiceBusReceivedMessage message,
FunctionContext ctx)
{
var placed = JsonSerializer.Deserialize<OrderPlacedMessage>(
message.Body.ToString(), OrderMessageContext.Default.OrderPlacedMessage)!;
await audit.UpsertAsync(
AuditLogEntry.ForOrder(placed.OrderId, "Processed", placed.Total),
ctx.CancellationToken);
log.LogInformation("Audited order {Id}", placed.OrderId);
}
}
// Flow: API → IServiceBusPublisher → queue → Function → ITableRepository
Put every abstraction together: a Minimal API writes to SQL and publishes a typed message; an Azure Function consumes it and writes an audit row to Table Storage. Same contracts and infrastructure code in both projects.
11.1
Step 1 — API publishes after SQL commit
// POST /orders
// 1. Save Order to Azure SQL (EF Core)
// 2. Publish OrderPlacedMessage to "orders" queue
// 3. Return 201 Created
app.MapPost("/orders", async (
CreateOrderDto dto,
AppDbContext db,
IServiceBusPublisher<OrderPlacedMessage> bus,
CancellationToken ct) =>
{
var order = new Order { CustomerId = dto.CustomerId, Total = dto.Total };
db.Orders.Add(order);
await db.SaveChangesAsync(ct); // source of truth in SQL
await bus.PublishAsync(new OrderPlacedMessage(
order.Id, order.CustomerId, order.Total), ct: ct);
return Results.Created($"/orders/{order.Id}", order);
});
11.2
Step 2 — Function fulfills + audits
// ProcessOrderFunction — fulfill in SQL
public class ProcessOrderFunction(IOrderService orders, ILogger<ProcessOrderFunction> log)
{
[Function("ProcessOrder")]
public async Task Run(
[ServiceBusTrigger("orders", Connection = "ServiceBusConnection")]
ServiceBusReceivedMessage msg, FunctionContext ctx)
{
var placed = JsonSerializer.Deserialize<OrderPlacedMessage>(
msg.Body.ToString(), OrderMessageContext.Default.OrderPlacedMessage)!;
await orders.FulfillAsync(placed.OrderId, ctx.CancellationToken);
log.LogInformation("Fulfilled {Id}", placed.OrderId);
}
}
// AuditOrderFunction — write to Table Storage (separate function or same handler)
await audit.UpsertAsync(AuditLogEntry.ForOrder(
placed.OrderId, "Fulfilled", placed.Total), ct);
11.3
Message flow diagram
Client
│ POST /orders { customerId, total }
▼
OrderApp.Api (App Service)
│ EF Core → Azure SQL (Orders table)
│ IServiceBusPublisher<OrderPlacedMessage> → "orders" queue
▼
Azure Service Bus (queue: orders)
│ at-least-once delivery, peek-lock
▼
OrderApp.Functions (isolated worker)
│ ProcessOrderFunction → IOrderService.FulfillAsync()
│ AuditOrderFunction → ITableRepository<AuditLogEntry>.UpsertAsync()
▼
Azure Table Storage (AuditLog table)
PartitionKey = OrderId, RowKey = ticks
Watch Out — publish after commit
Always SaveChangesAsync before publishing. If the bus send fails, you have an order in SQL without a message — use an outbox table or MassTransit transactional outbox for exactly-once semantics. See the MassTransit guide.
12.1
Reliability & security habits
- Managed Identity everywhere in Azure — no connection strings in app settings.
- Table Storage: design PartitionKey to avoid hot partitions; use batch only within one partition.
- Service Bus: enable dead-lettering; monitor DLQ depth; idempotent consumers.
- Functions: use Application Insights; set
FUNCTIONS_WORKER_RUNTIME=dotnet-isolated.
- Prefer Premium or Dedicated Service Bus for production SLAs (Basic tier has limitations).
- Use separate namespaces per environment (dev/staging/prod).
- Local dev: Azurite for Table Storage, Service Bus Explorer or emulator alternatives.
12.2
Architecture pattern — typical .NET on Azure
// ASP.NET Core API (App Service)
// → writes to Azure SQL (EF Core)
// → publishes to Service Bus topic "order-events"
// → caches reads in Redis (optional)
// Azure Functions (consumers)
// → subscription "fulfillment" processes orders
// → subscription "notify" sends emails
// → writes audit trail to Table Storage
// Hangfire (optional, same API app)
// → recurring internal jobs not suited for serverless
// MassTransit (optional)
// → abstracts Service Bus transport + outbox + sagas
Mastery Move — where to go next
1. Section 8 — C# Abstractions — revisit ITableRepository<T>, IServiceBusPublisher<T> and IMessageHandler<T> before going to production.
2. MassTransit & RabbitMQ — same messaging patterns; swap RabbitMQ for Azure Service Bus transport.
3. Hangfire & Quartz — in-process scheduling vs serverless timer triggers.
4. T-SQL Deep-Dive — Azure SQL uses the same T-SQL; design schemas for cloud scale.