Saturday, 7 November 2026

How do you handle retries, failures, duplicate messages, and dead-letter queues in Service Bus?

Retries, Failures, Duplicates, and Dead-Lettering in Service Bus

Service Bus gives you at-least-once delivery, so your design has to assume that messages can fail, be retried, and arrive more than once. Four mechanisms work together:

  1. Client retries for transient errors when talking to Service Bus
  2. Message redelivery (peek-lock and delivery count) when processing fails
  3. Dead-lettering for messages that can't be processed
  4. Duplicate handling, through broker-side detection and idempotent consumers

1. Two kinds of retry

Client-side retries cover transient failures when sending or receiving (network blips, throttling, server busy). The Azure SDK retries automatically, and you can tune it:

var client = new ServiceBusClient(
    "mynamespace.servicebus.windows.net",
    new DefaultAzureCredential(),
    new ServiceBusClientOptions
    {
        RetryOptions = new ServiceBusRetryOptions
        {
            Mode = ServiceBusRetryMode.Exponential,
            MaxRetries = 5,
            Delay = TimeSpan.FromSeconds(1),
            MaxDelay = TimeSpan.FromSeconds(30)
        }
    });

Message redelivery covers failures in your processing logic. This is controlled by peek-lock settlement, covered next.

2. Peek-lock settlement and redelivery

In peek-lock mode, a received message is locked (default lock duration 30 seconds, max 5 minutes). You then settle it:

Action Effect
Complete Message is removed. Success.
Abandon Lock is released; the message becomes available again and delivery count increments.
Defer Message stays in the queue but is only retrievable by its sequence number. Useful for out-of-order processing.
Dead-letter Message moves to the dead-letter queue immediately.
(do nothing / crash / lock expires) Message becomes available again and delivery count increments.

Use ServiceBusProcessor, which handles concurrency, lock renewal, and settlement for you:

var processor = client.CreateProcessor("orders", new ServiceBusProcessorOptions
{
    MaxConcurrentCalls = 10,
    AutoCompleteMessages = false,          // settle explicitly
    MaxAutoLockRenewalDuration = TimeSpan.FromMinutes(10),
    PrefetchCount = 20
});

processor.ProcessMessageAsync += async args =>
{
    try
    {
        await HandleAsync(args.Message, args.CancellationToken);
        await args.CompleteMessageAsync(args.Message);
    }
    catch (TransientException)
    {
        // Retry: becomes visible again, delivery count + 1
        await args.AbandonMessageAsync(args.Message);
    }
    catch (PermanentException ex)
    {
        // Retrying won't help: dead-letter right away
        await args.DeadLetterMessageAsync(args.Message,
            deadLetterReason: "ValidationFailed",
            deadLetterErrorDescription: ex.Message);
    }
};

processor.ProcessErrorAsync += args =>
{
    logger.LogError(args.Exception,
        "Service Bus error from {Source} on {Entity}", args.ErrorSource, args.EntityPath);
    return Task.CompletedTask;
};

await processor.StartProcessingAsync();

Key point: classify failures. Transient failures (timeouts, 503s from a dependency, deadlocks) should be abandoned or retried. Permanent failures (malformed payload, validation errors, missing required data) should be dead-lettered immediately, since retrying them just burns delivery attempts.

Lock duration and long processing

If processing takes longer than the lock duration, the lock expires, the message is redelivered to another consumer, and your original Complete call fails with MessageLockLost. Avoid this by:

  • Setting MaxAutoLockRenewalDuration so the processor renews the lock automatically
  • Setting an appropriate lock duration on the entity
  • Moving very long work into a durable workflow and keeping message handlers short

Retry timing: Service Bus has no built-in backoff on redelivery

An abandoned message is available immediately, which can cause a tight retry loop that hammers a failing dependency. Common patterns:

  • In-process retry first: retry transient calls with exponential backoff (Polly or Microsoft.Extensions.Http.Resilience) within the handler before abandoning.
  • Scheduled re-enqueue: complete the original and send a copy scheduled for later, which gives you true delayed retries:
var retry = new ServiceBusMessage(args.Message)
{
    ScheduledEnqueueTime = DateTimeOffset.UtcNow.AddSeconds(Math.Pow(2, attempt) * 10)
};
retry.ApplicationProperties["attempt"] = attempt + 1;

await sender.SendMessageAsync(retry);
await args.CompleteMessageAsync(args.Message);

If you do this, do the send and complete in a transaction so you don't lose or duplicate the message (see below), and track the attempt count yourself.

  • Circuit breaker: if a dependency is down, stop receiving for a while instead of cycling messages through delivery attempts.

3. Dead-letter queues (DLQ)

Every queue and every topic subscription has a dead-letter sub-queue, addressed as <entity>/$deadletterqueue. Messages end up there when:

  • MaxDeliveryCount is exceeded (default 10). This is the "poison message" safety net.
  • TTL expires and dead-lettering on expiration is enabled (DeadLetteringOnMessageExpiration).
  • Your code dead-letters explicitly with a reason and description.
  • A subscription filter evaluation fails (when configured to dead-letter on filter evaluation exceptions).
  • Session-related issues, in some cases.

The DLQ is not automatically processed and has no TTL of its own, so messages stay until you deal with them. It also counts against the entity's size quota, so an ignored DLQ can eventually fill the queue.

Reading dead-lettered messages:

var dlqReceiver = client.CreateReceiver("orders", new ServiceBusReceiverOptions
{
    SubQueue = SubQueue.DeadLetter
});

