↓ Skip to main content
  1. Posts/

A Polyglot App Is More Than a Process List

Chris Ayers
Author
Chris Ayers
I am a Principal Software Engineer at Microsoft, father, nerd, gamer, and speaker.
Aspire Field Notes - This article is part of a series.
Part 2: This Article

Starting a frontend, an API, and a database is straightforward. Knowing which address each process should use, when its dependencies are ready, and where a failed request went is the harder part.

In the catalog companion, Load catalog can return HTTP 503 while the API and inventory service both report healthy. Nothing has to crash for the application to fail.

In part one, we kept a failed catalog request. This time, let’s look at the wiring that made the request possible and the evidence that tells us which part failed.

Aspire puts the service relationships in the AppHost. The language integrations help, but each service still has to use the configuration it receives and report what happens inside it.

The runnable path in this article was validated with Aspire 13.6.4: a Vite frontend, a .NET catalog API, PostgreSQL, and a .NET inventory service. Java and Rust appear later as optional preview examples, not extra requirements for running the companion.

Three contracts, not one
#

An AppHost relationship can serve several purposes, but they are not interchangeable:

ContractWhat it answersWhat it does not prove
ConfigurationHow does this consumer find its dependency?That the dependency is ready or the caller is authorized
ReadinessWhen is it reasonable to start the consumer?That every later request will succeed
TelemetryCan we follow an operation through the system?That launching a process instrumented all its code

WithReference supplies configuration, WaitFor gates startup, and OpenTelemetry lets us follow the request. A reference can be correct while the dependency is still starting; both can work while the trace is missing.

David Fowler’s developer-loop article makes the broader case: describe the relationships that otherwise live in shell history and a teammate’s memory. You can adopt that model incrementally.

Start with the companion
#

Use the sample README for prerequisites and the one-time PostgreSQL secret setup. Run all commands below from aspire-field-notes/.

To run the companion, select the catalog AppHost explicitly because the repository also contains a separate terminal AppHost:

apphost=catalog/Catalog.AppHost/Catalog.AppHost.csproj
aspire start --apphost "$apphost" --isolated

The apphost variable keeps the path reusable for later commands; only aspire start launches the application. In the dashboard, wait for web to become Healthy, open its HTTP endpoint, and click Load catalog. The optional CLI checks automate readiness and the catalog request; they are not extra startup requirements.

Follow the catalog’s service graph
#

For one Load catalog request, the shape of the app is:

flowchart TB
accTitle: Catalog request flow
accDescr: The browser calls the Vite frontend. Vite proxies to the catalog API, which queries catalogdb in PostgreSQL and then calls inventory.

browser["Browser"]
web["web: Vite frontend and proxy"]
api["api: .NET catalog API"]
inventory["inventory: .NET service"]
catalogdb[("catalogdb (PostgreSQL)")]

browser -->|"GET /api/catalog"| web
web -->|"HTTP via API_HTTP"| api
api -->|"SQL query"| catalogdb
api -->|"GET /inventory"| inventory

The arrows show request traffic, not startup order. The API reads catalogdb, hosted by the postgres resource, before calling inventory. The AppHost supplies configuration and readiness relationships; it is not an HTTP hop in this path.

The catalog AppHost project models five application resources, plus the postgres-password and catalog-region parameters:

ResourceRoleContract to inspect
postgresLocal PostgreSQL serverContainer readiness and credentials
catalogdbThe catalog databaseConnection information supplied to the API
inventoryDownstream HTTP serviceAn instrumented business call, separate from its healthy /health endpoint
apiCatalog APIReads PostgreSQL and makes one inventory request for /api/catalog
webVite frontendSame-origin /api requests proxied to the API’s assigned endpoint

The resource list also shows web-installer, a child resource that runs npm ci for the frontend; web waits for it to finish. The dashboard’s Graph view draws the same model, with relationships as arrows and resource health shown on the nodes:

Aspire dashboard Graph view: the finished web-installer feeds web, web points to api, api points to inventory and catalogdb, and catalogdb belongs to postgres; the application resources have green health badges
Web calls the API; the API depends on inventory and catalogdb. The finished installer is a separate child resource.

This focused excerpt from the AppHost shows the API’s request wiring. catalogdb and inventory are defined earlier in that file:

var api = builder.AddProject<Projects.Catalog_Api>("api")
    .WithReference(catalogdb)
    .WithReference(inventory)
    .WithHttpHealthCheck("/health")
    .WaitFor(catalogdb)
    .WaitFor(inventory);

The full AppHost keeps the region and state-volume settings in a separate block for the configuration/state walkthrough. The shared API still requires those settings at startup; omitting them from this excerpt is not a request to remove them from the runnable companion.

Selecting Load catalog in the frontend makes the browser call /api/catalog on its own origin. Vite forwards the request to the API, which reads PostgreSQL and calls inventory once:

The companion frontend after Load catalog: 3 products loaded from local, HTTP 200, a trace ID, and a table with the debugging mug, field notebook, and trace sticker
Load catalog returns HTTP 200, three products, and a trace ID to follow in the dashboard.

Open that trace ID in the dashboard to see the database query and the single correlated inventory call. The Load catalog command on web, whether invoked from the dashboard or the CLI, also goes through the frontend endpoint. It does not bypass Vite to make the API look successful.

Readiness needs a real check
#

The companion implements /health in the API, inventory service, and Vite server. The API seeds its catalog before accepting traffic, and its database check participates in readiness. Vite uses an actual health middleware, not a catch-all HTML page mistaken for a successful probe.

When adapting the sample, check that each probe’s endpoint exists. Otherwise, the dependency will stay unhealthy. At the other extreme, a process with no health checks can satisfy a readiness wait as soon as it is running.

AddViteApp handles Vite’s development endpoint and port arguments. For other programs, declaring an endpoint and an environment variable only works when the application actually listens on that value.

Follow configuration into the consumer
#

The AppHost’s .WithReference(inventory) gives the API connection information. It does not create an HTTP client. Likewise, .WithReference(catalogdb) supplies database configuration but does not register a database client in the API.

The relevant registrations in Catalog.Api/Program.cs are:

builder.AddServiceDefaults();
builder.AddNpgsqlDataSource("catalogdb");
builder.Services.AddHttpClient("inventory", client =>
{
    client.BaseAddress = new Uri("http://inventory");
    client.Timeout = TimeSpan.FromSeconds(5);
});

The API later calls GetAsync("/inventory"), so this client needs a base address. http://inventory identifies a logical service, not a fixed machine or port. The companion’s Service Defaults registers the HTTP service-discovery handler that resolves the actual endpoint from the supplied configuration. WithReference(inventory) does not set the client’s base address for it. AddNpgsqlDataSource("catalogdb") registers the database client using the named connection string.

The API also rejects missing ConnectionStrings:catalogdb or services:inventory:http:0 configuration at startup. It does not quietly substitute a localhost service. Its OpenTelemetry setup lives in Service Defaults and the Npgsql integration; the AppHost reference is not the instrumentation.

For server-side Node, Python, Go, or Rust code, endpoint URL variables are often the simpler contract. A resource named api with an endpoint named http supplies API_HTTP. A named endpoint called admin supplies API_ADMIN; the suffix is the endpoint’s name, not necessarily its scheme.1

The services__api__http__0 format serves .NET configuration-based discovery. Do not assume a JavaScript HTTP client understands .NET’s logical URI syntax.

Our frontend uses that convention directly. .WithReference(api) supplies API_HTTP, and the server-side Vite proxy reads it. There is no separate environment-variable alias or hard-coded localhost URL to maintain.

Here is the development wiring for web:

var web = builder.AddViteApp("web", "../web")
    .WithNpm(installCommand: "ci")
    .WithReference(api)
    .WithHttpHealthCheck("/health")
    .WithExternalHttpEndpoints()
    .WaitFor(api);

WithReference(api) supplies connection information; WaitFor(api) controls startup. Removing one is not a substitute for the other.

Keep server configuration out of the browser bundle
#

The checked-in vite.config.mjs provides the health endpoint and configures the development proxy:

import { defineConfig } from "vite";
import { apiTarget } from "./proxy-config.mjs";

export default defineConfig(({ command }) => ({
  plugins: [{
    name: "field-notes-health",
    configureServer(server) {
      server.middlewares.use("/health", (_request, response) => {
        response.setHeader("Content-Type", "text/plain");
        response.end("Healthy");
      });
    },
  }],
  // Production uses the generated YARP proxy, not a build-time VITE_* API URL.
  server: command === "serve" ? {
    proxy: {
      "/api": {
        target: apiTarget(process.env),
        changeOrigin: true,
      },
    },
  } : undefined,
}));

apiTarget reads API_HTTP from the server environment and rejects a missing value or a URL that is not an HTTP(S) origin. It also rejects embedded credentials, a path, query parameters, or a fragment. Browser code calls /api/catalog without learning the local API port.

The production build does not need the injected URL. The companion does not use Vite’s development or preview server for production; the deployment walkthrough inspects the generated static-site and YARP proxy configuration instead.

Settings with Vite’s VITE_ prefix can be embedded in browser code; they are not an appropriate place for secrets or private server-only configuration.

In the companion run, API_HTTP on web matched the API’s assigned endpoint. The browser’s Network panel showed a request to /api/catalog on the web origin, and no VITE_* infrastructure variables were injected. Those checks prove different things: the proxy knows where to send the request, while the browser does not need that internal address.

Inspect just the settings you need in the dashboard. Do not paste the entire resource environment or raw aspire describe --format Json output into a report; the API’s connection-string fields can contain the PostgreSQL password even when the standalone secret parameter is redacted.

A green graph can still return 503
#

The failure walkthrough restarts the catalog with Inventory__FaultEnabled=true. Inventory continues to answer its health probe, but its /inventory business endpoint returns a real 503. The catalog API reports Inventory unavailable instead of hiding the failure.

Here is what the catalog run showed:

ScenarioAPI and inventory readinessCatalog responseInventory HTTP attempts
Normal requestHealthyHTTP 200, three products1
Inventory fault enabledHealthyHTTP 5031
Restart with fault disabledHealthyHTTP 200, three products1

The API queries PostgreSQL before calling inventory, so the database span is present even in the failed request. For each of those three resource-command traces, the evidence was:

  • An API server span for GET /api/catalog.
  • A PostgreSQL query span.
  • Exactly one API HTTP client span for the inventory request.
  • An inventory server span correlated as that HTTP client’s child.

The dashboard’s trace view is enough for this walkthrough. If you prefer the CLI, replace <trace-id> with the ID returned by Load catalog:

aspire otel spans --apphost "$apphost" --trace-id "<trace-id>" --format Json

Vite does not emit application spans in this sample. The browser Network panel verifies the frontend hop; the trace follows the instrumented API, database, and inventory work. Hosting all of them in Aspire does not automatically produce a browser-to-database trace.

The companion deliberately has no HTTP resilience handler, so a catalog request makes one inventory attempt. Disabling the fault and restarting restored HTTP 200 without adding retries. That is a controlled demonstration, not a recommendation to remove retries from every application: choose a retry policy deliberately and check what it actually does.

What Java and Rust add in 13.6
#

Aspire 13.6 moves Java and Rust hosting into first-party preview packages, building on Community Toolkit contributions. That is different from saying every language feature is generally available.2

The Java and Rust hosting packages matching this baseline are 13.6.4-preview.1.26523.11, not stable 13.6.4 packages. With those optional packages referenced, an AppHost can declare:

var catalog = builder.AddSpringBootApp("catalog", "../catalog");

