Skip to content

Adopt an ATS-first API design for Aspire.Hosting exported capabilities #16243

@davidfowl

Description

@davidfowl

Related: #16220

Aspire.Hosting exports should be designed from the ATS surface inward, not by projecting C# overload history, mutable property bags, and CLR service abstractions outward.

This issue is specifically about the exported capability surface and the projection layer. It is not a statement that callbacks are bad. Callbacks are fine. Unions are fine. Lists and dictionaries may be fine as transport/runtime concepts. The problem is when those implementation details become the author-facing cross-language API shape.

Principles

  • one conceptual operation should generally project as one capability
  • overload variation should prefer unions or chunky options objects
  • callbacks should remain first-class ATS callbacks
  • projection can reshape the public API; it does not need to mirror the underlying CLR surface 1:1
  • mutable collection state should not automatically project as List<T> / Dict<K,V> when a domain-specific facade is clearer
  • CLR service abstractions like IServiceProvider and ILogger should not define the xlang surface

Concrete examples from apphost.ts usage

1. Environment callback projects storage instead of intent

Current sample from playground/TypeScriptAppHost/apphost.ts:

builder
    .addViteApp("frontend", "./vite-frontend")
    .withReference(api)
    .waitFor(api)
    .withEnvironment("CUSTOM_ENV", "value")
    .withEnvironmentCallback(async (ctx: EnvironmentCallbackContext) => {
        var ep = await api.getEndpoint("http");
        await ctx.environmentVariables.set("API_ENDPOINT", refExpr`${ep}`);
    });

Potential direction:

builder
    .addViteApp("frontend", "./vite-frontend")
    .withReference(api)
    .waitFor(api)
    .withEnvironment("CUSTOM_ENV", "value")
    .withEnvironmentCallback(async (ctx) => {
        const ep = await api.getEndpoint("http");
        await ctx.environment.set("API_ENDPOINT", refExpr`${ep}`);
    });

The runtime can still use a live dictionary internally. Projection should expose an environment editor, not a dictionary.

2. Pipeline configuration exposes a remote property bag

Current sample from tests/PolyglotAppHosts/Aspire.Hosting/TypeScript/apphost.ts:

await container.withPipelineConfiguration(async (configContext) => {
    const configServices = await configContext.services.get();
    const configModel = await configContext.model.get();
    const configLoggerFactory = await configServices.getLoggerFactory();
    const configLogger = await configLoggerFactory.createLogger("ValidationAppHost.PipelineConfigurationContext");
    await configLogger.logInformation("Pipeline configuration logger");

    const allSteps = await configContext.steps.get();
    const taggedSteps = await configContext.getStepsByTag("custom-build");

    await allSteps[0].tags.add("validated");
    await allSteps[0].dependsOnSteps.add("restore");
    await taggedSteps[0].requiredBySteps.add("publish");
    await taggedSteps[0].requiredBy("publish");
    await allSteps[0].dependsOn("build");
});

Potential direction:

await container.withPipelineConfiguration(async (ctx) => {
    await ctx.log.info("Pipeline configuration logger");

    const steps = await ctx.pipeline.steps();
    const tagged = await ctx.pipeline.stepsByTag("custom-build");

    await steps[0].addTag("validated");
    await steps[0].dependsOn("restore");
    await tagged[0].requiredBy("publish");
});

This is a good example of ExposeProperties creating a mechanically projected but awkward API.

3. addProject leaks overload history into projected names

Current sample from tests/PolyglotAppHosts/Aspire.Hosting/TypeScript/apphost.ts:

const project = await builder.addProject("myproject", "./src/MyProject", "https");
const projectWithoutLaunchProfile = await builder.addProjectWithoutLaunchProfile("myproject-noprofile", "./src/MyProject");

Potential direction:

const project = await builder.addProject("myproject", "./src/MyProject", {
    launchProfileName: "https",
});

const projectWithoutLaunchProfile = await builder.addProject("myproject-noprofile", "./src/MyProject");

This is one concept and should not require overload-history method names in the projected API.

4. withUrl / withUrlExpression should likely be one projected method