var messages = await dlqReceiver.ReceiveMessagesAsync(maxMessages: 20, TimeSpan.FromSeconds(5));
foreach (var m in messages)
{
    Console.WriteLine($"{m.MessageId}: {m.DeadLetterReason} - {m.DeadLetterErrorDescription}");
}

For system dead-lettering, the reason is set for you (for example MaxDeliveryCountExceeded or TTLExpiredException). For your own dead-lettering, always set deadLetterReason and deadLetterErrorDescription so triage is easy.

What to do with dead-lettered messages

  1. Alert when the DLQ message count is above zero (Azure Monitor metric DeadletteredMessages, filtered by entity). Unmonitored DLQs are where messages go to be forgotten.
  2. Inspect them (Service Bus Explorer in the portal or in Visual Studio, or code like the above) and read the reason.
  3. Fix the cause: a bug, a bad payload, or a downstream outage that has since recovered.
  4. Resubmit after the fix: receive from the DLQ, send a clone to the original entity, then complete the DLQ copy. The Azure portal's Service Bus Explorer also supports resending dead-lettered messages.
  5. Or discard/archive messages that are genuinely invalid, recording where they went.
await foreach (var dead in dlqReceiver.ReceiveMessagesAsync())
{
    var clone = new ServiceBusMessage(dead);   // copies body and properties
    await sender.SendMessageAsync(clone);
    await dlqReceiver.CompleteMessageAsync(dead);
}

Automate DLQ handling with an Azure Function triggered from the DLQ path (orders/$deadletterqueue) if you want logging, notification, or auto-resubmission with a retry cap.

Tuning MaxDeliveryCount

  • The default of 10 is often too high for failures that will never succeed and too low for slow-recovering dependencies.
  • Pair it with how long a failure typically lasts. If a dependency outage can last 10 minutes and messages retry instantly, 10 attempts can be exhausted in seconds, sending healthy messages to the DLQ. This is another reason to add backoff.

4. Duplicate messages

Duplicates arrive from two directions:

Producer-side duplicates. A sender times out after the broker accepted the message and retries, so the broker gets two copies.

Consumer-side duplicates. A message is delivered more than once because the lock expired, the consumer crashed after processing but before completing, or you abandoned it. This is inherent in at-least-once delivery.

Broker-side duplicate detection (producer duplicates)

Enable it when creating the queue or topic and set a detection window (default 10 minutes, max 7 days). Service Bus then discards any message whose MessageId matches one already received within the window.

await adminClient.CreateQueueAsync(new CreateQueueOptions("orders")
{
    RequiresDuplicateDetection = true,
    DuplicateDetectionHistoryTimeWindow = TimeSpan.FromMinutes(30)
});

// Set a deterministic MessageId derived from the business key
var message = new ServiceBusMessage(body) { MessageId = $"order-{order.Id}" };

Important details:

  • It only works if you set a stable, meaningful MessageId. Random GUIDs defeat it.
  • It can only be enabled at entity creation time. You can't turn it on later.
  • It only covers duplicates sent within the window. It does not help with redelivery to consumers.
  • It isn't supported in the same way on partitioned or some Premium configurations. Check the current docs for your tier.

Idempotent consumers (the real solution)

Because at-least-once delivery can't be fully eliminated, make processing idempotent, so handling a message twice has the same effect as handling it once.

Common approaches:

  • Natural idempotency: use upserts (MERGE, INSERT ... ON CONFLICT DO NOTHING) and "set status = X" rather than "increment by 1."
  • Processed-message table (inbox pattern): record MessageId (or a business key) in the same database transaction as the business change. If the insert fails on a unique key, the message was already handled, so skip it and complete it.
// In one DB transaction:
//   INSERT INTO ProcessedMessages (MessageId) VALUES (@id)   -- unique constraint
//   ... apply business change ...
// If the insert violates the constraint: already processed, so just complete the message.
  • Idempotency keys on downstream calls: pass a stable key to payment providers and similar APIs so they dedupe too.
  • Check delivery count (message.DeliveryCount > 1) as a hint that a message may have been processed before, then verify against state.

5. Atomic operations with transactions

A common failure mode: you send a follow-up message and then crash before completing the original, producing duplicates or lost work. Service Bus transactions group operations on the same connection so they succeed or fail together:

var client = new ServiceBusClient(fqdn, credential,
    new ServiceBusClientOptions { EnableCrossEntityTransactions = true });

var receiver = client.CreateReceiver("orders");
var sender = client.CreateSender("payments");

var message = await receiver.ReceiveMessageAsync();

using (var scope = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled))
{
    await sender.SendMessageAsync(new ServiceBusMessage($"charge-{message.MessageId}"));
    await receiver.CompleteMessageAsync(message);
    scope.Complete();
}

Transactions span only Service Bus operations. They do not include your database. For database plus message consistency, use the outbox pattern: write the outgoing message to an outbox table in the same DB transaction as your business change, and have a separate relay publish it to Service Bus (which, combined with consumer idempotency, gives effectively-once results).

6. Ordering and sessions

If order matters, use sessions. They give FIFO per SessionId and exclusive processing by one consumer at a time. Be aware that a failing message in a session can block the rest of that session until it's dead-lettered or completed, so set a sensible MaxDeliveryCount and handle poison messages.

7. Azure Functions behavior