var pricing = builder.AddRustApp("pricing", "../pricing")
    .WithHttpEndpoint(env: "PORT");

These fragments go before an existing builder’s Build().Run() and require real applications at those paths. Their package versions and APIs were checked against the published references, but these Java and Rust services were not run as part of the companion. You do not need either toolchain to follow the catalog walkthrough.

Spring Boot uses the application’s Maven or Gradle wrapper and receives its port through SERVER_PORT. Add an /actuator/health check only if the application includes and exposes the corresponding Actuator support. WithOtelAgent() is available when you want Java-agent instrumentation; configuring an exporter by itself does not create spans.3

The Rust resource runs Cargo. The Rust server must read PORT and expose an appropriate endpoint. The integration supplies OpenTelemetry settings, but the application still needs the Rust SDK and instrumentation. Cargo features, binary selection, and publishing are separate concerns from HTTP endpoint configuration.4

If you have an existing Java or Rust service, start with that. Verify its endpoint, readiness, and trace before adding more services to the graph.

Do not confuse two language choices
#

There is the language of the AppHost, and there are the languages of the services it runs.

ChoicePosition in the 13.6 story
C# or TypeScript AppHostEstablished authoring options; TypeScript reached GA in 13.4
JavaScript, Python, or Go serviceExisting first-party hosting integrations (Go since 13.4); none is new in 13.6
Java or Rust serviceNew first-party preview hosting packages
Additional AppHost languagesExperimental, feature-flagged authoring paths
DenoDeno 2 can run a TypeScript AppHost; the new Deno guest-hosting API is experimental

You can write the AppHost in TypeScript and keep the API in C#, or host Rust from a C# AppHost. Pick an authoring language your team can maintain; service-language support is a separate choice.

Another example: the Node.js weather map
#

David Pine’s Bluesky post and Mastodon post point to the official Node.js weather-map sample.

It combines Express and OpenTelemetry, React 19, Vite, Leaflet, and a TypeScript AppHost. The external weather API is modeled too. More importantly, it shows different run and publish arrangements: a development proxy locally, and frontend build output served with the API in the published application.

Follow one weather request and look at how the external API is configured. Before adapting it for production, you’ll also need authentication, rate limits, caching, and a plan for the weather provider’s quota.

Prove one path before adding more resources
#

Take one operation through your application and check:

  • The consumer receives an endpoint derived from its resource reference.
  • A configured readiness check gates startup where it matters.
  • The request reaches the intended dependency.
  • Telemetry connects the instrumented hops.
  • An intentional dependency failure stays visible, and any retries match the policy you configured.
  • A missing setting fails explicitly rather than falling back to an unrelated localhost service.

A shared dashboard does not apply .NET retry handlers to Go or Node. Browser trace propagation also needs its own setup. Keep those responsibilities visible rather than assuming the graph handles them.

Use walkthrough 02 for the detailed configuration and trace checks. When you finish, stop only this AppHost:

aspire stop --apphost "$apphost"

Before adding another language or another box to the graph, make one path explainable when it succeeds and when it fails. That is more useful than a longer list of running processes.

Aspire Field Notes - This article is part of a series.
Part 2: This Article

Related

Stop Losing the Bug When You Restart Aspire

··1951 words·10 mins
You reproduce an intermittent failure, change one line, and restart the application. This time the request works. Now you need the old trace to work out why. If it disappeared with the restart, you’re left comparing the new run with whatever you remember. Aspire 13.6’s dashboard keeps completed runs, so you can go back to the failed request instead.1

Building a Flexible AI Provider Strategy in .NET Aspire

How I architected a single codebase to seamlessly switch between Azure OpenAI, GitHub Models, Ollama, and Foundry Local without touching the API service When building my latest .NET Aspire application, I faced a common challenge: how do you develop and test with different AI providers without constantly rewriting your API service? The answer turned out to be surprisingly elegant - a configuration-driven approach that lets you switch between four different AI providers with zero code changes.