Multi-language integrations
Ce contenu n’est pas encore disponible dans votre langue.
Aspire hosting integrations are C# libraries that extend the AppHost with new resource types. By default, these integrations are only available in C# AppHosts. To make them available in TypeScript AppHosts, you annotate your APIs with ATS (Aspire Type System) attributes.
This guide walks you through the process of exporting your integration for TypeScript AppHost use. The same ATS metadata can support additional AppHost languages as they become available in future Aspire releases.
How it works
Section titled “How it works”When a TypeScript AppHost adds your integration, the Aspire CLI:
- Loads your integration assembly
- Scans for ATS attributes on methods, types, and properties, such as
[AspireExport]. - Generates a typed TypeScript SDK with matching methods
- The generated SDK communicates with your C# code via JSON-RPC at runtime
Your C# code runs as-is — the TypeScript SDK is a thin client that calls into it. You don’t need to rewrite anything in TypeScript.
Enable the analyzer
Section titled “Enable the analyzer”The integration analyzer provides build-time validation that catches common export mistakes. Use either supported enablement path:
- If your integration already references
📦 Aspire.Hosting, opt in by setting theEnableAspireIntegrationAnalyzersMSBuild property in your integration project. - Alternatively, reference the standalone
📦 Aspire.Hosting.Integration.Analyzerspackage withPrivateAssets="all"using the same Aspire package version. The standalone analyzer package applies automatically and doesn’t require the MSBuild property.
<PropertyGroup> <EnableAspireIntegrationAnalyzers>true</EnableAspireIntegrationAnalyzers></PropertyGroup>The analyzer reports diagnostics that help you get your exports right before users encounter runtime errors. Common scenarios include detecting incompatible parameter types, missing export annotations on public methods, duplicate export or capability IDs, and synchronous callbacks that could deadlock in multi-language app hosts.
Export extension methods
Section titled “Export extension methods”Annotate your extension methods with [AspireExport] and use XML doc comments to document them for the generated TypeScript SDK:
/// <summary>Adds a MyDatabase container resource.</summary>/// <param name="builder">The distributed application builder.</param>/// <param name="name">The MyDatabase resource name.</param>/// <param name="port">The optional port.</param>/// <returns>The MyDatabase resource builder.</returns>[AspireExport]public static IResourceBuilder<MyDatabaseResource> AddMyDatabase( this IDistributedApplicationBuilder builder, [ResourceName] string name, int? port = null){ // Your existing implementation...}
/// <summary>Adds a database to the MyDatabase server.</summary>/// <param name="builder">The MyDatabase server resource builder.</param>/// <param name="name">The database name.</param>/// <param name="databaseName">The optional database name override.</param>/// <returns>The database resource builder.</returns>[AspireExport]public static IResourceBuilder<MyDatabaseDatabaseResource> AddDatabase( this IResourceBuilder<MyDatabaseResource> builder, [ResourceName] string name, string? databaseName = null){ // Your existing implementation...}
/// <summary>Adds a data volume to the MyDatabase server.</summary>/// <param name="builder">The MyDatabase server resource builder.</param>/// <param name="name">The optional volume name.</param>/// <returns>The MyDatabase resource builder.</returns>[AspireExport]public static IResourceBuilder<MyDatabaseResource> WithDataVolume( this IResourceBuilder<MyDatabaseResource> builder, string? name = null){ // Your existing implementation...}This generates the following highlighted TypeScript APIs:
import { createBuilder } from './.aspire/modules/aspire.mjs';
const builder = await createBuilder();
const db = await builder .addMyDatabase('db', { port: 5432 }) .addDatabase('mydata') .withDataVolume();
await builder.build().run();Document your exports
Section titled “Document your exports”XML doc comments are the primary source for generated SDK API documentation. The ATS scanner reads <summary>, <param>, <returns>, and <remarks> tags and includes them in the TypeScript JSDoc generated for your integration.
Use ats-* override tags when the standard C# XML documentation doesn’t translate well to generated SDK docs — for example, when a <summary> references C#-specific types or language constructs that have no direct equivalent in TypeScript. The supported overrides are:
<ats-summary>— overrides<summary>in polyglot docs<ats-param name="...">— overrides<param>for a specific parameter<ats-returns>— overrides<returns><ats-remarks>— overrides<remarks>
An empty ats-* tag intentionally suppresses the matching standard documentation in the generated SDK.
/// <ats-summary>Adds a Redis container resource.</ats-summary>/// <summary>/// Adds a <see cref="RedisResource"/> to the <see cref="IDistributedApplicationBuilder"/>./// </summary>/// <param name="builder">The distributed application builder.</param>/// <param name="name">The Redis resource name.</param>/// <returns>The Redis resource builder.</returns>[AspireExport]public static IResourceBuilder<RedisResource> AddRedis( this IDistributedApplicationBuilder builder, string name){ // ...}Cross-references in ATS docs
Section titled “Cross-references in ATS docs”Use <ats-see cref="!:kind:identifier.path" /> and <ats-seealso cref="!:kind:identifier.path" /> to link to generated SDK elements. The !: prefix prevents the C# compiler from validating the custom cref format; ATS removes it before parsing. The supported kind values are type, method, and field. The reference path uses dot notation over generated SDK identifiers:
/// <summary>/// Configures <ats-see cref="!:type:RedisResource" />./// </summary>/// <remarks>/// See also <ats-see cref="!:method:RedisResource.withPersistence" /> and/// <ats-seealso cref="!:field:RedisDefaults.Port" />./// </remarks>[AspireExport]public static IResourceBuilder<RedisResource> ConfigureRedis( this IDistributedApplicationBuilder builder, string name){ // ...}TypeScript renders these references as JSDoc {@link ...} links.
Keep capability IDs unique
Section titled “Keep capability IDs unique”The runtime dispatches TypeScript AppHost calls by capability ID. A capability ID is more restrictive than a C# method signature: it doesn’t include the C# receiver type, parameter list, or overload signature. Starting in Aspire 13.3, the analyzer reports ASPIREEXPORT013 when two exports in the same assembly generate the same capability ID.
For static exports, the generated capability ID uses the assembly name and effective export ID. The following methods collide even though they target different C# types:
[AspireExport("configure")]public static void ConfigureBuilder( this IDistributedApplicationBuilder builder, string name){ // ...}
[AspireExport("configure")]public static IResourceBuilder<MyDatabaseResource> ConfigureDatabase( this IResourceBuilder<MyDatabaseResource> builder, string value){ return builder;}Use distinct export IDs for the runtime capability, and use MethodName when you want the generated SDK method name to stay concise on different target types:
[AspireExport("configureBuilder", MethodName = "configure")]public static void ConfigureBuilder( this IDistributedApplicationBuilder builder, string name){ // ...}
[AspireExport("configureDatabase", MethodName = "configure")]public static IResourceBuilder<MyDatabaseResource> ConfigureDatabase( this IResourceBuilder<MyDatabaseResource> builder, string value){ return builder;}When you set ExposeMethods = true or ExposeProperties = true on a context type, public instance members also generate capabilities. Overloaded instance methods with the same name generate the same default capability ID, so either expose a single ATS-friendly overload, mark unsupported overloads with [AspireExportIgnore], or give each exported member a unique [AspireExport] ID. Inspect the generated SDK too; not every exposed-method naming edge case is reported as a generated member-name diagnostic.
Field-tested authoring patterns
Section titled “Field-tested authoring patterns”Use these patterns as an implementation checklist when you design a hosting integration that should feel natural in both C# AppHosts and generated SDKs such as TypeScript:
- Design the ATS contract first: the generated methods, DTOs, values, callbacks, and docs that SDK users should see.
- Keep the C# API ergonomic with overloads and rich implementation types where they help C# callers.
- Adapt the C# API to ATS with explicit exports, DTO/options types, unions, small context/editor types, and ignored C#-only overloads.
- Validate the generated TypeScript SDK as the first concrete generated SDK, but keep the contract language-neutral so future SDKs can share it.
- Inspect generated signatures and docs before shipping.
Do and don’t checklist
Section titled “Do and don’t checklist”| Do | Don’t |
|---|---|
| Design the ATS contract first, then map it back to C#. | Export every C# overload and rely on renamed generated methods to make the API usable. |
Keep runtime capability IDs unique and use MethodName only when the generated method name needs to differ. | Reuse explicit export IDs across methods or set explicit IDs that duplicate the convention-derived name. |
Use flat [AspireDto] options bags, init setters, arrays, records, and [AspireUnion] for cross-language inputs. | Put runtime handles, delegates, loggers, configuration objects, service providers, or mutable framework types in DTO inputs. |
| Accept ATS values with union-shaped method parameters or editor methods. | Hide live resource handles, endpoint references, or expressions inside JSON DTOs. |
Use explicit [AspireExport] attributes for callback contexts and editor types. | Turn on broad property or method expansion for large framework-style types. |
Document every exported API, DTO, parameter, and property with language-neutral XML comments and ats-* overrides when needed. | Publish C#-specific descriptions that mention implementation types the generated SDK doesn’t expose. |
Test with the analyzer and a TypeScript AppHost project reference, then inspect generated .d.ts signatures. | Assume a C# API shape is polyglot-safe without checking the generated module. |
Design ATS-first, not C#-first
Section titled “Design ATS-first, not C#-first”Start from the ATS contract you want generated SDKs to expose, then map that contract back to C#. TypeScript is a useful way to test and illustrate the shape today, but the design target is the ATS surface that other generated AppHost SDKs can share. Prefer one clear ATS-visible operation over a set of C# overloads that need different generated names. Use DTO options bags, [AspireUnion], and small exported context or editor types to represent the cross-language contract.
Every documented AppHost feature should have ATS metadata if generated SDKs can reasonably use it. For example, APIs for health checks, files, endpoints, commands, or resource annotations shouldn’t be documented as C#-only surface area unless the underlying feature is intentionally unavailable to generated SDKs.
Separate runtime IDs from friendly method names
Section titled “Separate runtime IDs from friendly method names”Capability IDs are runtime dispatch identifiers. Keep them unique and stable. Avoid explicit export IDs when the convention-derived ID is already correct, and use MethodName only when the runtime ID must differ from the friendly generated SDK method name.
When several overloads describe one cross-language concept, prefer a single DTO-based overload, a union parameter, or a dispatcher-style export instead of many ATS-visible overloads with renamed methods. This usually produces a better generated API and avoids collisions.
[AspireExport("configureServer", MethodName = "configure")]public static IResourceBuilder<MyServerResource> Configure( this IResourceBuilder<MyServerResource> builder, string value) => builder;
[AspireExport("configureDatabase", MethodName = "configure")]public static IResourceBuilder<MyDatabaseResource> Configure( this IResourceBuilder<MyDatabaseResource> builder, string value) => builder;await server.configure('server-value');await database.configure('database-value');Overload design
Section titled “Overload design”ATS dispatch is not C# overload resolution. A capability ID identifies one callable operation at runtime; it doesn’t include the C# receiver type, parameter list, generic constraints, or overload signature. Design the exported surface as the API you want generated SDK users to call, then adapt to your C# overloads internally.
Use these rules for overload sets:
- If overloads represent one user concept, export one ATS method and model the variation with a DTO/options object,
[AspireUnion], or an internal dispatcher. - If overloads represent different user concepts on the same generated target type, give them distinct export IDs and distinct generated method names. Don’t rely on C# signatures to disambiguate.
- Use
MethodNameonly when the runtime export ID must be unique but the generated methods live on different target types and can safely share the same friendly name. - Mark C#-only overloads with
[AspireExportIgnore], especially overloads using interpolated string handlers, non-serializable callbacks or contexts, opaque provisioning types, or convenience overloads that are better represented by one ATS-friendly DTO or union method.
For one user concept, C# can keep convenience overloads, but export one generated shape:
[AspireExportIgnore(Reason = "Use the ATS-friendly withInput export.")]public static IResourceBuilder<T> WithInput<T>( this IResourceBuilder<T> builder, string value) where T : IResource => WithInputCore(builder, value);
[AspireExportIgnore(Reason = "Use the ATS-friendly withInput export.")]public static IResourceBuilder<T> WithInput<T>( this IResourceBuilder<T> builder, EndpointReference endpoint) where T : IResource => WithInputCore(builder, endpoint);
[AspireExport("withInput")]internal static IResourceBuilder<T> WithInputForExport<T>( this IResourceBuilder<T> builder, [AspireUnion( typeof(string), typeof(EndpointReference), typeof(ReferenceExpression))] object value) where T : IResource => WithInputCore(builder, value);Export adapter members can be internal when they exist only to shape the generated SDK. The ATS metadata still exposes the generated method, while the helper stays out of the public C# API.
The generated SDK stays focused on one concept:
await resource.withInput('literal-value');await resource.withInput(api.getEndpoint('https'));For overload families that vary by optional configuration, prefer one flat options object:
[AspireDto]public sealed class PublishOptions{ public string? Tag { get; init; } public bool? Push { get; init; }}
[AspireExport("publishAsImage")]internal static IResourceBuilder<T> PublishAsImageForExport<T>( this IResourceBuilder<T> builder, PublishOptions? options = null) where T : IResource{ return PublishAsImageCore(builder, options);}await container.publishAsImage({ tag: 'v1', push: true });Avoid generating overload-shaped APIs such as publishAsImageWithTag, publishAsImageWithPush, or nested bags when the variation is optional configuration.
For different concepts on the same target type, use different generated method names:
[AspireExport]public static IResourceBuilder<MyResource> WithHttpEndpoint( this IResourceBuilder<MyResource> builder, int? port = null) => builder;
[AspireExport]public static IResourceBuilder<MyResource> WithGrpcEndpoint( this IResourceBuilder<MyResource> builder, int? port = null) => builder;await service.withHttpEndpoint({ port: 8080 });await service.withGrpcEndpoint({ port: 9090 });Don’t use MethodName to give multiple exports the same generated method name on the same target type. That creates generated SDK member collisions even if the runtime capability IDs are unique. If the generated target type is the same, choose distinct generated method names or collapse the overloads into one union or DTO-shaped export.
Expand members intentionally
Section titled “Expand members intentionally”Treat ExposeProperties = true and ExposeMethods = true as broad expansion switches. They export every compatible public member on the type, including inherited members where applicable. That is convenient for small purpose-built handle or context types, but risky on broad framework-style types because generated SDK member names can collide with extension methods or with other exposed members.
Prefer explicit [AspireExport] attributes on callback context and editor types, and use [AspireExportIgnore] for members that are C#-only, internal implementation details, or not useful in generated SDKs.
[AspireExport]public sealed class MyCallbackContext( IResource resource, Dictionary<string, object> environmentVariables, IServiceProvider services){ [AspireExport] public IResource Resource => resource;
[AspireExport] internal EnvironmentEditor Environment => new(environmentVariables);
[AspireExportIgnore(Reason = "Implementation detail; not useful in generated SDKs.")] public IServiceProvider Services => services;}// Avoid on broad context types unless every public member is intended for SDK users.[AspireExport(ExposeProperties = true, ExposeMethods = true)]public sealed class MyLargeContext{ // ...}Generated member naming must be checked per generated target type, not just by runtime capability ID. Collisions can happen between exposed properties and exported extension methods. Inherited exposed properties also count, and common names such as name, command, or workingDirectory can collide with integration-specific methods. Read-only or init-only C# properties should be readable in generated SDKs, but shouldn’t expand into setters.
Support ATS value inputs
Section titled “Support ATS value inputs”Some APIs need to accept values that aren’t plain JSON: literals, endpoint references, resource references, parameters, connection strings, or expressions. Model these as ATS value inputs on exported methods or editor methods with [AspireUnion]; don’t bury them inside DTOs, because DTOs are JSON-shaped input objects.
[AspireExport]public static IResourceBuilder<T> WithSetting<T>( this IResourceBuilder<T> builder, string name, [AspireUnion( typeof(string), typeof(ReferenceExpression), typeof(EndpointReference), typeof(IResourceBuilder<ParameterResource>), typeof(IResourceBuilder<IResourceWithConnectionString>), typeof(IExpressionValue))] object value) where T : IResourceWithEnvironment{ return WithSettingCore(builder, name, value);}await resource.withSetting('MODE', 'debug');await resource.withSetting('UPSTREAM_URL', api.getEndpoint('https'));await resource.withSetting('ConnectionStrings__db', database);Use [AspireValue] value catalogs for predefined constants, such as model names or regions. Use [AspireUnion] parameters when callers need to pass live AppHost values.
Use JSON-shaped inputs
Section titled “Use JSON-shaped inputs”DTOs are input objects, not live resource handles. Keep [AspireDto] types JSON-serializable, use init setters for input properties, and model collections as value-shaped arrays or records. Avoid exposing runtime handle-backed types, mutable framework collections, delegates, loggers, configuration objects, or service-provider types through DTOs.
For an API with one optional options DTO, prefer a flat generated call shape such as addMyResource('name', { port: 5432 }) instead of requiring { options: { port: 5432 } }. If an existing C# API uses a rich model type that doesn’t serialize cleanly, add a small DTO or options type for the exported API.
[AspireDto]public sealed class AddMyWorkerOptions{ public string? ImageTag { get; init; } public string[] Args { get; init; } = [];}
[AspireExport]public static IResourceBuilder<MyWorkerResource> AddMyWorker( this IDistributedApplicationBuilder builder, [ResourceName] string name, AddMyWorkerOptions? options = null){ var resource = new MyWorkerResource( name, options?.ImageTag, options?.Args ?? []);
return builder.AddResource(resource);}const worker = await builder.addMyWorker('worker', { imageTag: '1.2.3', args: ['--verbose'],});Collection-valued DTO inputs should also feel like plain JSON in the generated SDK:
[AspireDto]public sealed class AccessRuleOptions{ public string[] AddressPrefixes { get; init; } = []; public Dictionary<string, string> Tags { get; init; } = [];}await resource.withAccessRule({ addressPrefixes: ['10.0.0.0/24'], tags: { environment: 'dev' },});Treat callbacks as async and re-entrant
Section titled “Treat callbacks as async and re-entrant”Generated SDK callbacks can call back into ATS/RPC; in TypeScript, that commonly means callbacks can await generated SDK calls. Keep callback context types small, expose only the members the callback needs, and prefer editor objects over raw mutable collections.
If an exported method invokes a synchronous delegate inline, set RunSyncOnBackgroundThread = true so nested callback responses can continue flowing. This includes async-returning exported methods that call the synchronous delegate before their first await. When possible, design callback APIs so they don’t re-enter RPC on a blocked thread.
[AspireExport(RunSyncOnBackgroundThread = true)]public static IResourceBuilder<MyWorkerResource> WithConfiguration( this IResourceBuilder<MyWorkerResource> builder, Action<MyConfigurationEditor> configure){ var editor = new MyConfigurationEditor(); configure(editor);
return builder.WithEnvironment("MY_WORKER_CONFIG", editor.ToJson());}Write language-neutral doc comments
Section titled “Write language-neutral doc comments”Treat XML doc comments as part of the ATS contract. Document exported methods, DTOs, parameters, properties, and value catalogs because generated SDK docs come from these comments. Keep the wording language-neutral and describe the generated contract rather than C#-specific implementation details. Use ats-* override tags when a standard XML comment needs a generated-SDK-specific description; the TypeScript SDK renders the resulting docs as JSDoc.
[AspireDto]public sealed class AddMyDatabaseDocsOptions{ /// <summary>The database engine version.</summary> public string? Version { get; init; }}
/// <summary>Adds a MyDatabase resource.</summary>/// <param name="builder">The distributed application builder.</param>/// <param name="name">The resource name.</param>/// <param name="options">Optional database configuration.</param>/// <returns>The MyDatabase resource builder.</returns>[AspireExport]public static IResourceBuilder<MyDatabaseResource> AddMyDatabase( this IDistributedApplicationBuilder builder, [ResourceName] string name, AddMyDatabaseDocsOptions? options = null){ return builder.AddResource(new MyDatabaseResource(name, options));}Validate the generated shape
Section titled “Validate the generated shape”Use the analyzer and a TypeScript AppHost that references your integration project. After aspire restore or aspire run generates modules, inspect the generated imports and .d.ts signatures to confirm method names, DTO shapes, callback accessors, and docs metadata match the API you intended. Generated TypeScript APIs are promise-heavy, so await builder and fluent calls, await PropertyAccessor reads and writes, and consult the generated .d.ts signatures for service-provider methods and callback accessors. New TypeScript AppHosts use apphost.mts and .aspire/modules/*.mjs imports; legacy apphost.ts projects remain supported and may use .modules/*.js imports.
import { createBuilder } from './.aspire/modules/aspire.mjs';
const builder = await createBuilder();
const worker = await builder.addMyWorker('worker');await worker.withConfiguration(async (config) => { await config.set('mode', 'debug');});
await builder.build().run();Export resource types
Section titled “Export resource types”Mark your resource types with [AspireExport] so the TypeScript SDK can reference them as typed handles. Set ExposeProperties = true to make all public properties accessible as capabilities, or annotate individual properties with [AspireExport] for fine-grained control:
[AspireExport(ExposeProperties = true)]public sealed class MyDatabaseResource(string name) : ContainerResource(name), IResourceWithConnectionString{ /// <summary> /// Gets the primary endpoint for the database. /// </summary> public EndpointReference PrimaryEndpoint => new(this, "tcp");
/// <summary> /// Internal implementation detail — not exported. /// </summary> [AspireExportIgnore] public string InternalConnectionPool { get; set; } = "";}
[AspireExport]public sealed class MyDatabaseDatabaseResource(string name, MyDatabaseResource parent) : Resource(name){ // Your existing implementation...}When ExposeProperties = true, each public property becomes a capability in the generated SDK. Use [AspireExportIgnore] on properties that shouldn’t be exposed.
You can also set ExposeMethods = true to export public instance methods as capabilities alongside properties.
How getter-only properties appear in TypeScript
Section titled “How getter-only properties appear in TypeScript”The code generator distinguishes between getter-only properties, mutable collection wrapper properties, and scalar or wrapper read-write properties:
- Getter-only properties are generated as async methods in TypeScript:
property(): Promise<T>. This includes getter-onlyAspireList<T>andAspireDict<K,V>properties. - Mutable collection wrapper properties (such as
AspireList<T>orAspireDict<K,V>with a setter) are generated asreadonlysynchronous wrapper properties. - Scalar or wrapper read-write properties are generated as property accessors with async
get()andset(value)functions.
Read-only or init-only C# properties can be readable in generated SDKs, but they shouldn’t expose TypeScript setters. If TypeScript callers need to mutate state, export an explicit method or editor type instead.
For example, a C# class with all three property shapes:
[AspireExport(ExposeProperties = true)]public class MyPropertyDemo{ /// <summary>Getter-only — becomes an async method in TypeScript.</summary> public IResource Resource => _resource;
/// <summary>Getter-only collection — also becomes an async method in TypeScript.</summary> public AspireList<string> DefaultTags { get; } = new();
/// <summary>Mutable collection wrapper — stays a readonly wrapper in TypeScript.</summary> public AspireList<string> Tags { get; set; } = new();
/// <summary>Scalar read-write value — becomes a property accessor.</summary> public string DisplayName { get; set; } = "";}Generates the following TypeScript interface:
export interface MyPropertyDemo { toJSON(): MarshalledHandle; resource(): Promise<IResourceHandle>; // getter-only → async method defaultTags(): Promise<AspireList<string>>; // getter-only collection → async method readonly tags: AspireList<string>; // mutable collection wrapper → readonly property readonly displayName: PropertyAccessor<string>; // scalar read-write → async get/set accessor}TypeScript AppHost authors call getter-only properties as functions:
const resource = await context.resource();const defaultTags = await context.defaultTags();const tags = context.tags; // no await needed for mutable collection wrappersconst displayName = await context.displayName.get();await context.displayName.set('api');Callback context types and the ATS-first editor pattern
Section titled “Callback context types and the ATS-first editor pattern”When you export a method that accepts a callback (such as withEnvironmentCallback, withArgsCallback, or withUrls), the callback receives a context object. For ATS compatibility, context types should follow the ATS-first design:
- Use
[AspireExport](notExposeProperties = true) on the context class. - Annotate only the properties that generated SDK callers need with individual
[AspireExport]attributes. - For mutable state (environment variables, command-line arguments, URL lists), expose a small editor class rather than the raw collection.
Defining an editor class
Section titled “Defining an editor class”An editor wraps a mutable collection and exposes specific operations — typically add, set, or remove — instead of handing the raw collection to TypeScript:
/// <summary>/// Provides an ATS-first editor for environment variables within polyglot callbacks./// </summary>[AspireExport]internal sealed class EnvironmentEditor(Dictionary<string, object> environmentVariables){ /// <summary>Sets an environment variable.</summary> /// <param name="name">The environment variable name.</param> /// <param name="value">The environment variable value.</param> [AspireExport] public void Set( string name, [AspireUnion( typeof(string), typeof(ReferenceExpression), typeof(EndpointReference), typeof(IResourceBuilder<ParameterResource>), typeof(IResourceBuilder<IResourceWithConnectionString>))] object value) { environmentVariables[name] = value; }}Defining the callback context
Section titled “Defining the callback context”Use individual [AspireExport] attributes on each property the generated SDK caller needs. Pass the editor as a getter-only property so TypeScript callers receive it as an async method:
[AspireExport]public sealed class MyCallbackContext( DistributedApplicationExecutionContext executionContext, IResource resource, Dictionary<string, object> environmentVariables){ /// <summary>Gets the resource associated with this callback.</summary> [AspireExport] public IResource Resource => resource;
/// <summary>Gets the execution context.</summary> [AspireExport] public DistributedApplicationExecutionContext ExecutionContext => executionContext;
/// <summary>Gets the environment variable editor.</summary> [AspireExport] internal EnvironmentEditor Environment => new(environmentVariables);}Exporting the callback method
Section titled “Exporting the callback method”Export the extension method that accepts the callback, using Action<MyCallbackContext> as the parameter type:
/// <summary>Configures the resource using a callback.</summary>/// <param name="builder">The resource builder.</param>/// <param name="configure">The callback to configure the resource.</param>/// <returns>The resource builder.</returns>[AspireExport]public static IResourceBuilder<MyResource> WithMyCallback( this IResourceBuilder<MyResource> builder, Action<MyCallbackContext> configure){ return builder.WithAnnotation(new MyCallbackAnnotation(configure));}The generated TypeScript API accepts an async arrow function:
await myResource.withMyCallback(async (context) => { const resource = await context.resource(); const env = await context.environment(); await env.set('MY_KEY', 'my-value');});Services available from callback service providers
Section titled “Services available from callback service providers”Several ATS callback contexts expose an IServiceProvider handle through a services() or serviceProvider() accessor. These accessors are async PropertyAccessor values, so await them before using the returned service provider. For example, resource events expose await event.services(), command contexts expose await context.serviceProvider(), and DistributedApplicationExecutionContext exposes await executionContext.serviceProvider().
In TypeScript, use the ATS-friendly methods on the service provider instead of the C# generic dependency-injection pattern. For example, call services.getLoggerFactory() instead of serviceProvider.GetRequiredService<ILoggerFactory>().
await builder.subscribeBeforeStart(async (event) => { const services = await event.services(); const loggerFactory = services.getLoggerFactory(); const logger = loggerFactory.createLogger('AppHost');
logger.logInformation('The AppHost is starting.');});The generated TypeScript SDK exposes these service-provider methods:
getDistributedApplicationModel()returnsDistributedApplicationModel. Inspect application resources withgetResources()or locate a resource by name withfindResourceByName().getLoggerFactory()returnsILoggerFactory. Create a named logger withcreateLogger(), then write messages withlogInformation(),logWarning(),logError(), orlogDebug().getResourceLoggerService()returnsResourceLoggerService. Complete resource log streams withcompleteLog()orcompleteLogByName().getResourceNotificationService()returnsResourceNotificationService. Publish resource updates, inspect state, or wait for readiness withpublishResourceUpdate(),tryGetResourceState(),waitForDependencies(),waitForResourceHealthy(),waitForResourceState(), orwaitForResourceStates().getUserSecretsManager()returnsIUserSecretsManager. Read user-secrets availability and path withawait secrets.isAvailable()andawait secrets.filePath()(or the.get()methods on those property accessors), set or delete secrets, and persist state withgetOrSetSecret(),trySetSecret(),tryDeleteSecret(), orsaveStateJson().getAspireStore()returnsIAspireStore. Create deterministic file copies withgetFileNameWithContent()when generated artifacts need stable paths.getEventing()returnsIDistributedApplicationEventing. Access eventing infrastructure from callbacks, includingunsubscribe()for event subscriptions created by builder-level helpers such assubscribeBeforeStart()andsubscribeAfterResourcesCreated().getResourceCommandService()returnsResourceCommandService. Execute another command withexecuteCommandAsync()when command or event callbacks need to orchestrate resource actions.
Inspect the application model
Section titled “Inspect the application model”Use getDistributedApplicationModel() when callback code needs to inspect or locate resources in the AppHost model:
await builder.subscribeAfterResourcesCreated(async (event) => { const services = await event.services(); const model = services.getDistributedApplicationModel(); const resources = model.getResources(); const api = model.findResourceByName('api');});Write logs
Section titled “Write logs”Use getLoggerFactory() to create loggers from callbacks that expose a service provider:
await builder.subscribeBeforeStart(async (event) => { const services = await event.services(); const logger = services.getLoggerFactory().createLogger('startup');
logger.logInformation('Preparing resources.');});Work with resource state and logs
Section titled “Work with resource state and logs”Use getResourceNotificationService() for resource state transitions and getResourceLoggerService() for resource log streams:
const cache = await builder.addRedis('cache');
cache.onResourceReady(async (event) => { const resource = await event.resource(); const services = await event.services(); const resourceName = resource.getResourceName(); const notifications = services.getResourceNotificationService(); const resourceLogs = services.getResourceLoggerService();
notifications.waitForResourceHealthy(resourceName); resourceLogs.completeLog(resource);});Manage user secrets and generated files
Section titled “Manage user secrets and generated files”Use getUserSecretsManager() for user secrets and getAspireStore() for stable files created during AppHost execution:
await builder.subscribeBeforeStart(async (event) => { const services = await event.services(); const secrets = services.getUserSecretsManager(); const store = services.getAspireStore(); const model = services.getDistributedApplicationModel(); const api = model.findResourceByName('api');
if (await secrets.isAvailable()) { secrets.getOrSetSecret(api, 'ApiKey', crypto.randomUUID()); }
const generatedFile = store.getFileNameWithContent( 'seed-data.json', './seed-data.json' );});Eventing and command services
Section titled “Eventing and command services”Use getEventing() when callback code needs the eventing service itself:
const subscription = await builder.subscribeBeforeStart(async (event) => { const services = await event.services(); const eventing = services.getEventing();
eventing.unsubscribe(subscription);});Use getResourceCommandService() when a callback needs to execute another resource command. Resolve the command service from the AppHost execution context’s service provider before registering the command, then capture it in the callback:
const executionContext = await builder.executionContext();const serviceProvider = await executionContext.serviceProvider();const commandService = serviceProvider.getResourceCommandService();
const cache = await builder.addRedis('cache');await cache.withCommand('clear-cache', 'Clear Cache', async () => { return { success: true };});
const api = await builder.addProject('api', '../Api/Api.csproj');await api.withCommand('reset-all', 'Reset Everything', async (ctx) => { const cancellationToken = await ctx.cancellationToken();
return commandService.executeCommandAsync(cache, 'clear-cache', { arguments: { requestedBy: 'reset-all' }, cancellationToken, });});Export configuration DTOs
Section titled “Export configuration DTOs”If your integration accepts structured configuration, mark the options class with [AspireDto]. DTOs are serialized as JSON between the TypeScript AppHost and the .NET runtime:
[AspireDto]public sealed class AddMyDatabaseOptions{ public required string Name { get; init; } public int? Port { get; init; } public string? ImageTag { get; init; }}Collection properties in DTOs
Section titled “Collection properties in DTOs”DTO collection properties (such as List<T>, IList<T>, IEnumerable<T>, arrays, or Dictionary<string, T>) are generated as value-shaped JSON types in the TypeScript SDK. This means AppHost code can supply plain collection literals directly rather than constructing handle-backed wrapper objects.
For example, a DTO that accepts address prefix and fully qualified domain name collections:
[AspireDto]public sealed class MyAccessRule{ public List<string> AddressPrefixes { get; init; } = []; public List<string> FullyQualifiedDomainNames { get; init; } = [];}The TypeScript SDK exposes collection values using ordinary TypeScript collection shapes:
const rule: MyAccessRule = { addressPrefixes: ['203.0.113.0/24', '198.51.100.0/24'], fullyQualifiedDomainNames: ['example.com'],};Export value catalogs
Section titled “Export value catalogs”Use [AspireValue] to export immutable predefined values from your integration into guest SDKs as typed catalog objects. This is useful when your integration ships well-known constants or configuration presets—such as a list of supported model names or region identifiers—that TypeScript AppHost authors should be able to reference without reconstructing them manually.
Define a value catalog
Section titled “Define a value catalog”Apply [AspireValue] to public static fields (prefer static readonly) or public static properties with a public static getter. The required catalogName argument sets the root name of the generated catalog in guest SDKs:
using Aspire.Hosting;
[AspireDto]public sealed class MyModel{ public required string Name { get; init; } public required string Version { get; init; }}
public static class MyModels{ public static class FastModels { /// <summary>A fast, lightweight model for simple tasks.</summary> [AspireValue("MyModels")] public static readonly MyModel Lite = new() { Name = "my-model-lite", Version = "1" };
/// <summary>A fast model with extended context support.</summary> [AspireValue("MyModels")] public static readonly MyModel LiteLong = new() { Name = "my-model-lite-long", Version = "1" }; }
public static class PowerModels { /// <summary>A high-capability model for complex tasks.</summary> [AspireValue("MyModels")] public static readonly MyModel Pro = new() { Name = "my-model-pro", Version = "2" }; }}The scanner snaps the values at scan time by serializing each field or property to JSON. It also reads XML doc comments to include descriptions in the generated catalog.
Use catalog values in guest SDKs
Section titled “Use catalog values in guest SDKs”After generating the SDK (for example, with aspire run), the catalog is available as a nested object in the TypeScript SDK. The nesting mirrors the static class hierarchy of the C# source:
import { createBuilder } from './.aspire/modules/aspire.mjs';import { MyModels } from './.aspire/modules/my-integration.mjs';
const builder = await createBuilder();
// Use predefined catalog values directlyawait builder.addMyService('svc', { model: MyModels.FastModels.Lite });
await builder.build().run();Override the exported name
Section titled “Override the exported name”By default, the exported name matches the field or property name. Use the Name property to override it:
[AspireValue("MyModels", Name = "lite")]public static readonly MyModel Lite = new() { Name = "my-model-lite", Version = "1" };Value catalog constraints
Section titled “Value catalog constraints”- Exported members must be public static fields or properties with a public static getter. Indexed properties aren’t supported, and non-public members are skipped with a warning.
- Catalog paths (
catalogName, nested static type names, andName) must be valid generated SDK identifiers: an ASCII letter or_first, followed by ASCII letters, digits, or_. - Duplicate catalog paths and parent/leaf path conflicts are skipped.
- Values must be copied shapes supported by the scanner: primitives, enums, arrays, read-only dictionaries, and DTOs that recursively contain only supported copied shapes.
- Don’t use mutable
List<T>orDictionary<K,V>, handles (IResourceBuilder<T>, resource instances), delegates, runtime state, or DTOs containing unsupported shapes. These are skipped even whenSystem.Text.Jsoncould serialize them. - Values are snapped once at scan time. They are emitted as compile-time constants in generated SDKs and are not refreshed at runtime.
Handle incompatible overloads
Section titled “Handle incompatible overloads”Some C# overloads use types that can’t be represented in generated SDKs (for example, interpolated string handlers, non-serializable callback contexts, or C#-specific types). Mark these with [AspireExportIgnore]:
// This overload works in generated SDKs — simple parameters/// <summary>Sets the maximum number of connections.</summary>/// <param name="builder">The resource builder.</param>/// <param name="maxConnections">The maximum number of connections.</param>/// <returns>The resource builder.</returns>[AspireExport]public static IResourceBuilder<MyDatabaseResource> WithConnectionStringLimit( this IResourceBuilder<MyDatabaseResource> builder, int maxConnections){ // ...}
// This overload uses a C#-specific type — exclude it[AspireExportIgnore(Reason = "ForwarderConfig is not ATS-compatible. Use the DTO-based overload.")]public static IResourceBuilder<MyDatabaseResource> WithConnectionStringLimit( this IResourceBuilder<MyDatabaseResource> builder, ForwarderConfig config){ // ...}Union types
Section titled “Union types”When a parameter accepts multiple types, use [AspireUnion] to declare the valid options:
/// <summary>Sets an environment variable on the resource.</summary>/// <param name="builder">The resource builder.</param>/// <param name="name">The environment variable name.</param>/// <param name="value">The value, which may be a string, reference expression, or endpoint reference.</param>/// <returns>The resource builder.</returns>[AspireExport]public static IResourceBuilder<T> WithEnvironment<T>( this IResourceBuilder<T> builder, string name, [AspireUnion( typeof(string), typeof(ReferenceExpression), typeof(EndpointReference), typeof(IResourceBuilder<ParameterResource>), typeof(IResourceBuilder<IResourceWithConnectionString>), typeof(IResourceBuilder<ExternalServiceResource>), typeof(IExpressionValue))] object value) where T : IResourceWithEnvironment{ // ...}All types in the union must be ATS-compatible. The analyzer (ASPIREEXPORT005, ASPIREEXPORT006) validates union declarations at build time.
Analyzer diagnostics
Section titled “Analyzer diagnostics”The integration analyzer reports these diagnostics when it’s enabled:
| ID | Severity | Description |
|---|---|---|
| ASPIREEXPORT001 | Error | Standalone [AspireExport] method must be static |
| ASPIREEXPORT002 | Error | Invalid export ID format (must match [a-zA-Z][a-zA-Z0-9.]*) |
| ASPIREEXPORT003 | Error | Return type is not ATS-compatible |
| ASPIREEXPORT004 | Error | Parameter type is not ATS-compatible |
| ASPIREEXPORT005 | Warning | [AspireUnion] requires at least 2 types |
| ASPIREEXPORT006 | Warning | Union type is not ATS-compatible |
| ASPIREEXPORT007 | Warning | Duplicate export ID for the same target type |
| ASPIREEXPORT008 | Warning | Public extension method on exported type missing [AspireExport] or [AspireExportIgnore] |
| ASPIREEXPORT009 | Warning | Export name may collide with other integrations |
| ASPIREEXPORT010 | Warning | Synchronous callback invoked inline — may deadlock in multi-language app hosts |
| ASPIREEXPORT011 | Warning | Explicit export ID matches the convention-derived name |
| ASPIREEXPORT012 | Warning | Callback context type missing [AspireExport] |
| ASPIREEXPORT013 | Warning | Duplicate polyglot capability ID across exports in the same assembly |
| ASPIREEXPORT014 | Error | Duplicate generated polyglot member name on the same generated SDK target type; use a unique MethodName or combine overloads |
| ASPIREEXPORT015 | Error | [AspireExport(Description = ...)] is deprecated — use XML doc comments instead |
| ASPIREEXPORT016 | Warning | DTO property is a get-only mutable collection. Add an init accessor |
A clean build with zero analyzer warnings or errors is the required baseline. Still validate the generated module and signatures before shipping the integration.
Local development with project references
Section titled “Local development with project references”You can test your integration locally without publishing to a NuGet feed. In your TypeScript AppHost’s aspire.config.json, set the package value to a .csproj path instead of a version number:
{ "appHost": { "path": "apphost.mts", "language": "typescript/nodejs" }, "packages": { "Aspire.Hosting.Redis": "13.3.0", "MyCompany.Hosting.MyDatabase": "../src/MyCompany.Hosting.MyDatabase/MyCompany.Hosting.MyDatabase.csproj" }}When the CLI detects a .csproj path, it builds the project locally and generates the TypeScript SDK from the resulting assemblies. This lets you iterate on your exports without publishing to a feed.
Test your exports
Section titled “Test your exports”-
Create a TypeScript AppHost for testing:
Create test AppHost mkdir test-apphost && cd test-apphostaspire init --language typescript -
Add your integration via project reference in
aspire.config.json:JSON — aspire.config.json (packages section) {"packages": {"MyCompany.Hosting.MyDatabase": "../src/MyCompany.Hosting.MyDatabase/MyCompany.Hosting.MyDatabase.csproj"}} -
Run
aspire runto generate the TypeScript SDK:Generate SDK and start aspire run -
Check the generated
.aspire/modules/directory for your integration’s TypeScript types. Verify that your exported methods appear with the correct signatures. -
Use the generated API in
apphost.mts:TypeScript — apphost.mts import { createBuilder } from './.aspire/modules/aspire.mjs';const builder = await createBuilder();const db = await builder.addMyDatabase('db', { port: 5432 }).addDatabase('mydata').withDataVolume();await builder.build().run();
Supported types
Section titled “Supported types”The following types are ATS-compatible and can be used in exported method signatures:
| Category | Types |
|---|---|
| Primitives | string, bool, int, long, float, double, decimal |
| Value types | DateTime, TimeSpan, Guid, Uri |
| Enums | Any enum type |
| Handles | IResourceBuilder<T>, IDistributedApplicationBuilder, resource types marked with [AspireExport] |
| DTOs | Classes/structs marked with [AspireDto] |
| Exported values | Static fields/properties marked with [AspireValue] (emitted as catalog constants in guest SDKs) |
| Collections | List<T>, Dictionary<string, T>, arrays — where T is ATS-compatible |
| Delegates | Action<T>, Func<T>, and other delegate types (use RunSyncOnBackgroundThread = true for exports that invoke synchronous callbacks inline, including async-returning exports that call callbacks before their first await) |
| Services | ILogger, IServiceProvider, IConfiguration (already exported by the core framework) |
| Special | ParameterResource, ReferenceExpression, EndpointReference, IExpressionValue, CancellationToken |
| Nullable | Any of the above as nullable (T?) |
Types that are not ATS-compatible include: interpolated string handlers and custom complex types without [AspireExport] or [AspireDto].
See also
Section titled “See also”- Build your first app — Get started with a TypeScript AppHost
- Custom resources — Creating custom resource types
- Integrations overview — Available integrations