With the Service Bus trigger:

  • The host calls Complete if your function succeeds and Abandon if it throws (when autoCompleteMessages is true, the default).
  • Delivery count and DLQ rules are the same as above, so repeated exceptions end in the DLQ after MaxDeliveryCount.
  • Tune concurrency in host.json:
{
  "extensions": {
    "serviceBus": {
      "maxConcurrentCalls": 16,
      "maxAutoLockRenewalDuration": "00:10:00",
      "autoCompleteMessages": true
    }
  }
}
  • Use ServiceBusMessageActions with autoCompleteMessages: false if you want explicit control (dead-letter with a reason, defer, and so on).
  • Setting MaxDeliveryCount too high combined with a flaky dependency can multiply load. Use Functions' built-in retry policies only for non-Service-Bus triggers; for Service Bus, rely on the broker's redelivery and delivery count.

8. Monitoring checklist

Alert on:

  • Dead-lettered message count > 0
  • Active message count / queue depth growing steadily (consumers falling behind or failing)
  • Oldest message age rising
  • Throttled requests and server errors on the namespace
  • Lock-lost and processing exceptions in Application Insights

Log MessageId, DeliveryCount, CorrelationId, and the entity name in every handler so a single message can be traced across retries.


Friday, 6 November 2026

What are Durable Functions, and when would you use them?

Durable Functions

Durable Functions is an extension of Azure Functions that lets you write stateful, long-running workflows as code in a serverless environment. Normal functions are stateless and short-lived. Durable Functions adds orchestration on top: you write a function that calls other functions in sequence or in parallel, waits for events or timers, and survives restarts, scale-out, and crashes without losing its place.

Function types

  • Client function: starts, queries, signals, or terminates orchestration instances (usually an HTTP, queue, or timer trigger).
  • Orchestrator function: defines the workflow. It calls activities, awaits results, and decides what happens next.
  • Activity function: a unit of work (call an API, write to a DB). This is where the actual I/O happens.
  • Entity function (Durable Entities): a small piece of named, addressable state (a counter, a shopping cart) with operations you can signal or call.

How it works

Durable Functions uses event sourcing and replay. Each step the orchestrator takes is recorded in a history table in the configured storage backend. When an orchestrator awaits something (an activity result, a timer, an external event), it is unloaded from memory, so you pay for nothing while it waits. When the awaited thing completes, the orchestrator is reloaded and replays from the start, using the recorded history to skip work already done, until it reaches the next new step.

This is why orchestrator code has strict rules (see below).

Storage backends: Azure Storage (default), the Netherite backend (high throughput, uses Event Hubs and page blobs), and the Microsoft SQL backend (state in SQL Server or Azure SQL). The Durable Task Scheduler is a newer managed backend; check the current docs for availability and status.

Example (C# isolated worker): function chaining

public static class OrderWorkflow
{
    [Function("OrderOrchestrator")]
    public static async Task<string> RunOrchestrator(
        [OrchestrationTrigger] TaskOrchestrationContext context)
    {
        var order = context.GetInput<Order>();

        await context.CallActivityAsync("ReserveInventory", order);
        await context.CallActivityAsync("ChargePayment", order);
        await context.CallActivityAsync("SendConfirmation", order);

        return $"Order {order.Id} completed";
    }

    [Function("ReserveInventory")]
    public static Task ReserveInventory([ActivityTrigger] Order order, FunctionContext ctx)
    {
        // call inventory service
        return Task.CompletedTask;
    }

    // ChargePayment and SendConfirmation follow the same pattern
}

public static class Starter
{
    [Function("StartOrder")]
    public static async Task<HttpResponseData> Start(
        [HttpTrigger(AuthorizationLevel.Function, "post")] HttpRequestData req,
        [DurableClient] DurableTaskClient client)
    {
        var order = await req.ReadFromJsonAsync<Order>();
        string instanceId = await client.ScheduleNewOrchestrationInstanceAsync(
            "OrderOrchestrator", order);

        return await client.CreateCheckStatusResponseAsync(req, instanceId);
    }
}

The starter returns an HTTP 202 with status-query URLs, so callers can poll for progress.

Common patterns

1. Function chaining: run steps in order, passing output to the next (above).

2. Fan-out/fan-in: run many activities in parallel and aggregate the results.

var tasks = files.Select(f => context.CallActivityAsync<int>("ProcessFile", f));
int[] results = await Task.WhenAll(tasks);
int total = results.Sum();

3. Async HTTP API: start a long operation and return a status endpoint instead of holding a connection open.

4. Monitor: poll something repeatedly until a condition is met, with flexible intervals (a durable timer loop instead of a fragile timer trigger).

5. Human interaction / approval: wait for an external event with a timeout.

using var cts = new CancellationTokenSource();
var approval = context.WaitForExternalEvent<bool>("Approval");
var timeout = context.CreateTimer(context.CurrentUtcDateTime.AddDays(3), cts.Token);

if (await Task.WhenAny(approval, timeout) == approval)
{
    cts.Cancel();   // cancel the timer so the orchestration can complete
    // approved or rejected
}
else
{
    // escalate
}

6. Aggregator (stateful entities): accumulate state from many sources into one addressable entity.

7. Saga / compensation: if a later step fails, run compensating activities (refund payment, release inventory) in a catch block.

Built-in reliability features

  • Automatic retries for activities with configurable backoff.
  • Durable timers that can wait minutes, days, or longer at no compute cost.
  • Sub-orchestrations to compose workflows from smaller ones.
  • Checkpointing: if the host crashes mid-workflow, it resumes from the last completed step.
  • Instance management APIs: query, suspend, resume, terminate, rewind, and purge instances.

Orchestrator code constraints

Because the orchestrator is replayed, its code must be deterministic:

  • Don't use DateTime.UtcNow; use context.CurrentUtcDateTime.
  • Don't use Guid.NewGuid(); use context.NewGuid().
  • Don't do I/O (HTTP, DB, file access) or read changing config in the orchestrator; do that in activities.
  • Don't use Task.Delay or Thread.Sleep; use context.CreateTimer.
  • Don't use threads or non-durable async constructs; only await durable tasks.
  • Use context.CreateReplaySafeLogger(...) so logs aren't duplicated on every replay.
  • Keep inputs, outputs, and history small. Large payloads bloat storage; pass references (blob URLs, IDs) instead.
  • Version carefully. Changing orchestrator logic while instances are in flight can break replay, so use versioning strategies or drain running instances first.

When to use Durable Functions

Good fits:

  • Multi-step business processes that must complete reliably (order fulfillment, onboarding, provisioning)
  • Long-running workflows lasting minutes to days or longer
  • Human-in-the-loop approvals with timeouts and escalation
  • Parallel batch processing with aggregation (process thousands of files, then summarize)
  • Saga-style transactions across services that need compensation
  • Polling and monitoring loops
  • Replacing hand-rolled state machines built from queues and tables

Consider something else when:

  • The work is a single short operation. A plain function is simpler.
  • You need very high-throughput, ultra-low-latency processing. Orchestration adds storage round-trips, so look at plain queue triggers, Event Hubs, or the Netherite backend.
  • You want a low-code, visual designer with many connectors. Logic Apps fits better.
  • The workflow is complex enough to need a dedicated engine with rich versioning, search, and visibility. Temporal or a similar engine may fit, though it brings more operational work.
  • You need strict message ordering, sessions, and broker features. Service Bus is the better primitive, possibly alongside Durable Functions.

Durable Functions vs. related options

Durable Functions Logic Apps Service Bus + Functions Plain Functions
Style Code-first workflows Visual/low-code designer Messaging between steps Single stateless step
State Managed for you Managed for you You manage it None
Long waits, timers, approvals Built in Built in Manual Manual
Complex logic and loops Natural, it's just code Possible, harder to maintain Scattered across handlers N/A
Best for Developers coding complex workflows Integration with many SaaS connectors Simple decoupled processing Event handlers

They combine well. For instance, a Service Bus-triggered function can start an orchestration, and an orchestration can call out to Logic Apps or other services.

Operations and cost

  • Monitoring: Application Insights captures orchestration history and traces. Use the instance management APIs, Durable Functions Monitor, or the Durable Task Scheduler dashboard (where available) to inspect state.
  • Cost: you pay for function execution plus storage transactions for state. Chatty orchestrations with many tiny activities can drive up storage costs, so batch work sensibly.
  • Hosting: works on Consumption, Flex Consumption, Premium, and Dedicated plans. Check current docs for backend support on each plan.
  • Housekeeping: purge completed instance history periodically so storage doesn't grow without bound.

Quick rule of thumb

  • If your workflow is "do A, then B, then C, maybe wait for something, and don't lose my place if something crashes", use Durable Functions.
  • If it is "react to one event and do one thing", use a normal function.
  • If you want to click together connectors, use Logic Apps.


Thursday, 5 November 2026

What is Azure API Management (APIM), and why do we use it?

Azure API Management (APIM)

Azure API Management is a managed service that sits in front of your APIs as a gateway. Clients call APIM, and APIM applies policies (authentication, rate limiting, transformation, caching) before forwarding requests to your backends. The backends can be App Service, Functions, AKS, Container Apps, on-premises systems, or even other clouds.

The three components

  • Gateway: the runtime proxy that receives API calls, enforces policies, and routes to backends. This is the data plane.
  • Management plane: the Azure portal, CLI, Bicep, or REST API, where you define APIs, products, policies, and users.
  • Developer portal: an auto-generated, customizable website where API consumers browse docs, try calls, and get subscription keys.

Self-hosted gateway: you can also run the gateway in containers on-premises or in other clouds, managed from the same APIM instance (Developer and Premium tiers).

Core concepts

  • API: a set of operations (e.g., GET /orders/{id}), imported from OpenAPI, WSDL, an App Service, a Function App, or created manually.
  • Product: a bundle of APIs with its own terms, quotas, and visibility (e.g., "Free" vs. "Premium" plans).
  • Subscription: a key a consumer uses to access a product or API (sent in the Ocp-Apim-Subscription-Key header by default).
  • Policy: XML statements that run on the request or response pipeline at global, product, API, or operation scope.
  • Named values: configuration and secrets used inside policies (can be backed by Key Vault).
  • Backend: a reusable definition of a downstream service, with URL, credentials, and circuit breaker settings.

Why use it

1. Security at the edge

  • Validate JWTs from Entra ID before requests reach your backend
  • Enforce subscription keys, client certificates, and IP filtering
  • Authenticate to backends with managed identity, so backends never expose public credentials
  • Centralize security policies instead of re-implementing them in every service

2. Traffic control

  • Rate limiting and quotas per client, per key, or per IP
  • Protection against abusive callers and backend overload
  • Response caching to reduce backend load and latency
  • Retry and circuit breaker behavior for flaky backends

3. A single, consistent front door

  • One public URL and domain across many backend services
  • Backends can move or be replaced without breaking consumers
  • Consistent URL structure, headers, and error formats

4. Versioning and lifecycle

  • Run multiple versions (/v1, /v2) side by side
  • Revisions let you make non-breaking changes and test them before making them current

5. Transformation and mediation

  • Rewrite URLs, add or strip headers, convert XML to JSON, mask fields in responses
  • Expose a clean API over a messy legacy backend without changing it

6. Observability

  • Built-in analytics, plus diagnostic logs and traces to Application Insights and Azure Monitor
  • Correlation IDs, per-API and per-consumer usage and latency

7. Developer experience and monetization

  • Self-service portal with documentation and a test console
  • Products and subscriptions let you onboard partners, issue keys, and apply different limits per tier

Example policies

Validate an Entra ID token, rate limit, and forward using managed identity:

<policies>
  <inbound>
    <base />
    <validate-jwt header-name="Authorization" failed-validation-httpcode="401"
                  failed-validation-error-message="Unauthorized">
      <openid-config url="https://login.microsoftonline.com/<tenant-id>/v2.0/.well-known/openid-configuration" />
      <audiences>
        <audience>api://<api-client-id></audience>
      </audiences>
      <required-claims>
        <claim name="roles" match="any">
          <value>Orders.Read</value>
        </claim>
      </required-claims>
    </validate-jwt>

    <rate-limit-by-key calls="100" renewal-period="60"
                       counter-key="@(context.Request.IpAddress)" />

    <authentication-managed-identity resource="api://<backend-client-id>" />
  </inbound>
  <backend>
    <base />
  </backend>
  <outbound>
    <base />
    <set-header name="X-Powered-By" exists-action="delete" />
  </outbound>
  <on-error>
    <base />
  </on-error>
</policies>

Response caching:

<inbound>
  <base />
  <cache-lookup vary-by-developer="false" vary-by-developer-groups="false" />
</inbound>
<outbound>
  <base />
  <cache-store duration="60" />
</outbound>

Tiers

Tier Typical use
Consumption Serverless, pay per call, scale to zero; good for low or spiky traffic (no VNet injection, limited features)
Developer Non-production, for evaluation and testing (no SLA)
Basic / Standard Small to mid-sized production workloads (classic tiers)
Premium Multi-region, availability zones, VNet injection, self-hosted gateway, highest scale
v2 tiers (Basic v2, Standard v2, Premium v2) Newer generation with faster provisioning and scaling, and simpler VNet integration

Tier names and feature sets change over time, so check the current pricing and feature comparison before choosing.

Typical architecture

Client ─► Front Door / App Gateway (WAF) ─► APIM ─► Backends
                                              │       ├─ App Service (Orders API)
                                              │       ├─ Functions (Webhooks)
                                              │       └─ AKS / Container Apps (Inventory)
                                              └─► Application Insights

Lock backends down so they only accept traffic from APIM (access restrictions, private endpoints, or a shared header or token check). Otherwise callers can bypass your policies by hitting the backend directly.

When APIM is a good fit

  • You expose APIs to external partners or the public and need keys, quotas, and a developer portal
  • You have multiple backend services and want one consistent entry point
  • You need centralized auth, throttling, and logging without changing each service
  • You're modernizing legacy services and want to hide their quirks behind a clean facade
  • You need to publish APIs across regions or hybrid environments

When it may be overkill

  • A single internal API with one consumer, where app-level auth and Easy Auth suffice
  • Very low-budget or prototype projects (the non-Consumption tiers carry a meaningful fixed monthly cost)
  • Scenarios needing only WAF and routing, where Front Door or Application Gateway is simpler
  • Latency-critical hot paths, where every extra hop matters (APIM adds some overhead, usually small)

APIM vs. related services

APIM Front Door / App Gateway Easy Auth
Focus API lifecycle, policy, consumer management Global routing, WAF, load balancing Simple built-in authentication
Understands API operations and products Yes No No
Rate limiting per consumer/key Yes Basic (WAF rate rules) No
Developer portal Yes No No

They're complementary: Front Door provides global entry and WAF, and APIM provides API governance behind it.

Good practices:

  • Define APIM as code (Bicep or Terraform, with policies in versioned files) and deploy through CI/CD. Use the APIOps approach to promote API definitions between environments.
  • Use managed identity for backend authentication, and Key Vault-backed named values for secrets.
  • Validate tokens in APIM and also in the backend (defense in depth), and restrict backends to APIM traffic only.
  • Keep policies simple and reusable with fragments, because complex policy logic is hard to test and debug.
  • Send diagnostics to Application Insights and watch for gateway versus backend latency and failures.
  • Use revisions and versions deliberately: revisions for non-breaking changes, versions for breaking ones.
  • Plan capacity and region placement: choose a tier that supports the VNet and availability needs you actually have, because some of these can't be changed later without redeploying.


Wednesday, 4 November 2026

What deployment options are available for a Web API in Azure—App Service or AKS?

Deployment Options for a Web API in Azure

App Service and AKS sit at opposite ends of a spectrum: App Service is a managed platform where you deploy code or a container and Azure runs everything else, while AKS is managed Kubernetes where you get full control of the orchestration layer and take on the operational work that comes with it. There are also middle-ground options, covered below.

App Service

A PaaS for web apps and APIs. You deploy code (zip, Git, CI/CD) or a single container, and Azure handles the OS, patching, load balancing, TLS, and scaling.

Strengths:

  • Fastest path to production, with very little operational overhead
  • Built-in deployment slots (Standard tier and above) for zero-downtime swaps and instant rollback
  • Autoscale, custom domains, managed certificates, Easy Auth, VNet integration, and managed identity
  • Native .NET support, with no Dockerfile required
  • A predictable, per-plan price

Limitations:

  • Less control over networking, scheduling, and the runtime environment
  • Multi-container setups are limited. It's one app per container, with no real orchestration
  • Not suited to complex microservice meshes, sidecars, or custom operators
  • Scale is bounded by plan limits (though it's plenty for most APIs)

Deploying:

az webapp up --name my-api --resource-group my-rg --runtime "dotnet:8"
# or, in CI/CD: azure/webapps-deploy@v3 with a staging slot, then swap

AKS (Azure Kubernetes Service)

Managed Kubernetes. Azure runs the control plane, and you manage the workloads, node pools, networking, ingress, and upgrades. AKS Automatic is a newer mode that manages more for you (node provisioning, scaling, and defaults), which narrows the gap with simpler platforms.

Strengths:

  • Full Kubernetes ecosystem: Helm, operators, service meshes, GitOps, custom controllers
  • Fine-grained control over scheduling, networking, resource limits, and node types (including GPU or spot nodes)
  • Good for many services, sidecars, and portability across clouds
  • Efficient bin-packing of many workloads onto shared nodes

Limitations:

  • Real operational burden: cluster upgrades, node patching, ingress, certificates, observability, and security hardening
  • Needs Kubernetes expertise on the team
  • You must containerize everything
  • Costs include the nodes (paid whether busy or idle) plus the effort to run the platform

Deploying:

az acr build --registry myacr --image my-api:1.0.0 .
az aks get-credentials --name my-aks --resource-group my-rg
kubectl apply -f deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata: { name: my-api }
spec:
  replicas: 3
  selector: { matchLabels: { app: my-api } }
  template:
    metadata: { labels: { app: my-api } }
    spec:
      containers:
      - name: my-api
        image: myacr.azurecr.io/my-api:1.0.0
        ports: [{ containerPort: 8080 }]
        readinessProbe: { httpGet: { path: /health, port: 8080 } }
        resources:
          requests: { cpu: 250m, memory: 256Mi }
          limits: { memory: 512Mi }

You'd also add a Service and an Ingress (for example, Application Gateway for Containers or NGINX), use workload identity for Azure access, and roll out with Helm or Flux/Argo CD.

Side-by-side

App Service AKS
Model PaaS, you deploy an app Managed Kubernetes, you run workloads
Packaging Code or container Container (required)
Operational effort Low High
Learning curve Gentle Steep
Scaling Autoscale rules, per plan Pod autoscaling (HPA/KEDA) plus node autoscaling
Zero-downtime release Deployment slots Rolling updates, canary, blue/green via ingress or mesh
Networking control Moderate (VNet integration, private endpoints) Very high (CNI options, network policies, service mesh)
Multi-service / microservices Possible, one plan hosting many apps Native fit
Cost model Pay per plan Pay for nodes, plus platform effort
Best for Typical APIs and web apps Large, complex, multi-service platforms

Other options worth considering

  • Azure Container Apps: serverless containers built on Kubernetes (with KEDA and Dapr) but without cluster management. Supports scale-to-zero, revisions with traffic splitting, and event-driven scaling. For many teams it's the best middle ground between App Service and AKS.
  • Azure Functions: for small, event-driven, or bursty APIs where per-execution billing and scale-to-zero make sense.
  • App Service Environment (ASE v3): single-tenant, VNet-injected App Service for strict isolation and compliance needs.
  • Azure Container Instances: simple, short-lived containers. Rarely the right choice for a production API on its own.
  • Virtual Machines: only if you need OS-level control or have legacy requirements.

How to choose

  • A single API or a handful, a small or mid-sized team, and you want to ship quickly → App Service
  • Containerized microservices, but you don't want to run Kubernetes → Container Apps
  • Spiky or event-driven workloads, minimal API surface → Functions
  • Dozens of services, a platform team, a need for service mesh, operators, custom scheduling, or multi-cloud portability → AKS
  • Strict network isolation or compliance requirements on a PaaS model → App Service Environment

A practical rule: start with App Service (or Container Apps) and move to AKS only when you hit a concrete limit. Teams often adopt AKS for the flexibility they imagine they'll need, then spend a lot of effort on cluster operations that App Service would have handled. Because both can run the same container image, moving later is less painful if you containerize from the start.

Cross-cutting concerns (the same on all of them)

  • Use managed identity and Key Vault instead of stored credentials.
  • Put APIM, Front Door, or Application Gateway in front for routing, WAF, and rate limiting.
  • Send telemetry to Application Insights and define health checks and alerts.
  • Deploy through CI/CD with infrastructure as code (Bicep or Terraform).


Tuesday, 3 November 2026

How do you implement CI/CD for a .NET application in Azure?

CI/CD for a .NET Application in Azure

CI (continuous integration) builds and tests every change automatically. CD (continuous delivery/deployment) ships the tested build through environments to production in a repeatable, low-risk way. The two common tool choices are GitHub Actions and Azure Pipelines (Azure DevOps). The structure is the same in both.

The pipeline shape

Commit/PR ─► Build ─► Test ─► Publish artifact ─► Deploy Dev ─► Deploy Staging ─► (approval) ─► Production
                                  (build once)                    (slot / test)                  (swap)

Principles:

  • Build once, deploy the same artifact everywhere. Don't rebuild per environment. Differences come from configuration, not code.
  • Run tests on every PR and block merges when they fail (branch protection).
  • No stored credentials. Authenticate to Azure with OIDC (workload identity federation).
  • Gate production with approvals and automated checks.

Option 1: GitHub Actions

One-time setup: passwordless Azure login

Create an app registration (or user-assigned managed identity), add a federated credential trusting your repo, and grant it a role on the target scope.

# App registration + service principal
az ad app create --display-name gh-deploy-my-app
az ad sp create --id <appId>

# Trust GitHub Actions for the main branch
az ad app federated-credential create --id <appId> --parameters '{
  "name": "gh-main",
  "issuer": "https://token.actions.githubusercontent.com",
  "subject": "repo:my-org/my-repo:environment:production",
  "audiences": ["api://AzureADTokenExchange"]
}'

# Least-privilege role at resource group scope
az role assignment create --assignee <appId> --role "Website Contributor" \
  --scope /subscriptions/<sub-id>/resourceGroups/my-rg

The subject must match how the workflow runs. For jobs using a GitHub environment, use environment:<name>. For branch-based runs, use ref:refs/heads/main. You need one federated credential per subject.

Store AZURE_CLIENT_ID, AZURE_TENANT_ID, and AZURE_SUBSCRIPTION_ID as repository secrets or variables. They're identifiers, not credentials.

Workflow

name: ci-cd

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

permissions:
  id-token: write   # needed for OIDC
  contents: read

jobs:
  build-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '8.0.x'

      - run: dotnet restore
      - run: dotnet build --no-restore -c Release
      - run: dotnet test --no-build -c Release --logger trx --collect:"XPlat Code Coverage"

      - run: dotnet publish src/MyApi/MyApi.csproj -c Release -o ./publish --no-build

      - uses: actions/upload-artifact@v4
        with:
          name: api
          path: ./publish

  deploy-staging:
    needs: build-test
    if: github.ref == 'refs/heads/main'   # PRs build and test only
    runs-on: ubuntu-latest
    environment: staging
    steps:
      - uses: actions/download-artifact@v4
        with: { name: api, path: ./publish }

      - uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

      - uses: azure/webapps-deploy@v3
        with:
          app-name: my-api
          slot-name: staging
          package: ./publish

      - name: Smoke test
        run: curl --fail --retry 5 --retry-delay 5 https://my-api-staging.azurewebsites.net/health

  deploy-production:
    needs: deploy-staging
    runs-on: ubuntu-latest
    environment: production        # configure required reviewers on this environment
    steps:
      - uses: azure/login@v2
        with:
          client-id: ${{ secrets.AZURE_CLIENT_ID }}
          tenant-id: ${{ secrets.AZURE_TENANT_ID }}
          subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}

      - name: Swap staging into production
        run: |
          az webapp deployment slot swap \
            --name my-api --resource-group my-rg \
            --slot staging --target-slot production

