Container Apps and AKS solve how to run services that are always available. But a good part of what Contoso Airlines does is not like that. Generating the PDF of a boarding pass takes two seconds and happens when a passenger confirms their booking. Synchronizing the fare catalog happens when a document changes in cosmos-contoso-tarifas-pro. Between one event and the next there is nothing to do, and yet today Contoso pays for a container sitting there waiting.

Azure Functions is the answer: you write a function, you declare what fires it, and Azure takes care of running it when it is due and of it not existing the rest of the time. This lesson goes beyond "hello world": the triggers and bindings model, the hosting plans with their real trade-offs, Contoso's two production functions, orchestration with Durable Functions and the hardest lesson of the model, which is idempotency.

Contents

  1. What serverless computing is and what you pay for
  2. Triggers and bindings: the mental model
  3. Hosting plans compared
  4. Creating the project and running it locally
  5. GenerarTarjetaEmbarque: the queue function
  6. SincronizarTarifas: the Cosmos DB change feed
  7. Configuration, managed identity and Key Vault
  8. Durable Functions and the check-in flow
  9. Idempotency, retries, timeouts and limits
  10. Testing, deployment and monitoring
  11. When NOT to use functions
  12. Common Mistakes and Tips
  13. Exercises
  14. Conclusion

  1. What serverless computing is and what you pay for

"Serverless" does not mean there are no servers; it means they are not your problem. You do not pick a machine size, you do not patch, you do not size anything: you write a unit of code and the platform runs it and scales it.

What you pay for on the Consumption plan is three things: the number of executions, the gigabyte-second consumption (memory allocated multiplied by execution time) and outbound traffic. There is a generous monthly free grant, and two practical consequences. The first: a function that does not run costs nothing, and that is the saving compared with the container left switched on. The second, less intuitive: cost is proportional to execution time, so a slow function costs more, and a function that sits blocked waiting on an external HTTP call bills you for waiting.

  1. Triggers and bindings: the mental model

Here is the concept you need to internalize. A function has exactly one trigger — what decides when it runs — and zero or more bindings, input and output, which are declarative connections to other services. The platform takes care of the connection, the authentication, the deserialization and the retries; you receive objects and return objects.

Trigger Fires when Use at Contoso
HTTP A request arrives Lightweight API, payment gateway webhooks
Timer A cron expression comes due Nightly purge of expired bookings
Blob A blob is created or modified Processing an uploaded file
Storage queue A message appears cola-emision-tarjetas
Service Bus A message reaches a queue or subscription Confirmed booking events (06-05)
Event Grid An event is published New blob in sttarjetascontosopro
Cosmos DB A document changes (change feed) Fare catalog

What makes the model valuable is the bindings. Compare:

// WITHOUT bindings: plumbing you have to write, test and maintain
var credential = new DefaultAzureCredential();
var client = new BlobServiceClient(
    new Uri("https://sttarjetascontosopro.blob.core.windows.net"), credential);
var container = client.GetBlobContainerClient("tarjetas-embarque");
await container.CreateIfNotExistsAsync();
await container.GetBlobClient(name).UploadAsync(stream, overwrite: true);

// WITH an output binding: you declare it, and that is that
[BlobOutput("tarjetas-embarque/{name}.pdf", Connection = "AlmacenTarjetas")]
public byte[] Pdf { get; set; }

Six lines of plumbing replaced by one declaration. And it is not only brevity: the connection, the retries and the authentication with a managed identity are all handled by the runtime.

  1. Hosting plans compared

Consumption Flex Consumption Premium App Service
Billing Per execution and GB-s Per execution and GB-s Always-on instances App Service plan
Cold start Yes, noticeable Reduced; always-ready instances No No
Maximum scale 200 instances High, configurable 100 instances Manual or automatic
Virtual network integration No Yes Yes Yes
Maximum duration 5 min (up to 10) Configurable Unlimited Unlimited
Cost with no traffic Zero Almost zero High (minimum instance) The plan's

