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:
- unify obvious overload families in projection
withUrl / withUrlExpression
addProject / addProjectWithoutLaunchProfile
withConnectionProperty value variants
- project callback collection properties as domain-specific editors/facades
- environment variables
- command-line args
- resource URLs
- pipeline mutation
- stop leaking service-locator/logging abstractions into the projected callback experience
- 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
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
List<T>/Dict<K,V>when a domain-specific facade is clearerIServiceProviderandILoggershould not define the xlang surfaceConcrete examples from apphost.ts usage
1. Environment callback projects storage instead of intent
Current sample from
playground/TypeScriptAppHost/apphost.ts:Potential direction:
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:Potential direction:
This is a good example of
ExposePropertiescreating a mechanically projected but awkward API.3. addProject leaks overload history into projected names
Current sample from
tests/PolyglotAppHosts/Aspire.Hosting/TypeScript/apphost.ts:Potential direction:
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:Potential direction:
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: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: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:Potential direction:
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:
environmentVariablesasctx.environmentargsasctx.argsurlsasctx.urlswithUrl/withUrlExpressioninto one projected methodaddProject/addProjectWithoutLaunchProfileinto one projected method2. ATS-native editor/facade types
Add explicit exported editor/facade types for callback surfaces:
EnvironmentEditorArgsEditorUrlsEditorPipelineEditorLogFacadeThis 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:
withUrl/withUrlExpressionaddProject/addProjectWithoutLaunchProfilewithConnectionPropertyvalue variantsaddContainer(name, string | { image, tag? })remain first-classSuccess criteria