Notes:

  • GitHub Environments (staging, production) give you required reviewers, wait timers, and environment-scoped secrets.
  • Deployment slots require Standard tier or above. Slot swaps warm up the staging instance first, so production sees no cold start.
  • To roll back, swap again. The previous version is still sitting in the staging slot.

Option 2: Azure Pipelines (YAML)

Create a service connection to Azure using workload identity federation (the recommended option over secret-based connections).

trigger:
  branches:
    include: [main]

pool:
  vmImage: ubuntu-latest

variables:
  buildConfiguration: Release

stages:
- stage: Build
  jobs:
  - job: BuildTest
    steps:
    - task: UseDotNet@2
      inputs:
        packageType: sdk
        version: 8.0.x

    - script: dotnet restore
    - script: dotnet build --no-restore -c $(buildConfiguration)

    - task: DotNetCoreCLI@2
      displayName: Test
      inputs:
        command: test
        projects: '**/*Tests.csproj'
        arguments: '-c $(buildConfiguration) --no-build'

    - task: DotNetCoreCLI@2
      displayName: Publish
      inputs:
        command: publish
        publishWebProjects: true
        arguments: '-c $(buildConfiguration) -o $(Build.ArtifactStagingDirectory)'
        zipAfterPublish: true

    - publish: $(Build.ArtifactStagingDirectory)
      artifact: drop

- stage: Staging
  dependsOn: Build
  condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
  jobs:
  - deployment: DeployStaging
    environment: staging
    strategy:
      runOnce:
        deploy:
          steps:
          - task: AzureWebApp@1
            inputs:
              azureSubscription: my-service-connection
              appType: webAppLinux
              appName: my-api
              deployToSlotOrASE: true
              resourceGroupName: my-rg
              slotName: staging
              package: $(Pipeline.Workspace)/drop/**/*.zip

- stage: Production
  dependsOn: Staging
  jobs:
  - deployment: DeployProd
    environment: production      # add Approvals and checks on this environment
    strategy:
      runOnce:
        deploy:
          steps:
          - task: AzureAppServiceManage@0
            inputs:
              azureSubscription: my-service-connection
              action: Swap Slots
              webAppName: my-api
              resourceGroupName: my-rg
              sourceSlot: staging
              targetSlot: production

Azure DevOps Environments provide approvals, checks, and deployment history, the same role that GitHub Environments play.

Infrastructure as Code

Don't create Azure resources by hand. Define them in Bicep (or Terraform) and deploy them through the pipeline, so environments stay consistent and reviewable.

- uses: azure/arm-deploy@v2
  with:
    scope: resourcegroup
    resourceGroupName: my-rg
    template: ./infra/main.bicep
    parameters: ./infra/prod.bicepparam
    failOnStdErr: false

A common pattern is to deploy infrastructure first and the app second. Use what-if on pull requests to preview infrastructure changes before merging.

Database migrations

Don't let the app run migrations on startup in production. Prefer an explicit pipeline step:

# Idempotent SQL script, reviewable and safe to re-run
dotnet ef migrations script --idempotent -o migrate.sql \
  --project src/MyApi --startup-project src/MyApi

# Or a self-contained migration bundle
dotnet ef migrations bundle -o efbundle --project src/MyApi
./efbundle --connection "<connection-string>"

Authenticate to SQL with the pipeline's Entra identity rather than a password. Write migrations in an expand/contract style (add new columns and tables first, remove old ones later) so the previous version keeps working during a slot swap or rollback.

Configuration and secrets per environment

  • Keep non-secret settings in app settings, applied per slot or environment. Mark settings that must not move during a swap as slot settings (sticky).
  • Store secrets in Key Vault and use Key Vault references. Give the app a managed identity to read them.
  • Never put secrets in the repo or the build artifact. Use environment-scoped secrets only for pipeline identifiers.
  • Use Azure App Configuration with feature flags if you want to release code dark and enable features separately.

Containerized apps

If you deploy containers (Container Apps, AKS, App Service for Containers):

- run: |
    az acr login --name myacr
    docker build -t myacr.azurecr.io/my-api:${{ github.sha }} .
    docker push myacr.azurecr.io/my-api:${{ github.sha }}