What Contoso chooses and why:

  • GenerarTarjetaEmbarque goes on Flex Consumption: it needs a virtual network to reach pe-storage-tarjetas and pe-sql-reservas over a private endpoint, which classic Consumption does not allow, and its load is bursty, with almost zero consumption in the small hours.
  • SincronizarTarifas also goes on Flex Consumption and in the same app: it shares networking and configuration, and its volume is low.
  • The check-in orchestration with Durable Functions goes on Premium. Its orchestrations run for hours — they wait for the passenger to upload a document — and there both the cold start and the duration limit matter.
  • Nothing goes on the App Service plan, unless they wanted to reuse plan-contoso-api-pro to take advantage of capacity already paid for.

  1. Creating the project and running it locally

npm install -g azure-functions-core-tools@4 --unsafe-perm true   # the host, on your machine
func init ContosoFunciones --worker-runtime dotnet-isolated
cd ContosoFunciones
func new --name GenerarTarjetaEmbarque --template "Queue trigger"
func start          # runs the functions locally, with debugging

The isolated model (dotnet-isolated) is the current one: the function runs in its own process, decoupled from the host version. Local configuration values go in local.settings.json, which is never committed to the repository — it is in .gitignore for a reason; in Azure, those same names are read from the app settings.

  1. GenerarTarjetaEmbarque: the queue function

When the passenger completes check-in, app-contoso-reservas-pro enqueues a message on cola-emision-tarjetas in stoperacionescontosopro. This function consumes it, generates the PDF and drops it into the tarjetas-embarque container of sttarjetascontosopro.

public record BoardingPassRequest(string Localizador, string FlightNumber,
                                  string Passenger, string Seat, DateTime Departure);

public class GenerarTarjetaEmbarque
{
    private readonly ILogger<GenerarTarjetaEmbarque> _log;
    private readonly IPdfGenerator _pdf;
    public GenerarTarjetaEmbarque(ILogger<GenerarTarjetaEmbarque> l, IPdfGenerator p)
        => (_log, _pdf) = (l, p);

    [Function(nameof(GenerarTarjetaEmbarque))]
    [BlobOutput("tarjetas-embarque/{Localizador}-{Seat}.pdf", Connection = "AlmacenTarjetas")]
    public async Task<byte[]> Run(
        [QueueTrigger("cola-emision-tarjetas", Connection = "AlmacenOperaciones")]
        BoardingPassRequest request,
        FunctionContext context)
    {
        _log.LogInformation("Issuing boarding pass {Loc} flight {Flight}",
            request.Localizador, request.FlightNumber);

        // Idempotency: if the blob already exists, this message was already processed (section 9)
        if (await _pdf.AlreadyExistsAsync(request.Localizador, request.Seat))
        {
            _log.LogInformation("Boarding pass {Loc} already issued; skipping", request.Localizador);
            return null;   // returning null does NOT write the blob
        }

        return await _pdf.GenerateAsync(request);
    }
}

What you need to understand about this code:

  • [QueueTrigger] declares the trigger. The runtime polls the queue, deserializes the message JSON straight into BoardingPassRequest and hands it to you typed. If deserialization fails, the message goes to the poison queue without your code ever running.
  • Connection = "AlmacenOperaciones" is not a connection string: it is the prefix of a configuration setting. With AlmacenOperaciones__queueServiceUri pointing at the account and the managed identity assigned, there is no key at all. This is the point most often got wrong.
  • [BlobOutput] with the {Localizador}-{Seat}.pdf template: the incoming message's fields are interpolated into the blob path. The file name is deterministic, and the idempotency check falls out of that for free.
  • Returning null does not write the blob, the clean way of bailing out without throwing an exception. And dependency injection works as it does in any .NET application: the constructor receives the logger and the PDF generator, which makes the function testable with ordinary unit tests.

  1. SincronizarTarifas: the Cosmos DB change feed

When the commercial team updates a fare in the tarifas container of cosmos-contoso-tarifas-pro, the change feed you already know from 03-03 delivers the modified document to this function, which refreshes the search index and notifies the availability engine's cache.

public class SincronizarTarifas
{
    private readonly ILogger<SincronizarTarifas> _log;
    private readonly IFareIndex _index;
    public SincronizarTarifas(ILogger<SincronizarTarifas> l, IFareIndex i)
        => (_log, _index) = (l, i);

    [Function(nameof(SincronizarTarifas))]
    public async Task Run(
        [CosmosDBTrigger(
            databaseName: "catalogo",
            containerName: "tarifas",
            Connection = "CosmosTarifas",
            LeaseContainerName = "arrendamientos",
            CreateLeaseContainerIfNotExists = true)]
        IReadOnlyList<Fare> changes)
    {
        foreach (var fare in changes)
        {
            // Upsert by key: processing it twice leaves the same result
            await _index.UpsertAsync(fare);
            _log.LogInformation("Fare {Id} for route {Route} synchronized",
                fare.Id, fare.OrigenDestino);
        }
    }
}

The lease container deserves attention: it is where the trigger records how far it has read in each partition. It lives in the same database, consumes its own RU/s and, if you delete it, the function reprocesses from the beginning. The function receives batches of changes, not individual documents, and the partition key /origenDestino determines the parallelism: each partition is processed in order and independently.

  1. Configuration, managed identity and Key Vault

# The function app, on Flex Consumption and integrated into the virtual network
az functionapp create \
  --resource-group rg-contoso-reservas-pro --name func-contoso-tarjetas-pro \
  --storage-account stoperacionescontosopro --flexconsumption-location westeurope \
  --runtime dotnet-isolated --runtime-version 8.0 --vnet vnet-contoso-pro --subnet snet-integracion-app \
  --tags entorno=produccion proyecto=contoso-reservas \
         centro-coste=CC-1042 [email protected]

# User-assigned managed identity, the usual one
az functionapp identity assign -g rg-contoso-reservas-pro -n func-contoso-tarjetas-pro \
  --identities $(az identity show -g rg-contoso-seguridad-pro \
      -n id-contoso-api-pro --query id -o tsv)

# IDENTITY-BASED connections: a service name, not a key
az functionapp config appsettings set -g rg-contoso-reservas-pro \
  -n func-contoso-tarjetas-pro --settings \
  "AlmacenOperaciones__queueServiceUri=https://stoperacionescontosopro.queue.core.windows.net" \
  "AlmacenTarjetas__blobServiceUri=https://sttarjetascontosopro.blob.core.windows.net" \
  "[email protected](SecretUri=https://kv-contoso-pro.vault.azure.net/secrets/clave-pasarela/)"

Three ideas: connections with the __queueServiceUri / __blobServiceUri suffix switch on identity-based mode, with no keys; the Key Vault reference with the @Microsoft.KeyVault(...) syntax from 04-03 resolves the secret at runtime and the value is never visible in the portal; and the identity needs its roles — Storage Queue Data Contributor, Storage Blob Data Contributor and Key Vault Secrets User.

  1. Durable Functions and the check-in flow

Normal functions are stateless and short-lived. What if the process has several steps, takes hours and you need to know how far it got? Durable Functions adds durable state and resumption: an orchestrator describes the flow with ordinary code, and its execution is persisted automatically at every waiting point.

sequenceDiagram
  participant P as Passenger
  participant O as OrquestadorCheckIn
  participant A as Activities
  P->>O: Starts check-in (record locator)
  O->>A: ValidarReserva
  A-->>O: Booking valid
  par Fan-out
    O->>A: ComprobarDocumentacion
    O->>A: AsignarAsiento
    O->>A: CalcularEquipaje
  end
  A-->>O: Fan-in: results
  O->>P: Requests a passport photo
  Note over O: Waits for an external event (up to 24 h)
  P-->>O: DocumentoSubido
  O->>A: EmitirTarjeta -> cola-emision-tarjetas

