Part 3 started two services and two sidecars from a Dapr multi-app run file, one dapr run per app, and that file never leaves your laptop. Aspire replaces it with a C# program that also never leaves your laptop: aspire.dev states that "The AppHost isn't a production runtime. It's a development-time orchestration tool that simplifies the process of running and debugging your application locally." Both artifacts are deleted at the deployment boundary. So the question is what the C# one does in the hours before that boundary that the run file does not. The four projects that answer it are in DaprAspireDemo in azure-functions-samples: an AppHost, a ServiceDefaults library, and the two services, building clean at 0 warnings and 0 errors.
What the AppHost actually owns
The local loop is still one command, and that command is the only line of the developer workflow that changed:
dapr run -f . # Part 3: one run file, one dapr run per app
aspire run # Part 4: one C# program, the same four processes
The second one came back with four resources in the dashboard, all Running. Two are the services, listed against their .csproj. The other two are order-service-dapr-cli and inventory-service-dapr-cli, executable resources whose Source column holds the entire dapr run command line the integration assembled: the app ID, the app port, the three sidecar ports, the app channel address, the app protocol, and whichever components flag it chose to pass. That column is the best debugging surface in this whole setup. "Which component folder did this sidecar actually load" becomes one row in a browser instead of a log grep, and the in-memory state store further down is nothing but an exercise in reading it.
The run file had no equivalent. dapr run -f . gives you four processes interleaving their stdout into one terminal and a dapr stop -f . to take the set back down. Under the AppHost each sidecar is a resource in its own right: its own console log pane, its own recorded start time, its own Stop and Restart actions, and its own OTLP stream arriving in the same dashboard as the app it sits beside. The sidecar stops being something you remember to start and becomes something you can point at.
Startup order is the second thing the AppHost owns, and it is where people arriving from Docker Compose lose an afternoon. Two methods look interchangeable and are not. WithReference(x) is wiring: it injects the configuration a consumer needs in order to find x, and the documentation is explicit that it says nothing whatsoever about who starts first. WaitFor(x) is ordering: it holds a resource back until x is running and its registered health checks report healthy. Read WithReference as compose's depends_on and you have written down a dependency the runtime will not honour, which surfaces as a startup race rather than as an error message. aspire.dev's own compose migration guide names this as the top gotcha for people coming from compose, and it earns the title.
One piece of housekeeping before any of this reproduces on your machine. Ask a box set up today which Aspire version it is on and you get three answers: one from the CLI, one from the installed project templates, and one from the packages that actually resolve at build time. aspire update reconciles them. Everything below is pinned to Aspire.AppHost.Sdk 13.5.3 and CommunityToolkit.Aspire.Hosting.Dapr 13.0.0, and version-qualifying your own transcripts is worth the ten seconds when a stack moves this fast.
It is not docker-compose, and it does not replace it
The reflex on first reading an AppHost.cs is that this is a compose file with C# syntax, and half of that reflex is correct. The compose file that exists purely to stand up Postgres, Redis, RabbitMQ or Azurite and then run your services does go away: AddPostgres plus AddProject covers it, and adds service discovery, health-gated startup and the dashboard on top. The part that goes away with it is the workaround where you containerise a .NET service you have no intention of shipping in a container, just so compose can see it. AddProject runs it as a host process, so breakpoints and the ordinary build loop keep working.
The other half of the reflex is wrong in a way that matters, because a compose file is a deployment artifact and the AppHost is not. Aspire does not delete your compose file. It writes one: publishing to a Docker Compose environment emits docker-compose.yaml, a .env, per-environment .env files, and a Dockerfile per resource, and aspire deploy then runs docker compose up -d --remove-orphans over the result. The C# is the model; the YAML is the output.
Three gaps matter before you delete anything, and all three come from that same migration guide rather than from community complaints. deploy.resources.limits.memory and cpus are documented as "Not supported", so you cannot reproduce a memory-starved container locally the way compose lets you. Restart policies exist at publish time only. And network isolation has no direct equivalent at all: Microsoft's own wording is that "If your Docker Compose setup relies on network isolation (for example, preventing a frontend service from directly accessing the database), Aspire doesn't provide a direct equivalent." That last one is the one to read twice if your compose file uses a private network as a dev-time security boundary, because the boundary does not survive the move and nothing tells you it is gone.
One file that starts everything
The AppHost is a console application whose whole job is to describe the other four resources. Its project file comes first, because the shape of it changed on the 13.x line and the version most tutorials show no longer matches what the template writes.
<Project Sdk="Aspire.AppHost.Sdk/13.5.3">
<PropertyGroup>
<OutputType>Exe</OutputType>
<IsAspireHost>true</IsAspireHost>
<AspireUseCliBundle>true</AspireUseCliBundle>
<UserSecretsId>dapraspiredemo-apphost-w36</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CommunityToolkit.Aspire.Hosting.Dapr" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DaprAspireDemo.OrderService\DaprAspireDemo.OrderService.csproj" />
<ProjectReference Include="..\DaprAspireDemo.InventoryService\DaprAspireDemo.InventoryService.csproj" />
</ItemGroup>
</Project>
The Aspire version lives on the Sdk attribute of the Project element, and there is no Aspire.Hosting.AppHost package reference anywhere to match it: the only PackageReference in the file is the Dapr integration. The entry point is AppHost.cs, not Program.cs. Material written against Aspire 9.x describes a different file, with a nested <Sdk Name="Aspire.AppHost.Sdk" Version="..." /> element and a hosting package reference, so a PropertyGroup copied from a 9.x post lands in a project that no longer expects it.
<AspireUseCliBundle> is the line worth stopping on. Setting it to true takes the dashboard and the orchestrator (DCP) from the installed Aspire CLI bundle rather than from NuGet, which is also what keeps Aspire.Dashboard.Sdk and the DCP packages out of the restore graph, and it needs the aspire CLI on PATH. Build the project on 13.5 without it and MSBuild answers:
warning ASPIRE010: DaprAspireDemo.AppHost is configured with AspireUseCliBundle=false. Some Aspire
features require the Aspire CLI bundle. Set AspireUseCliBundle=true to enable those features, or
suppress ASPIRE010 to continue without the bundle. See https://aka.ms/aspire/diagnostics/aspire010
for more information.
Aspire.Hosting.AppHost.props defaults the property to false and Aspire.Hosting.AppHost.targets then warns that it is false, which is an odd pairing until you reach the detail that makes it dangerous: TreatWarningsAsErrors=true does not promote ASPIRE010. It comes out of an MSBuild <Warning> task rather than out of a Roslyn diagnostic, so a CI gate that turns warnings into errors passes it straight through. Only a rule that counts warnings catches it, which means a repository with a strict-looking gate can ship an AppHost missing whatever the bundle provides and never see a red build. The property is new in 13.5; it did not exist in 13.3.5, so an older sample carries no clue that it is missing.
The one package reference is the one to get right, and the obvious search result is the wrong package. Aspire.Hosting.Dapr is dead: it stopped at 9.1.0, and every version of it is deprecated on nuget.org with the notice "We will no longer be publishing new versions of this package. We recommend using the CommunityToolkit.Aspire.Hosting.Dapr package going forward." "Deprecated" reads as "abandoned", and that is not what happened here. The move was an ownership transfer, argued out in the comments of CommunityToolkit/Aspire#349: David Fowler laid out three options, keep it in core, move it to the Toolkit, or hand it to the Dapr .NET client team, and the thread settled on the Toolkit. The reason on the record is maintainer bandwidth, that Dapr was not a priority for the core team and every PR would still need core review, not a technical fault in the package. The confusing result is that the Dapr integration docs are first-party on aspire.dev while the code is community-maintained, so the page you read and the package you install do not share a name. CommunityToolkit.Aspire.Hosting.Dapr 13.0.0 is the newest stable and depends on Aspire.Hosting >= 13.0.0, so it rides the same 13.x train as the AppHost SDK above.
That is the whole project file. Here is the program it builds:
using CommunityToolkit.Aspire.Hosting.Dapr;
var builder = DistributedApplication.CreateBuilder(args);
builder.AddDapr();
var stateStore = builder.AddDaprStateStore(
"statestore",
new DaprComponentOptions
{
LocalPath = Path.Combine(builder.AppHostDirectory, "..", "components", "statestore.yaml"),
});
var inventory = builder.AddProject<Projects.DaprAspireDemo_InventoryService>("inventory-service")
.WithDaprSidecar(sidecar => sidecar.WithOptions(new DaprSidecarOptions
{
AppId = "inventory-service",
}));
var orders = builder.AddProject<Projects.DaprAspireDemo_OrderService>("order-service")
.WithDaprSidecar(sidecar => sidecar
.WithOptions(new DaprSidecarOptions
{
AppId = "order-service",
})
.WithReference(stateStore));
orders.WaitFor(inventory);
builder.Build().Run();
builder.AddDapr() is optional, which is the kind of claim worth distrusting, so I commented the line out and ran it again: both *-dapr-cli resources still started, POST /orders still returned 201, and the dapr run command line the integration assembled came back character-identical modulo ports. WithDaprSidecar() calls builder.ApplicationBuilder.AddDapr() itself, and AddDapr registers its lifecycle hook through TryAddEventingSubscriber, so the second call is a no-op. What writing the line buys you is the AddDapr(Action<DaprOptions>) overload, and DaprOptions has exactly three members: DaprPath for a Dapr CLI that is not on PATH, EnableTelemetry for turning the sidecars' dashboard telemetry off, and PublishingConfigurationAction for the publish step. Called with no callback, as here, it is the line that names the dependency: documentation rather than wiring.
The lifecycle hook it registers does not check whether Dapr is initialised. It probes for the dapr CLI binary, Homebrew prefix included, so a machine where somebody ran brew install dapr and stopped there passes the check, the AppHost starts clean, and the order-service-dapr-cli resource fails on its own some seconds later because dapr run has no daprd to launch. The tell is dapr --version answering with a CLI version and a runtime version of n/a.
AddDaprStateStore gets the second aside below, because the argument that looks optional is the one that decides whether your data outlives a process. The name is doing three jobs in the meantime: statestore is the Aspire resource name, the metadata.name in the component YAML, and the first argument order-service passes to SaveStateAsync. All three have to be the same string, and nothing checks that they are.
Both WithDaprSidecar calls take a callback rather than an options object, and the callback receives a sidecar builder carrying its own WithOptions and WithReference. DaprSidecarOptions has 32 properties; this file sets one of them, twice. What the integration fills in when you set nothing is the part to know before you start overriding things: --app-id falls back to the Aspire resource name, --app-port to the HTTP endpoint Aspire allocated for the app, and --config is not passed at all. The two AppId values above are the fallback spelled out, which is what stops a later rename of the Aspire resource from silently breaking every caller. The Source column shows you the resulting dapr run command line without a debugger.
One number in that command line will not match the one your application sees. On the run behind this article, order-service was handed DAPR_HTTP_PORT=57679 while its own daprd started with --dapr-http-port 57685. DCP puts a proxy in front of the sidecar, so the port the app talks to is not the port daprd binds. Both answer.
orders.WaitFor(inventory) is the last line and it is not a Dapr feature at all. Part 3 dealt with the same window from inside the caller: when the sidecar cannot route to an app ID it answers HTTP 500 with an ERR_DIRECT_INVOKE body, and InventoryClient translated that into a retryable failure rather than a flat 502. WaitFor addresses it from the other end, by refusing to start the caller until the callee reports healthy. They are not alternatives. The ordering is there so the startup case cannot arise; the error translation stays because the target can also fall over at three in the morning, when nothing is starting up.
Three ways to write that reference, one that compiles
The form printed in the package's own README does not build in this repository:
builder.AddProject<Projects.DaprAspireDemo_OrderService>("order-service")
.WithDaprSidecar()
.WithReference(stateStore); // <- [Obsolete]; fails the build
AppHost.cs(10,1): error CS0618: 'IDistributedApplicationResourceBuilderExtensions.WithReference<TDestination>(IResourceBuilder<TDestination>, IResourceBuilder<IDaprComponentResource>)' is obsolete: 'Add reference to the sidecar resource instead of the project resource'
Build FAILED.
The error is harder to act on than it looks. The position is (10,1), the start of the whole chained expression, not the .WithReference token three lines further down, so an editor puts the squiggle on builder.AddProject and the method it is complaining about is off the highlighted line entirely. And the parameterless .WithDaprSidecar() in the middle is fine: it carries no diagnostic of its own, which sends you looking at the wrong call first. Only WithReference on the project builder is obsolete.
Under TreatWarningsAsErrors this fails the build, which is the good outcome. Without that setting it is a warning you can live with for months, and it is not cosmetic. Published both ways, the obsolete form emits a dapr.v0 resource with no components array at all, while the sidecar-callback form emits "components": ["statestore", "pubsub"]. Same C# intent, different published output, and what that difference costs you comes back at the end, when this model meets aspire publish.
Two other spellings look plausible and are not. builder.AddDaprSidecar("order-service") appears in enough posts to feel like an API:
AppHost.cs(5,9): error CS1061: 'IDistributedApplicationBuilder' does not contain a definition for 'AddDaprSidecar' and no accessible extension method 'AddDaprSidecar' accepting a first argument of type 'IDistributedApplicationBuilder' could be found (are you missing a using directive or an assembly reference?)
Reading the assembly explains why: the builder-level Dapr surface is exactly four methods, AddDapr, AddDaprComponent, AddDaprPubSub and AddDaprStateStore, and AddDaprSidecar is not among them. A sidecar attaches to a resource; it is never declared standalone.
The third one is the using at the top of AppHost.cs, which is easy to read as decoration and is not:
AppHost.cs(11,26): error CS0246: The type or namespace name 'DaprSidecarOptions' could not be found (are you missing a using directive or an assembly reference?)
The extension methods are declared in the Aspire.Hosting namespace, which the AppHost's implicit usings already bring in, so AddDapr and WithDaprSidecar resolve with no using directive at all. DaprSidecarOptions and DaprComponentOptions are not in that namespace. Write the callback form without using CommunityToolkit.Aspire.Hosting.Dapr; and the calls compile while the options types do not, which is exactly the kind of half-working state that sends you back to the package reference. The sample on aspire.dev omits the using.
What compiles, and what the file above already uses, is the reference on the sidecar builder: .WithDaprSidecar(sidecar => sidecar.WithOptions(...).WithReference(stateStore)). One callback, everything Dapr-shaped inside it.
The state store you did not write is in memory
Delete the LocalPath argument from AddDaprStateStore and everything keeps working. That is the problem.
The integration does not go looking for a component you might already have. It writes its own, into a temp directory, and this is what it writes:
apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
name: statestore
spec:
type: state.in-memory
version: v1
metadata: []
Then it passes that directory to the sidecar as --resources-path. The mechanism is in that flag, and it is the part the advice you will find online gets backwards: --resources-path replaces --components-path, it does not add to it. Whatever is in the machine's ~/.dapr/components, including the Redis state store dapr init put there, is invisible to a sidecar started that way.
The proof is an asymmetry inside a single launch. Two daprd processes, one ps:
daprd ... --components-path /Users/martino/.dapr/components --app-id inventory-service
daprd ... --resources-path /var/folders/.../T/aspire-dapr.2X7RwV/statestore --app-id order-service
inventory-service has no WithReference, so it keeps the default --components-path and sees the machine's real components: its /v1.0/metadata returns two, pubsub on pubsub.redis and statestore on state.redis. order-service, the one service in the solution that actually stores anything, has the reference, gets --resources-path, and returns exactly one component: statestore on state.in-memory. The service that needs a database is the service that lost it.
Nothing about this announces itself, because the generated store is fully functional. SaveStateAsync succeeds, GetStateAsync hands the value back, the dashboard trace has the same shape either way, and no log line anywhere mentions that the store is in memory. It fails on one event only: the sidecar restarting. Which is to say never on a laptop, and eventually in production.
Pointing LocalPath at the component file in the repository is the fix, and Redis can be asked whether it took:
$ docker exec dapr_redis redis-cli KEYS '*'
order-service||ORD-2001
With LocalPath set, /v1.0/metadata on the order sidecar reports state.redis and that key appears after a write; without it, KEYS '*' comes back empty and the metadata says state.in-memory. The key is also Part 3's point arriving intact: the application passed ORD-2001, and order-service|| in front of it was added by the sidecar, not by the SDK, so the app ID is still part of the physical key even though nothing in the AppHost or the service ever wrote it there.
Check it before you trust it. The sidecar will tell you what it loaded:
curl -s http://localhost:<daprHttpPort>/v1.0/metadata | jq '.components'
# [{"name":"statestore","type":"state.in-memory","version":"v1", ...}] <- the bug, visible
The daprHttpPort is the one on the dapr run line in the dashboard, not the one in the app's DAPR_HTTP_PORT, though as noted above both answer. If you would rather not leave the browser, the same dashboard Source column answers the question without a request: look at which of --resources-path and --components-path the sidecar was given.
ServiceDefaults: what every service inherits
Both services start with the same line, and that line is the entire footprint Aspire has inside a service process. All four calls it makes:
public static TBuilder AddServiceDefaults<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
{
builder.ConfigureOpenTelemetry();
builder.AddDefaultHealthChecks();
builder.Services.AddServiceDiscovery();
builder.Services.ConfigureHttpClientDefaults(http =>
{
// Turn on resilience by default
http.AddStandardResilienceHandler();
// Turn on service discovery by default
http.AddServiceDiscovery();
});
return builder;
}
Nothing in this sample was customised. Extensions.cs is byte-identical between the 13.3.5 and 13.5.3 templates and byte-identical again to the copy sitting in the repository's older AspireDemo solution, so the file above is current for the whole 13.x line and you can read someone else's copy as if it were your own.
It can stay identical because there is nothing to version. No Aspire.ServiceDefaults package exists to install. The template stamps a class library into your solution and from then on the code is yours, which is why you read the four calls above rather than trusting them. Look at what the project file references and the ownership gets clearer: Microsoft.Extensions.Http.Resilience, Microsoft.Extensions.ServiceDiscovery, and five OpenTelemetry.* packages. Not one Aspire.* reference anywhere. Resilience and service discovery ship out of dotnet/extensions on the 10.x line while the AppHost next door is pinned to 13.5.3, so upgrading Aspire does not upgrade this, and the three-way version skew from earlier does not reach it. First-party guidance is firm about keeping it that way: "Don't include other shared functionality or models in this project."
One call is missing from the list, and it is the one people assume is there. MapDefaultEndpoints is not invoked by AddServiceDefaults; you write it yourself in Program.cs. The reason is a type mismatch rather than an oversight. Everything inside AddServiceDefaults is generic over TBuilder : IHostApplicationBuilder, so it works on a worker host or a MAUI builder as happily as on a web app, while MapDefaultEndpoints takes a concrete WebApplication. A background worker can register health checks and has nothing to map them on, so registration and mapping had to be two calls. The aside below is about what those endpoints do once you have mapped them, which is less than the name suggests.
The interesting default is one method deeper than the list above, in the private AddOpenTelemetryExporters that ConfigureOpenTelemetry calls on its last line, and it is a conditional:
var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]);
if (useOtlpExporter)
{
builder.Services.AddOpenTelemetry().UseOtlpExporter();
}
Everything ConfigureOpenTelemetry does before reaching that call is unconditional: logging with formatted messages and scopes, ASP.NET Core, HttpClient and runtime metrics, ASP.NET Core and HttpClient tracing. The exporter is the only part that asks a question first, and the question is whether OTEL_EXPORTER_OTLP_ENDPOINT has a value. If it does not, no exporter is registered at all. The instrumentation still runs, still allocates, still builds every span, and then drops the lot. Nothing warns, because from the SDK's point of view nothing is wrong.
Under the AppHost that variable is always set, along with OTEL_EXPORTER_OTLP_PROTOCOL, OTEL_SERVICE_NAME (literally the string you passed to AddProject), a per-run service.instance.id in OTEL_RESOURCE_ATTRIBUTES, and an x-otlp-api-key header for the dashboard's ingest endpoint. That is why nothing in either Program.cs names a collector: the orchestrator names it, on every child process it starts, including the two dapr run executables. Two of the injected values are Development-only, and they explain a mismatch people blame on their APM vendor: locally you get OTEL_TRACES_SAMPLER=always_on and one-second export intervals, and in production Aspire sets neither, leaving you on the OTel SDK's own defaults. Dashboards that feel instant on a laptop lag in production for that reason and no other.
Take the same service and deploy it to App Service or a plain container without that variable and you get zero telemetry with zero warnings. Setting the variable is the fix, not editing Extensions.cs. And resist the reflex of adding your vendor's OTLP exporter beside the defaults: UseOtlpExporter is single-shot, so a second call, or a signal-specific AddOtlpExporter() on the same service collection, throws NotSupportedException at startup rather than warning.
That is the whole inheritance, and from the consuming side it is one line in OrderService/Program.cs:
using Dapr.Client;
using DaprAspireDemo.OrderService.Inventory;
using DaprAspireDemo.OrderService.Orders;
var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();
builder.Services.AddDaprClient();
// Keyed singleton rather than AddHttpClient, to keep the app ID out of reach of service discovery.
builder.Services.AddKeyedSingleton<HttpClient>(
InventoryClient.AppId,
(_, key) => DaprClient.CreateInvokeHttpClient(appId: (string)key!));
builder.Services.AddSingleton<InventoryClient>();
var app = builder.Build();
app.MapDefaultEndpoints();
app.MapOrderEndpoints();
await app.RunAsync();
Two frameworks, two lines, and neither knows the other exists. AddServiceDefaults never mentions Dapr. AddDaprClient never mentions Aspire: it reads DAPR_HTTP_PORT and DAPR_GRPC_PORT out of its own environment, exactly as it did in Part 3, and the only thing that changed is who set them. There it was dapr run from the multi-app file. Here it is the hosting integration, one layer further out. The defaults 3500 and 50001 appear nowhere in this file precisely because nothing in it assumes a port.
Service invocation itself runs over an ordinary HttpClient. CreateInvokeHttpClient gives the client a base address whose host is the app ID, inventory-service over plain http, and installs the handler that rewrites each request into {daprEndpoint}/v1.0/invoke/inventory-service/method/{path}, and that factory is the supported surface now that DaprClient.InvokeMethodAsync has carried [Obsolete] since the Dapr .NET SDK 1.17.
The keyed registration in the middle is the one line in the file that is a judgement call, and it deserves a more honest defence than "the factory would break", because the factory does not break. Registering the same client through AddHttpClient, same inventory-service base address, same Dapr InvocationHandler, resolved through IHttpClientFactory, returns HTTP 201 on POST /orders. Two independent launches, no exception at CreateClient and none at request time.
It works because of what AddServiceDiscovery() registers by default: a pass-through provider. A host name with no matching configuration entry is not an error, it is a name that gets handed onward untouched to ordinary DNS. A pair of probe clients in one launch proves both halves of that sentence live. The one with a configuration entry resolved and then failed at connect with Connection refused (localhost:59999), so service discovery is genuinely in the pipeline and genuinely rewriting. The one without an entry failed at the socket with nodename nor servname provided, or not known (inventory-service:80), so the URI reached DNS with the app ID still in it. On the real invoke client the Dapr handler intercepts before any socket opens and rewrites the authority to the sidecar, so the DNS failure never happens.
The trap is on the other side of that condition, and it is one line in the AppHost away. Add .WithReference(inventory) to the order-service resource, the ordinary service-discovery overload and not the obsolete component one from the AppHost earlier, and Aspire hands the order-service process four new environment variables: the lowercase http and https service-discovery entries, and the uppercase INVENTORY_SERVICE_HTTP and INVENTORY_SERVICE_HTTPS forms of the same thing. A full environment diff against a control launch found those four and nothing else. Service discovery now has something to resolve, so it rewrites the request before Dapr ever sees it:
services__inventory-service__http__0=http://localhost:5049
http://inventory-service/stock/check -> http://localhost:5049/stock/check
The handler then reads uri.Host off the rewritten URI, which drops the port, and the sidecar is asked to invoke a service called localhost:
{"errorCode":"ERR_DIRECT_INVOKE","message":"failed to invoke, id: localhost, err: couldn't find service: localhost"}
POST /orders comes back 503. I ran that rather than inferring it from the error message: same AppHost, the factory registration in place, 503 on the order and that body verbatim out of the sidecar. Seeing the rewrite behind it took a separate run. A second client in the same launch, same base address, service discovery in its pipeline and no InvocationHandler, reached inventory-service's own Kestrel and came back 200, which is only possible if something had already replaced the authority.
Run that same AppHost against the sample as it ships and the reference changes nothing: 201, 200, 409, byte-identical to a control launch without it. The registration style, not the AppHost, is what decides the outcome. DaprClient.CreateInvokeHttpClient builds its client directly, so it never passes through IHttpClientFactory, ConfigureHttpClientDefaults never reaches it, and the injected configuration sits there inert. The two mechanisms are not exclusive, they are ordered, and service discovery is first. Keeping the invoke client out of the factory keeps the app ID out of the pipeline service discovery reads, which is a smaller claim than "the factory would break" and the one the runs actually support.
AddStandardResilienceHandler in the shared defaults is quietly wrapping every factory-created client in retries, a circuit breaker and two layers of timeout, which is a good thing to know before you write your own retry loop on top of it; Part 5 pulls that apart properly, because it belongs to the migration story rather than to this one.
InventoryService/Program.cs is the shortest argument in the whole sample. It calls AddServiceDefaults, maps its endpoints, and stops. Its project file references DaprAspireDemo.ServiceDefaults and nothing else: no Dapr.Client, no Dapr.AspNetCore, no Dapr type anywhere in its source. It is reachable as inventory-service because the AppHost attached a sidecar with that app ID, and Part 3's asymmetry survives the move intact. Being callable by app ID still costs the callee zero lines.
The health endpoints disappear outside Development
MapDefaultEndpoints is nine lines of code, and the first of them is a condition:
public static WebApplication MapDefaultEndpoints(this WebApplication app)
{
// Adding health checks endpoints to applications in non-development environments has security implications.
// See https://aka.ms/aspire/healthchecks for details before enabling these endpoints in non-development environments.
if (app.Environment.IsDevelopment())
{
// All health checks must pass for app to be considered ready to accept traffic after starting
app.MapHealthChecks(HealthEndpointPath);
// Only health checks tagged with the "live" tag must pass for app to be considered alive
app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions
{
Predicate = r => r.Tags.Contains("live")
});
}
return app;
}
Outside Development neither /health nor /alive is mapped. Not "returns 503", not "returns an empty body": there is no route. Point an AKS readiness probe or a Container Apps health probe at /health, get a 404, and the rollout stalls with a pod that never goes ready and not one log line anywhere mentioning health checks. aspire.dev states it plainly: "In non-development environments, the /health and /alive endpoints are disabled by default."
The reason is defensible. An unauthenticated /health that fans out to your database, cache and message broker on every request is a DoS amplifier that anyone on the internet can aim at your dependencies, and its response body enumerates those dependencies by name.
Before you go rewriting them, get the tag semantics right, because the version repeated in most posts is backwards. /health is mapped with no predicate, so it runs every registered check, including the ones tagged live. /alive filters to live only, so untagged checks never reach it. A probe app registering the template's "self" check plus a second untagged check returning Unhealthy answers 503 on /health and 200 on /alive at the same moment, which is exactly the Kubernetes semantic you want: a dead dependency should fail readiness and pull the pod out of the load balancer, and must not fail liveness and trigger a restart that cannot possibly help. Every Aspire client integration that registers a check (AddNpgsqlDataSource, AddRedisClient) registers it untagged, so this is the behaviour you inherit whether you thought about it or not.
The fix is not deleting the if. The documented alternative keeps the endpoints cheap and quiet: a 5-second request timeout policy, a 10-second output cache so a probe storm hits the cache rather than the database, both applied through a MapGroup, and host filtering or authorization on that group so only the platform's probe can reach it. That is a handful of lines in the same method, and it is the variant to copy.
One consequence reaches back into the AppHost. WithHttpHealthCheck("/health") combined with WaitFor is what turns "start B after A" into "start B after A is actually answering", which is the mechanism orders.WaitFor(inventory) leaned on back in the AppHost. If the endpoint it probes exists only in Development, that gate exists only in Development too, and nothing tells you when it stops applying.
One request, seven spans
POST /orders does three things: it takes an order, asks inventory-service whether the lines can be filled, and writes the accepted order to the state store. Two of those cross a process boundary, and nothing in the order-service source names a host, a port or a URL to make them happen.
curl -X POST http://localhost:5037/orders \
-H "Content-Type: application/json" \
-d '{"orderId":"ORD-1001","customerId":"CUST-42","lines":[{"sku":"AZ-KEYBOARD","quantity":2,"unitPrice":79.99},{"sku":"AZ-MOUSE","quantity":1,"unitPrice":24.50}]}'
{"orderId":"ORD-1001","customerId":"CUST-42","lines":[...],"total":184.48,"status":0,"placedAt":"2026-08-28T06:47:16.941436+00:00"}
HTTP 201, with the total computed from the lines the inventory service agreed to reserve. Order something the warehouse does not have and the same endpoint answers 409 with the shortfall rather than a stack trace:
{"orderId":"ORD-1002","shortfalls":[{"sku":"AZ-DOCK","requested":1,"onHand":0}]}
Part 3's run file could produce both of those responses. What it could not produce is the next block. The dashboard's trace detail for order-service: POST /orders/ reports Duration 0.18s, Resources 4, Depth 5, Total spans 7, and the tree behind those numbers is this:
POST /orders/ order-service
HTTP POST 200 order-service
HTTP POST order-service-dapr-cli
CallLocal/inventory-service/stock/check inventory-service-dapr-cli
POST /stock/check inventory-service
DATA state /dapr.proto.runtime.v1.Dapr/SaveState order-service-dapr-cli
HTTP POST 200 order-service-dapr-cli order-service
The right-hand column is the resource each span came from, and it is the column that makes this worth printing. Both *-dapr-cli resources are in it. The sidecar is not a black box that the trace jumps over: order-service-dapr-cli records the outbound HTTP POST it received, and inventory-service-dapr-cli records CallLocal/inventory-service/stock/check, the sidecar-to-sidecar hop, as a real span with the callee's own ASP.NET Core span nested underneath it. The SaveState call is there too, as its own span on the order sidecar, tagged with the gRPC method the SDK actually invoked (/dapr.proto.runtime.v1.Dapr/SaveState) rather than with anything the application wrote. Four resources, one trace, and not a line of telemetry configuration in either Program.cs.
Two separate things have to be true for that tree to exist, and only one of them is about the dashboard. The first is instrumentation: the single AddServiceDefaults call in each service registers inbound ASP.NET Core instrumentation on both services and outbound HttpClient instrumentation on the caller, so order-service writes a traceparent on the way out and inventory-service reads one on the way in. The second is delivery: the AppHost sets OTEL_EXPORTER_OTLP_ENDPOINT on the two service processes and on the two dapr run executables, along with the protocol, a per-resource OTEL_SERVICE_NAME and the dashboard's OTLP API key, so all four processes ship to the same collector without any of them knowing where it is. Miss the difference between those two and you will draw the wrong conclusion the first time a trace comes back fragmented: curl a callee directly, with no caller upstream, and you get a separate trace with a single span in it, because spans link on propagated context and not on a shared destination.
Before you build a habit around that dashboard, know what it is and is not. Its telemetry is capped at 10,000 traces and 10,000 logs shared across every resource, and Microsoft's own framing of the scope is that "the dashboard is designed as a development and short-term diagnostic tool", which "persists telemetry in-memory" and where "no telemetry is persisted when the dashboard is restarted". And you reach it through a one-time ?t= token, which is regenerated on every launch, on whichever port the template wrote into the AppHost's launchSettings.json when it scaffolded the project. That port is randomised once, at scaffold time, and then pinned: 17004 for this sample on every run, and some other five-digit number for yours. Either way, any instruction that tells you to browse to localhost:18888 is describing the standalone dashboard container, not the one aspire run just printed a URL for.
One open risk belongs here rather than in a footnote, because it is specifically a multi-sidecar risk. CommunityToolkit/Aspire#1509, open since August 2026, reports one sidecar per launch dying while the dashboard continues to show it as Finished, with a rotating victim and the dapr CLI's serialised startup as the suspected cause. I never saw it across three launches on Aspire 13.5.3 with toolkit 13.0.0: both sidecars reached Running, stayed there, answered /v1.0/metadata and contributed spans every time. Three launches against a race, with no attempt on my part to provoke it, is evidence of absence and not much more. Read it as a reason to check the resource list rather than as a reason to assume the issue is gone.
Restarting the app proves nothing about durability
The dashboard puts a Restart button next to every resource, which makes it the obvious way to answer "does my data survive a restart?". It answers a different question.
Restart order-service from the dashboard and the app process is genuinely replaced: PID 54221 became PID 60531, and the start time in the resource list moved with it. Neither daprd moved. order-service-dapr-cli stayed on PID 54223 with the dashboard still showing its original start time, and inventory-service-dapr-cli stayed on 54222. The sidecar is a top-level resource in its own right, and restarting its app does not touch it.
That is a useful property most of the time and a trap once. Because the sidecar survives, anything the sidecar was holding survives with it, and a state store living inside daprd's own memory is exactly that. GET /orders/ORD-1001 on an order written before the restart came back HTTP 200 on the configuration with no LocalPath set, the one that resolves to state.in-memory. The store that loses everything the moment the sidecar dies passes the restart-the-app test with full marks. Restart the *-dapr-cli resource instead and the two configurations finally disagree: 404 with the generated in-memory component, 200 with the repository's Redis one. That is the test worth writing down.
Two small details from the same menu. An app resource offers Stop, Restart and Rebuild; a *-dapr-cli resource offers Stop and Restart with no Rebuild, which is one more reminder that the sidecar is an executable Aspire launched and not a project it built. And Restart is not Rebuild even where both exist: the product's own description of it is "Source code is not recompiled."
If you switch to the http profile
The AppHost template writes two launch profiles, https and http, and on Aspire 13.5.3 the second one does not start:
Unhandled exception. System.AggregateException: One or more errors occurred. (The 'applicationUrl'
setting must be an https address unless the 'ASPIRE_ALLOW_UNSECURED_TRANSPORT' environment variable
is set to true. ...)
---> Microsoft.Extensions.Options.OptionsValidationException: The 'applicationUrl' setting must be
an https address unless the 'ASPIRE_ALLOW_UNSECURED_TRANSPORT' environment variable is set to true.
The template-generated http profile does not set that variable, so the profile the template ships is dead on arrival. This is the good kind of failure: loud, immediate, and naming the variable it wants.
Getting far enough to see it is the awkward half. aspire run has no --launch-profile flag at all, and with AspireUseCliBundle=true the dotnet run --launch-profile http you would reach for next is routed through aspire run and the profile is dropped on the floor: the AppHost came up with DOTNET_LAUNCH_PROFILE=https regardless, with nothing in the output admitting the switch was ignored. Any instruction of the form "run it with the http profile" has to say how, which in this case meant running the built AppHost binary directly with the profile's environment variables set by hand.
Once ASPIRE_ALLOW_UNSECURED_TRANSPORT=true is set and the app comes up, the interesting part is that nothing about the trace changes. Same tree, 4 resources, depth 5, 7 spans, both sidecars contributing, over a plain http OTLP endpoint, with OTEL_EXPORTER_OTLP_INSECURE never set on either daprd process. The insecure-endpoint story you may have read about Dapr and OTLP is not what bites you on this stack; the launch profile is.
One more observation from reading those environments, because it explains where the sidecars' configuration is not coming from. Both daprd processes were started with --config ~/.dapr/config.yaml, and that flag arrives the same way --components-path does: it is the dapr CLI's own default, not something the integration passed. The file it points at declares only a Zipkin exporter and has no OTLP section anywhere in it, yet both sidecars exported to the dashboard anyway. daprd 1.18.3 honours the OTEL_EXPORTER_OTLP_* variables independently of the Dapr Configuration resource. I did not check whether the spans reached Zipkin at the same time.
Where the model stops
Everything above is one machine. The introduction called the AppHost a development-time orchestrator on Microsoft's own authority, and this is the section that says what that costs you, because the parts of the model that do not cross the deployment boundary do not announce that they are staying behind.
Start with the components, because that is the one people find last. An AppHost carrying AddAzureContainerAppEnvironment("cae").WithDaprComponents() alongside an AddDaprStateStore and an AddDaprPubSub publishes cleanly: aspire publish -o ./out reported all five steps succeeded. The cae.bicep it wrote contains a managed identity, a container registry, a role assignment, a Log Analytics workspace, the Microsoft.App/managedEnvironments resource itself, the dashboard's dotNetComponents resource, and the outputs. It contains no Microsoft.App/managedEnvironments/daprComponents resource of any kind. The two components declared in C# have no representation anywhere in the emitted infrastructure.
Reading the publishing code explains that rather than excusing it. WithDaprComponents invokes a component's publishing action only when that component carries an AzureDaprComponentPublishingAnnotation, and a plain AddDaprStateStore is never given one. The annotation comes from the Azure helper packages, in practice from CommunityToolkit.Aspire.Hosting.Azure.Dapr.Redis, and only when you call WithReference on the component with an AddAzureManagedRedis resource as the argument; AddRedis and AddAzureRedis do not qualify, and neither does the local YAML file that made everything work on your laptop. The place not to look for the missing components is azd. It deploys the bicep it was handed, and the bicep never had them.
Sidecar enablement does survive publish, and one value in it needs checking before anything reaches a cluster:
dapr: { enabled: true, appId: 'svc', appProtocol: 'http', appPort: 8080, logLevel: 'info', enableApiLogging: false }
That appPort was not inferred. The probe container behind that line listens on 80, and the bicep still said 8080, because the publishing code falls back to a literal 8080 whenever DaprSidecarOptions.AppPort is unset. Port inference from the app's allocated endpoint happens at run time only, and has no publish-mode equivalent. A container that does not happen to listen on 8080 gets a deployed sidecar that cannot reach it, and the first symptom is a health check rather than an error about ports.
AppPort is also one of the survivors, which is the more useful way to read that line. Of the 32 properties on DaprSidecarOptions, the publishing path translates five into an ACA sidecar: AppId, AppPort, EnableApiLogging, LogLevel and AppProtocol. The other 27 have no destination in the emitted bicep, including every AppHealth* property, the port overrides, ResourcesPaths, and Config. Config being dropped costs nothing in practice, because Azure Container Apps lists the Dapr Configuration spec first among the things it does not support: the capability is not available on that platform whether Aspire forwards the setting or not.
Which matters mostly because Aspire never writes a Dapr Configuration resource in the first place. The integration passes --config only when you point DaprSidecarOptions.Config at a file you wrote yourself, and there is no C# surface that produces one. Tracing sampling rates, mTLS, access control lists, resiliency policies, middleware pipelines: all of it is hand-written YAML on the local side, and a separate question again about what the target platform accepts.
Line those up and they resolve into one uncomfortable symmetry. components/statestore.yaml, the one file you had to write by hand because the generated alternative was an in-memory store, is the artifact from this whole exercise that crosses the boundary intact. It is a real file, in source control, describing a real Redis instance, and getting it onto Container Apps or Kubernetes is an ordinary deployment problem with ordinary answers. The component the AppHost wrote for you lived in a temp directory that was deleted at shutdown and has no counterpart on the far side of publish. The half you had to do by hand is the half that ships.
Conclusion
Both files are deleted at the deployment boundary, so whatever the C# one is worth had to show up before then, and it does. One aspire run started four processes, held the caller back until the callee's health check answered rather than until it happened to be up, and put seven spans from four resources into one trace, with no line in either Program.cs naming a collector, a port, or the other service. dapr run -f . starts the same four processes and hands you back a terminal.
What you give up for it is one thing rather than a list. Everything the AppHost inferred for you it inferred at run time and nowhere else: the component YAML, the wiring, the ports, the health-gated ordering. None of it is waiting on the far side of aspire publish, and the one Dapr artifact the AppHost could not generate, because the generated version would have quietly lost your data, is the one you can actually deploy. Treat that as the rule for how much of this model to trust: whatever the AppHost saved you from writing, you will write eventually, and the sooner you write it the less of it is a surprise.
Part 5 takes the same boundary from the other side, moving an existing Functions app across it one piece at a time, and works out what happens when Dapr's built-in service-invocation retries, the standard resilience handler ServiceDefaults installs on every HttpClient, and your own retry loop all fire on the same failed request.

Top comments (0)