- run: |
    az containerapp update --name my-api --resource-group my-rg \
      --image myacr.azurecr.io/my-api:${{ github.sha }}

Tag images with the commit SHA (never rely on latest), and give the app a managed identity with the AcrPull role. Container Apps revisions let you shift traffic gradually (blue/green or canary). For AKS, use Helm or GitOps (Flux/Argo CD).

Quality and safety gates

Add these as the pipeline matures:

  • Static analysis and security: Roslyn analyzers, dotnet list package --vulnerable, Dependabot or Renovate, CodeQL, secret scanning, and container image scanning (Defender for Cloud).
  • Integration tests against ephemeral resources (Testcontainers or a dedicated test environment).
  • Post-deploy smoke tests and health checks, which block the swap if they fail.
  • Load or performance checks on staging for critical paths.
  • Automated rollback if post-release metrics or alerts degrade.
  • Branch protection: required reviews, required status checks, and no direct pushes to main.

Good practices

  • Keep pipelines in the repo (YAML), versioned and reviewed like code.
  • Cache NuGet packages and use --no-restore and --no-build flags to speed up runs.
  • Use reusable workflows or templates to share logic across services.
  • Separate subscriptions or resource groups per environment, and use separate identities with least privilege for each.
  • Version your builds (for example, with MinVer or the commit SHA) and surface the version in a /health or /version endpoint, so you can confirm what's running.
  • Mark deployments in Application Insights (release annotations) so you can correlate regressions with releases.
  • Prefer small, frequent releases over large batches. They're easier to test and roll back.

Minimal starting point:

  1. Create a workflow that restores, builds, and tests on every PR.
  2. Add OIDC-based Azure login and deploy to a staging slot on merge to main.
  3. Add a smoke test and a manual approval on a production environment, then swap slots.
  4. Move infrastructure into Bicep and deploy it from the pipeline.
  5. Add security scanning, migrations as a controlled step, and alerts tied to releases.


Find employees who have the same salary as their manager.

To find employees who earn the exact same salary as their manager, you need to perform a Self-Join. This means you join the employees table to itself—treating one instance as the "Employee table" and the second instance as the "Manager table."
The Query
The join links the employee's manager_id to the manager's employee_id, and the WHERE clause filters for matching salary values:
sql
SELECT 
    e.employee_id AS emp_id,
    e.employee_name AS emp_name,
    e.salary AS emp_salary,
    m.employee_name AS manager_name,
    m.salary AS manager_salary
FROM employees e
INNER JOIN employees m ON e.manager_id = m.employee_id
WHERE e.salary = m.salary;
Use code with caution.
How It Works
  • employees e: Represents the table context looking down at the regular employees.
  • employees m: Represents the table context looking up at the managers.
  • ON e.manager_id = m.employee_id: Establishes the relationship path between the employee and their boss.
  • WHERE e.salary = m.salary: Isolates the records where both parties make the exact same amount of money.

Complete Playground Script (Schema + Test Data)
Copy and execute this script to test the logic live:
sql
-- 1. CREATE TABLE SCHEMA
CREATE TABLE employees (
    employee_id INT PRIMARY KEY,
    employee_name VARCHAR(100),
    salary DECIMAL(10, 2),
    manager_id INT,
    FOREIGN KEY (manager_id) REFERENCES employees(employee_id)
);

-- 2. INSERT SAMPLE DATA
-- Top levels (No managers)
INSERT INTO employees (employee_id, employee_name, salary, manager_id) VALUES
(1, 'Fiona Gallagher', 120000.00, NULL), -- CEO
(2, 'Alice Smith', 95000.00, NULL);     -- VP

-- Reports
INSERT INTO employees (employee_id, employee_name, salary, manager_id) VALUES
(3, 'Bob Jones', 75000.00, 1),      -- Reports to Fiona (Diff Salary)
(4, 'Charlie Brown', 95000.00, 2),  -- Reports to Alice (SAME Salary - Matches!)
(5, 'Diana Prince', 95000.00, 2),   -- Reports to Alice (SAME Salary - Matches!)
(6, 'Evan Wright', 60000.00, 3);    -- Reports to Bob (Diff Salary)

-- 3. EXECUTE THE SELF-JOIN QUERY
SELECT 
    e.employee_name AS employee_name,
    e.salary AS employee_salary,
    m.employee_name AS manager_name
FROM employees e
INNER JOIN employees m ON e.manager_id = m.employee_id
WHERE e.salary = m.salary;
Use code with caution.
Expected Query Output
employee_nameemployee_salarymanager_name
Charlie Brown95000.00Alice Smith
Diana Prince95000.00Alice Smith