The three patterns, applied:

  • Function chaining: ValidarReserva → EmitirTarjeta. Each step uses the previous one's output; if the process crashes at the third step, on resumption it does not repeat the first two.
  • Fan-out / fan-in: documentation, seat and baggage are independent; they are launched in parallel and awaited together. It cuts the total time down to that of the slowest step.
  • Waiting for an external event: the orchestrator stops at WaitForExternalEvent("DocumentoSubido") with a 24-hour timeout. While it waits it consumes no compute and bills nothing; that is what makes solving this with a normal function unworkable, since those are limited to minutes.
[Function(nameof(OrquestadorCheckIn))]
public static async Task<CheckInResult> Run(
    [OrchestrationTrigger] TaskOrchestrationContext context)
{
    var loc = context.GetInput<string>();
    var booking = await context.CallActivityAsync<Booking>("ValidarReserva", loc);

    // Fan-out: three activities in parallel
    await Task.WhenAll(                              // ...and fan-in when awaiting them
        context.CallActivityAsync<object>("ComprobarDocumentacion", booking),
        context.CallActivityAsync<object>("AsignarAsiento", booking),
        context.CallActivityAsync<object>("CalcularEquipaje", booking));

    // Human wait: consumes no compute in the meantime
    using var cts = new CancellationTokenSource();
    var uploaded = context.WaitForExternalEvent<string>("DocumentoSubido");
    var deadline = context.CreateTimer(context.CurrentUtcDateTime.AddHours(24), cts.Token);
    if (uploaded == await Task.WhenAny(uploaded, deadline)) cts.Cancel();
    else return new CheckInResult(loc, "Expired");

    await context.CallActivityAsync("EmitirTarjeta", booking);
    return new CheckInResult(loc, "Completed");
}

The orchestrator's golden rule: its code must be deterministic. No DateTime.Now, no Guid.NewGuid() and no direct I/O calls, because the orchestrator re-executes from the beginning every time it resumes, replaying its history. Use context.CurrentUtcDateTime and delegate everything non-deterministic to activities.

  1. Idempotency, retries, timeouts and limits

This is the lesson almost nobody mentions at the start and everybody learns in production: queues guarantee at-least-once delivery, not exactly-once. If the function crashes after generating the PDF but before acknowledging the message, the message reappears and the function runs again with the same data. With no protection, the passenger receives two boarding passes and the accounting record no longer balances.

How to protect yourself, in order of preference:

Strategy How Application at Contoso
Naturally idempotent operation Write with a deterministic key, upsert instead of insert SincronizarTarifas: the upsert by identifier
Check first See whether the result already exists before doing the work GenerarTarjetaEmbarque: the {Localizador}-{Seat}.pdf blob
Processed-message log A table holding the message identifier registroembarques in stoperacionescontosopro

On retries: the queue retries automatically (five times by default) and then sends the message to the poison queue. A message that always fails — malformed JSON, a nonexistent flight — is called a poison message, and with no poison queue it would block the queue indefinitely. Watch that queue with an alert: if it grows, something is broken.

On timeouts and limits: 5 minutes by default on Consumption (extendable to 10), configurable on Flex and unlimited on Premium, with the value in functionTimeout in host.json. A large queue message does not fit (64 KB), so send references, not payloads: the blob identifier, not the PDF. And maxConcurrentCalls limits the parallelism, which has to be lowered when the function writes to db-reservas, because a thousand instances opening connections exhaust the database's pool.

  1. Testing, deployment and monitoring

The logic lives in injected classes — IPdfGenerator, IFareIndex — so it is tested with ordinary unit tests; the function merely orchestrates. For integration, func start with the Storage emulator. Deployment reuses the 05-04 pipeline, with slots: you deploy to preproduccion, warm it up and swap.

- task: AzureFunctionApp@2
  inputs:
    azureSubscription: sc-contoso-pro
    appType: functionApp
    appName: func-contoso-tarjetas-pro
    deployToSlotOrASE: true
    slotName: preproduccion
    package: $(Pipeline.Workspace)/artefacto/funciones.zip