Current sample from tests/PolyglotAppHosts/Aspire.Hosting/TypeScript/apphost.ts:

await container.withUrl("http://localhost:8080");
await container.withUrlExpression(refExpr`http://${endpoint}`);

Potential direction:

await container.withUrl("http://localhost:8080");
await container.withUrl(refExpr`http://${endpoint}`);

This is a straightforward case for union-based projection.

5. addContainer is a positive precedent

Current sample from tests/PolyglotAppHosts/Aspire.Hosting/TypeScript/apphost.ts:

const container = await builder.addContainer("mycontainer", "nginx");
const taggedContainer = await builder.addContainer("mytaggedcontainer", {
    image: "nginx",
    tag: "stable-alpine",
});

This is already close to the kind of chunky xlang shape we want: one conceptual operation, one projected method, union/DTO for variation.

6. withConnectionProperty is already unified in projection

Current sample from tests/PolyglotAppHosts/Aspire.Hosting/TypeScript/apphost.ts:

await builtConnectionString.withConnectionProperty("Host", expr);
await builtConnectionString.withConnectionProperty("Mode", "Development");

This is a good projection precedent. The projected API is unified even though the underlying ATS dump currently still carries split capability names.

7. addConnectionStringBuilder shows that callbacks are fine; the overload-shaped name is the problem

Current sample from tests/PolyglotAppHosts/Aspire.Hosting/TypeScript/apphost.ts:

const builtConnectionString = await builder.addConnectionStringBuilder("customcs", async (connectionStringBuilder) => {
    await connectionStringBuilder.appendLiteral("Host=");
    await connectionStringBuilder.appendValueProvider(endpoint);
    await connectionStringBuilder.appendLiteral(";Key=");
    await connectionStringBuilder.appendValueProvider(secretParam);
    await connectionStringBuilder.build();
});

Potential direction:

const builtConnectionString = await builder.addConnectionString("customcs", {
    build: async (connectionString) => {
        await connectionString.appendLiteral("Host=");
        await connectionString.appendValue(endpoint);
        await connectionString.appendLiteral(";Key=");
        await connectionString.appendValue(secretParam);
    },
});

Callbacks are not the smell here. The smell is that overload history leaked into the exported name.

Alternative implementation strategies

1. Projection-only reshaping

Keep the underlying ATS/runtime shape, but make projection generate better APIs:

  • project environmentVariables as ctx.environment
  • project args as ctx.args
  • project urls as ctx.urls
  • group capability families like withUrl / withUrlExpression into one projected method
  • group addProject / addProjectWithoutLaunchProfile into one projected method

2. ATS-native editor/facade types

Add explicit exported editor/facade types for callback surfaces:

  • EnvironmentEditor
  • ArgsEditor
  • UrlsEditor
  • PipelineEditor
  • LogFacade

This would make the ATS dump itself cleaner, not just the generated TypeScript surface.

3. Hybrid

Use projection reshaping first where it is cheap, and introduce ATS-native editor/facade types where the current exported shape is clearly accidental or awkward.

Initial fixes

A good first set of fixes would be:

  1. unify obvious overload families in projection
    • withUrl / withUrlExpression
    • addProject / addProjectWithoutLaunchProfile
    • withConnectionProperty value variants
  2. project callback collection properties as domain-specific editors/facades
    • environment variables
    • command-line args
    • resource URLs
    • pipeline mutation
  3. stop leaking service-locator/logging abstractions into the projected callback experience
  4. ensure existing chunky ATS-first shapes like addContainer(name, string | { image, tag? }) remain first-class

Success criteria

  • one conceptual operation generally projects as one capability surface
  • unions/options DTOs absorb most overload variation
  • callbacks remain first-class without driving name proliferation
  • projection is allowed to diverge from the CLR API when the xlang shape is better
  • callback-heavy experiences use domain-specific editors/facades instead of raw property bags where that reads better
  • the ATS dump and generated SDKs are easier to explain from a capability-first / xlang-first point of view

Metadata

Metadata

Labels

area-polyglotIssues related to polyglot apphosts

Type

No type

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions