Module 5: Aspire, Containers, and Shipping

3:45 – 4:40 PM · Concepts 20 min · Lab 30 min

Present this module

Learning objectives

By the end of this module you can add a .NET Aspire AppHost to orchestrate the API and its dependencies locally, see the day's telemetry in the dashboard, build a container image without a Dockerfile, version the API, and describe the path to a cloud deployment.

What Aspire is, and is not

.NET Aspire is three things for local development: an orchestrator that starts your projects and their dependencies (databases, caches, message brokers) together; service discovery so the API finds the payment service by name instead of a hard-coded port; and a dashboard that shows logs, traces, and metrics from everything it started.

It is not a deployment target. It produces a manifest that tools such as azd turn into real infrastructure, but Aspire itself runs on your laptop.

flowchart TB
    AH[Roastery.AppHost] --> API[Roastery.Api]
    AH --> PAY[Roastery.Payments]
    AH -.->|stretch| PG[(Postgres container)]
    API -->|service discovery| PAY
    API --> SD[ServiceDefaults\nOTel · health · resilience]
    PAY --> SD
    AH --> DASH[Aspire Dashboard]
    API -.->|OTLP| DASH
    PAY -.->|OTLP| DASH

The two projects Aspire adds

Roastery.AppHost is a tiny program (AppHost.cs) that describes the system:

var builder = DistributedApplication.CreateBuilder(args);

var payments = builder.AddProject<Projects.Roastery_Payments>("payments");

builder.AddProject<Projects.Roastery_Api>("api")
       .WithReference(payments)
       .WithExternalHttpEndpoints();

builder.Build().Run();

WithReference(payments) injects the payment service's address into the API's configuration as services__payments__https__0, and AddServiceDiscovery() teaches HttpClient to resolve https://payments to it. The hard-coded https://localhost:7281 from Lab 4 goes away.

Roastery.ServiceDefaults is a class library with one extension method that every service calls:

builder.AddServiceDefaults();   // OpenTelemetry, health checks, service discovery, resilience defaults
// …
app.MapDefaultEndpoints();      // /health and /alive

This is where the OpenTelemetry block and the health checks from Lab 4 belong. You wrote them by hand so you would know what the template does; now you let the template do it.

The dashboard

When you press F5 on the AppHost, the dashboard opens with every resource, its state, and its endpoints. Place an order and open Traces: the request to /api/orders is a waterfall that includes the EF Core queries, the HttpClient call to payments, the retry spans from the resilience handler when the fake provider fails, and the log entry from OrderReadyNotifier correlated by trace id.

This is the payoff for Module 4. Nothing was added to make this visible; the instrumentation was already there.

A container image with no Dockerfile

The .NET SDK can publish an OCI image directly:

dotnet publish src/Roastery.Api -c Release \
    --os linux --arch x64 /t:PublishContainer \
    -p:ContainerRepository=roastery-api
docker run --rm -p 8080:8080 \
  -e ConnectionStrings__Roastery="Data Source=/data/roastery.db" \
  -e Jwt__SigningKey="$JWT_KEY" \
  -e Payments__BaseUrl="https://host.docker.internal:7281" \
  -v roastery-data:/data \
  roastery-api

What the SDK chose for you: a mcr.microsoft.com/dotnet/aspnet:10.0 base image, a non-root user, port 8080, and a layer per publish output. Every one of those is overridable with an MSBuild property when you need it, and none of them require you to maintain a Dockerfile.

The double-underscore environment variables (Jwt__SigningKey) map to configuration sections (Jwt:SigningKey). That one convention is what makes the same code run under user secrets on a laptop, environment variables in a container, and a key vault in the cloud.

Versioning before the first client

Once a client depends on your API, changing a response shape breaks them. Version from the start, when it costs nothing:

dotnet add package Asp.Versioning.Http
builder.Services.AddApiVersioning(o =>
{
    o.DefaultApiVersion = new ApiVersion(1);
    o.AssumeDefaultVersionWhenUnspecified = true;
    o.ReportApiVersions = true;
})
.AddApiExplorer(o => o.GroupNameFormat = "'v'V");
var versions = app.NewApiVersionSet().HasApiVersion(1).HasApiVersion(2).ReportApiVersions().Build();

var v1 = app.MapGroup("/api/v{version:apiVersion}").WithApiVersionSet(versions).MapToApiVersion(1);
v1.MapProductEndpoints();
v1.MapOrderEndpoints();

The OpenAPI document gets one entry per version, and the existing /api/products URLs become /api/v1/products with no other change to the endpoint code.

The deployment landscape in five minutes

Target How When it fits
Azure Container Apps azd init then azd up; Aspire's manifest drives the provisioning Fastest path from AppHost to a URL
Kubernetes aspirate or the Aspire manifest to generate manifests/Helm You already run a cluster
Any container host The image from PublishContainer plus environment variables Everything else

The instructor plays a recorded azd up rather than running it live; conference Wi-Fi has ended more demos than bad code has. The recording shows the full flow from azd init to hitting the menu endpoint on a public URL, and the repo README has the commands.

Head to the lab

Lab 5: Run It Like Production