- task: AzureAppServiceManage@0                    # and then the swap
  inputs:
    azureSubscription: sc-contoso-pro
    Action: 'Swap Slots'
    WebAppName: func-contoso-tarjetas-pro
    SourceSlot: preproduccion

Application Insights comes built in and is where you see everything: successful and failed executions, duration, dependency telemetry and the end-to-end trace from the queue to the blob. Exploiting it is 07-03; here it is enough to know that it is enabled when you create the app and that without it a failing function is a black box.

  1. When NOT to use functions

  • Long-running processes with in-memory state: use Durable Functions or a container. And constant, heavy load: if the code runs non-stop, a fixed plan or Container Apps works out cheaper than paying per execution.
  • Guaranteed minimum latency on the first request: the Consumption plan's cold start is not acceptable on the purchase path.
  • Applications with a lot of shared logic and a joint deployment: if the "functions" are a monolith sliced up that is always deployed together, it is an API, and its place is App Service or Container Apps. The same goes for persistent connections or heavy startup dependencies: every new instance pays that startup, and with aggressive scaling the target suffers.

Common Mistakes and Tips

  • Not making the function idempotent. It shows up as sporadic duplicates that are impossible to reproduce. Design for it from day one.
  • Using key-based connection strings in the Connection settings. Use the __blobServiceUri suffix with a managed identity.
  • Putting the full payload in the message. There is a 64 KB limit on Storage queues: send the reference.
  • Writing non-deterministic code in an orchestrator. DateTime.Now or Guid.NewGuid() produce inconsistent results on resumption.
  • Ignoring the poison queue, where poison messages end up; if nobody looks at it, the errors are invisible. And not limiting concurrency when the function writes to db-reservas: a thousand instances exhaust the connection pool.
  • Committing local.settings.json. It contains local credentials. Check for it in the 05-02 review.
  • Tip: measure duration in Application Insights; bringing a function down from 4 s to 1.5 s cuts the Consumption bill by almost the same proportion. And one function, one responsibility: if it has three logically distinct triggers, it is three functions.

Exercises

Exercise 1. Contoso Miles needs a function that, when a passenger completes a flight, adds the corresponding miles to their profile in cosmos-contoso-tarifas-pro. The events arrive on cola-vuelos-completados.

  1. Choose the trigger, the bindings and the hosting plan, and justify your choice.
  2. On the first day in production, some passengers show up with duplicated miles. Explain the exact cause, give two ways to fix it, and state the resource tags and the alert you would set up from day one.

Exercise 2. Design the ticket refund process with Durable Functions: validate the request, check the penalty and the gateway balance in parallel, wait for a supervisor's approval (72 hours maximum) and, if approved, issue the credit and notify.

  1. State which pattern covers each stretch and why it cannot be solved with a normal function on the Consumption plan.
  2. Write the supervisor wait with its expiry in pseudocode and say what happens if the orchestrator restarts while it is waiting.

Exercise 3. An HTTP-triggered function queries db-reservas and responds in 200 ms when traffic is light, but when seats go on sale it returns database timeout errors.

  1. Explain the mechanism of the failure.
  2. Propose three corrective measures.

Solutions

Solution 1:

  1. A Storage queue trigger on cola-vuelos-completados, with a Cosmos DB output binding to the perfiles container. Flex Consumption plan: bursty volume concentrated around flight landings, almost zero cost in the small hours, and it needs a virtual network to reach Cosmos DB's private endpoint.
  2. The cause is at-least-once delivery: if the function adds the miles and crashes before acknowledging the message, the message reappears and the miles are added again. Adding is the non-idempotent operation par excellence. Fix (a): store in the profile document the set of flight identifiers already credited and discard the event if it is already there — a check performed inside the same write. Fix (b): record the message identifier in the registroembarques table before adding, and discard if it already exists. (a) is preferable because it adds neither another store nor another failure window. Tags: entorno, proyecto=contoso-millas, centro-coste=CC-2077 and propietario. The day-one alert is on the poison queue length: as soon as it grows, there are poison messages and passengers without their miles.

Solution 2:

  1. Validate → check → credit → notify is chaining; penalty and balance in parallel are fan-out / fan-in; the supervisor's approval is waiting for an external event. It does not fit in a normal function because the wait lasts up to 72 hours and the Consumption plan cuts off at 5 or 10 minutes; on top of that you would need to persist the exact point in the process, and a normal function is stateless.
  2. var approval = context.WaitForExternalEvent<Decision>("AprobacionSupervisor"); alongside var deadline = context.CreateTimer(context.CurrentUtcDateTime.AddHours(72), cts.Token); and a Task.WhenAny over both; if the deadline wins it is rejected as expired, and if the event wins the timer is cancelled. If the orchestrator restarts while waiting, nothing happens: the instance is not occupying compute, its history is persisted and on resumption it replays up to the waiting point. That is exactly the property the durable model provides.

Solution 3:

  1. The function scales out to dozens or hundreds of parallel instances, and each instance opens its own connections to db-reservas. The server's connection pool is exhausted, requests queue up and eventually time out. The symptom appears in the database, but the cause is the function's uncontrolled scaling.
  2. (a) Limit concurrency in host.json and set functionAppScaleLimit to a ceiling compatible with the database. (b) Reuse the database client as a static or singleton instead of creating one per invocation, so that connections are pooled. (c) Take the synchrony out of the path: enqueue the request and respond immediately, or put a cache in front for repeated queries. As a reinforcement, review the tier of sql-contoso-reservas-pro (03-02), although scaling the database without fixing the concurrency only moves the limit.

Conclusion

You now know what "serverless" really means: there is no machine to choose or patch, you pay for executions and gigabyte-seconds, an idle function costs zero and a slow function costs more. You have internalized the mental model of triggers and bindings — one trigger that decides when, input and output bindings that remove the plumbing of connecting, authenticating and retrying — and you know the most-used ones and how they fit at Contoso. You know how to compare the hosting plans on cold start, scaling, virtual network, duration and cost, and why Contoso's functions go on Flex Consumption while the check-in orchestration needs Premium.

You have written the two real functions: GenerarTarjetaEmbarque, fired by cola-emision-tarjetas, with an output binding to the tarjetas-embarque container through a deterministic name template that hands you idempotency for free; and SincronizarTarifas, fed by the change feed of cosmos-contoso-tarifas-pro with its lease container and its batch processing. You have configured them with identity-based connections (__blobServiceUri, __queueServiceUri) and references to kv-contoso-pro, without a single key. With Durable Functions you have orchestrated check-in applying chaining, fan-out/fan-in and waiting for an external event for 24 hours without consuming compute, respecting the deterministic orchestrator rule. And you take away the hard lesson: at-least-once, not exactly-once, with its three idempotency strategies, the poison queue that has to be watched, the timeouts, the 64 KB limit and the concurrency that has to be reined in when there is a database behind it. You know how to deploy with slots from the pipeline, monitor with Application Insights (07-03) and, above all, when not to use functions.

One awkward piece remains. When a flight is delayed, Contoso has to find the affected passengers in db-reservas, notify them by email and by text message, post in the operations team's channel and open an incident in the support system. None of that is complex business logic: it is gluing systems together, and writing it as code means maintaining four integrations by hand with their authentication, their retries and their formats. Besides, the person who knows that process is not Diego, it is the operations team, and they do not code. The next lesson, Azure Logic Apps, is exactly for that: visual workflows over a catalog of more than a thousand connectors, with their versionable JSON definition, their flow control, their error handling and their run history, and with the criteria for knowing when a logic app is called for, when a function and when Data Factory.

Azure Course

Module 1: Introduction to Azure

Module 2: Core Azure Services

Module 3: Azure Databases

Module 4: Security in Azure

Module 5: Azure DevOps

Module 6: Advanced Azure Services

Module 7: Monitoring and Management

Module 8: Cost Management and Optimization

Module 9: Case Studies and Best Practices

© Copyright 2026. All rights reserved