KoalaSoft.Aspire.Hosting.ServiceSources.Java
0.3.1
dotnet add package KoalaSoft.Aspire.Hosting.ServiceSources.Java --version 0.3.1
NuGet\Install-Package KoalaSoft.Aspire.Hosting.ServiceSources.Java -Version 0.3.1
<PackageReference Include="KoalaSoft.Aspire.Hosting.ServiceSources.Java" Version="0.3.1" />
<PackageVersion Include="KoalaSoft.Aspire.Hosting.ServiceSources.Java" Version="0.3.1" />
<PackageReference Include="KoalaSoft.Aspire.Hosting.ServiceSources.Java" />
paket add KoalaSoft.Aspire.Hosting.ServiceSources.Java --version 0.3.1
#r "nuget: KoalaSoft.Aspire.Hosting.ServiceSources.Java, 0.3.1"
#:package KoalaSoft.Aspire.Hosting.ServiceSources.Java@0.3.1
#addin nuget:?package=KoalaSoft.Aspire.Hosting.ServiceSources.Java&version=0.3.1
#tool nuget:?package=KoalaSoft.Aspire.Hosting.ServiceSources.Java&version=0.3.1
Aspire.Hosting.ServiceSources
A .NET Aspire AppHost extension that lets builder.AddService("orders") resolve to a real,
running resource whose source is chosen per developer, not baked into the AppHost.
Why
AddProject<T>() assumes a service lives in the AppHost's own solution. In a real
microservice environment, services live in separate repositories, and different developers
want different things for the same service: clone it locally to edit, run it from an
already-checked-out working copy, reach an instance already running in a shared Kubernetes
dev cluster, hit a fixed URL, or just run a published container image. The AppHost should
only describe what it depends on; where that dependency actually comes from is a
per-developer choice, made without ever touching the AppHost's .csproj/.sln.
AddService() is the seam: the AppHost calls it once per service, and a developer-local
config file decides how it's actually resolved — a managed or self-managed local git
checkout ("local"), a kubectl port-forward against a dev cluster ("kubernetes"), a
fixed, already-known URL ("url"), or a published container image run locally
("container") — behind one stable return type, so the AppHost code never has to change
when a developer switches sources.
Install
Published on nuget.org as KoalaSoft.Aspire.Hosting.ServiceSources.
If every service your AppHost declares is a .NET project, this is the only package you need:
dotnet add package KoalaSoft.Aspire.Hosting.ServiceSources
These packages floor Aspire at 13.5.2, so an AppHost still on 13.4.x gets a mixed Aspire family. NuGet takes the highest floor, so
Aspire.Hostingis lifted to 13.5.2 while yourAspire.AppHost.Sdk,Aspire.Hosting.AppHostand the DCP and dashboard packages the SDK pins to it stay where they are. Nothing warns about it at restore. Move your AppHost's own Aspire version to 13.5.2 or later at the same time:<Sdk Name="Aspire.AppHost.Sdk" Version="13.5.2" />
Services that aren't .NET projects need the satellite package for their language, so an AppHost only takes on the hosting dependencies it actually uses — see Non-.NET local services:
| Language | Package |
|---|---|
| Java | KoalaSoft.Aspire.Hosting.ServiceSources.Java |
| JavaScript | KoalaSoft.Aspire.Hosting.ServiceSources.JavaScript |
A satellite already depends on the core package, so add it instead of the core package rather than alongside it — restore brings the matching core in for you:
dotnet add package KoalaSoft.Aspire.Hosting.ServiceSources.JavaScript
Two direct references mean two versions to move in step, because a satellite accepts core
only within its own minor: bump one and not the other and restore fails with NU1107. A
single reference has nothing to keep in step. Add a satellite per language you use; core
still arrives once, transitively.
Or reference the project directly from your AppHost instead:
<ItemGroup>
<ProjectReference Include="path/to/Aspire.Hosting.ServiceSources/Aspire.Hosting.ServiceSources.csproj" />
</ItemGroup>
Requires .NET 8 or later (net8.0, net9.0, and net10.0 are all supported) and an AppHost
project using the Aspire.AppHost.Sdk (aspire new / aspire restore sets this up).
Every release is listed in the
changelog, which
is where breaking changes and their migrations are recorded. Check it before upgrading —
while the version is below 1.0.0, a breaking change can ship in a minor release.
Preview builds
Every push to main publishes a prerelease build (0.x.y-alpha.0.N) to GitHub Packages.
Stable releases go to nuget.org only — use those unless you specifically need an unreleased
fix. Previews are pruned after each release — only the five most recent are kept — so treat
them as disposable and never pin one in a long-lived project.
GitHub's NuGet registry requires authentication for every download, even for public
packages — unlike the container registry, it has no anonymous access. This is not a grant
on this repository: any authenticated GitHub user can download a public package, so all you
need is a token on your own account. It must be a classic personal access token with the
read:packages scope; fine-grained tokens are not supported by GitHub Packages.
dotnet nuget add source https://nuget.pkg.github.com/flojon/index.json \
--name servicesources-preview --username <your-github-username> --password <your-pat>
dotnet add package KoalaSoft.Aspire.Hosting.ServiceSources.JavaScript --prerelease
Add the satellite here too, not core alongside it — the feed carries a prerelease of all
three packages per commit, so two direct references are two prereleases to keep in step.
If you use no satellite at all, dotnet add package KoalaSoft.Aspire.Hosting.ServiceSources --prerelease is the single reference to add.
Getting started
1. Declare the service in Program.cs:
using Aspire.Hosting.ServiceSources;
var builder = DistributedApplication.CreateBuilder(args);
var orders = builder.AddService("orders");
var api = builder.AddProject<Projects.Api>("api")
.WithReference(orders);
builder.Build().Run();
2. Add the shared catalog, servicesources.yaml, next to the AppHost project (commit this
file):
services:
orders:
repository: https://github.com/example/orders
project: src/Orders.Api/Orders.Api.csproj
defaultRef: main # optional; branch, tag, or commit SHA
(A service that isn't a .NET project also takes a kind — see
Non-.NET local services.)
3. Add your own servicesources.local.json next to it (gitignore this file — it's
per-developer):
{
"services": {
"orders": { "source": "local" }
}
}
That's it — running the AppHost now clones orders into
<AppHostDirectory>/.servicesources/checkouts/orders/, checks out main, and runs it via
Aspire's own project orchestration, wired up to api through service discovery exactly like
a project reference would be.
"local" source options
{
"services": {
"orders": { "source": "local" },
"payments": {
"source": "local",
"path": "/home/dev/code/payments",
"ref": "feature/new-checkout"
}
}
}
- Omit
pathfor a managed checkout: cloned once into<AppHostDirectory>/.servicesources/checkouts/<serviceName>/, and reconciled to the configuredref(or the catalog'sdefaultRef) on every run. Uncommitted edits are never discarded — if the checkout is dirty and the ref changed, resolution fails loudly instead of overwriting your work. Anything you put at that path yourself that isn't a plain clone — a linkedgit worktree, or a clone made with--separate-git-dir— is refused with an explanation rather than replaced; point at it withpathinstead. A directory there with no.gitentry at all is treated as debris from an interrupted clone and deleted, so don't hand-place a plain directory as a quick override — usepathfor that too. The.servicesources/directory gitignores itself on first use — no need to add it to your own.gitignore. - Set
pathto point at a checkout you manage yourself (e.g. an existing local clone). It's used as-is — no clone, no checkout, no fetch, ever. A relativepathis anchored to the AppHost directory, and must name a directory that already exists.refcannot be combined withpath. - Keep the file to the services you actually add.
AddService()has to hand back the real resource, so it can't wait until the AppHost has finished composing to find out which services it wants — the first call clones the checkouts for every"local"entry, in parallel. Only the services you actually add are then reconciled to their configuredref: a checkout that already exists is never touched on behalf of an entry you don'tAddService(), so work in progress on a branch there is safe. Entries you never add still cost network and disk for that first clone. The AppHost logs which ones those were at startup — and warns if one of them failed, since nothing else would ever tell you — so you know what to drop.
Several services from one repository
A catalog entry maps one service to one thing to run, so a repository holding several services
gets one entry per service — each naming the same repository, and each selecting its own part
of the tree (project for the default dotnet kind, or the kind's own options block, such as
appDirectory, for the kinds below):
services:
orders:
repository: https://github.com/example/monorepo
project: src/Orders.Api/Orders.Api.csproj
defaultRef: main
payments:
repository: https://github.com/example/monorepo
project: src/Payments.Api/Payments.Api.csproj
defaultRef: main
The catalog is the same either way; what differs is how many checkouts of that repository end
up on your machine, which each developer chooses in servicesources.local.json:
One managed checkout per service — omit
path. Managed checkouts are keyed by service name, soordersandpaymentseach get their own independent clone of the repository, at.servicesources/checkouts/orders/and.servicesources/checkouts/payments/. Each can sit on its ownrefand neither can disturb the other, but the repository is cloned once per service, and an edit to shared code in one checkout is invisible to the other.One checkout shared by every service — set
path. Clone the repository yourself, then point each service at that same directory; the entry'sproject(orappDirectory) is resolved relative to it:{ "services": { "orders": { "source": "local", "path": "/home/dev/code/monorepo" }, "payments": { "source": "local", "path": "/home/dev/code/monorepo" } } }This is usually what you want when the services share code: one clone, one branch, and an edit to a shared project is picked up by every service at once. The trade-off is that the clone is yours to manage — nothing is ever cloned, fetched or checked out on your behalf — and
refcannot be combined withpath.
Mixing the two is fine: services you're actively editing can share one path checkout while
the rest stay on managed clones.
Non-.NET local services: kind
A "local" service is resolved as a .NET project by default. Set kind in the catalog to run
the checkout some other way — the git clone/checkout is identical, only what gets built out of
the resulting directory changes:
services:
frontend:
repository: https://github.com/example/frontend
kind: javascript # optional; defaults to "dotnet"
javascript: # per-kind options block, named after the kind
appDirectory: .
runScript: dev
kind: dotnet (the default) uses the entry's project property and needs no options block.
Any other kind is resolved by a handler that a satellite package registers, and its options
live in a block named after the kind. Kind names are matched case-sensitively, and a kind with
no registered handler fails at that service's AddService() call, before its checkout is used.
JavaScript: kind: javascript
Provided by the KoalaSoft.Aspire.Hosting.ServiceSources.JavaScript package, which runs the
checkout through Aspire.Hosting.JavaScript.
Install it, then call UseJavaScript() once, before the first AddService() call:
using Aspire.Hosting.ServiceSources;
var builder = DistributedApplication.CreateBuilder(args);
builder.UseJavaScript();
var frontend = builder.AddService("frontend");
services:
frontend:
repository: https://github.com/example/frontend
kind: javascript
javascript:
appType: vite # javascript (default) | vite | nextjs | node | bun
appDirectory: web # directory holding package.json, relative to the repo root
runScript: dev # package.json script to run
packageManager: pnpm # npm | yarn | pnpm | bun
port: 4321 # the port consumers reach the service on
Keep
Aspire.Hosting.JavaScripton the same version asAspire.Hosting. Aspire releases the two together and tests them that way. They were also coupled across a friend-assembly boundary until 13.5.0:Aspire.Hosting.JavaScript13.4.6 againstAspire.Hosting13.5.x restores and compiles clean, then throwsMethodAccessExceptionthe first time akind: javascriptservice resolves. This package floors both at 13.5.2, so you get a matched pair by default. If you raiseAspire.Hostingpast that on its own, add a reference at whatever version your AppHost resolves for it — the version below is an example, not a version to copy:<PackageReference Include="Aspire.Hosting.JavaScript" Version="13.5.3" />
Every option is optional:
appType— which integration runs the app:javascript(the default,AddJavaScriptApp),vite,nextjs,node, orbun.nodeandbunexecute a file directly rather than apackage.jsonscript, so they requirescriptPath; the other three run a script and reject it.appDirectory— the directory holding the app'spackage.json, relative to the repository root, which is also the default. It must stay inside the checkout, and — for every app type that runs apackage.jsonscript — it is checked to actually hold one, so pointing it at the wrong directory of a monorepo is reported against the service rather than surfacing later as an npmcould not read package.json.runScript— thepackage.jsonscript to run; the integrations default this todev. Fornode/bunit overrides thescriptPaththey would otherwise execute directly, which needs apackage.jsoninappDirectory— without one those two app types runscriptPathand nothing else, so arunScriptset there is rejected rather than silently ignored.scriptPath— the entry-point file (e.g.server.js) relative toappDirectory. Required byappType: nodeandappType: bun, and rejected for the others. LikeappDirectoryit must stay inside the checkout, and it is checked to exist so a typo is reported against the service rather than surfacing later as acannot find modulecrash.packageManager—npm,yarn,pnpm, orbun, used to install dependencies before the app starts (a fresh clone has nonode_modules). Left unset, the integration's own default applies: npm for most app types, Bun forappType: bun.port/targetPort— the port consumers reach the service on, and the port the app itself listens on. Both are allocated by Aspire when unset.portEnv— the environment variable the app reads its listen port from; defaults toPORT. Rejected forvite/nextjs, whose integrations bind the dev server's port themselves.
The service always gets an http endpoint, so the builder AddService() returns can be passed to
a consumer's WithReference(...) like any other. Node and Bun must be on PATH for the app types
that use them.
Java: kind: java
Provided by the KoalaSoft.Aspire.Hosting.ServiceSources.Java package, which runs the checkout
through the .NET Aspire Community Toolkit's
Java integration. Install it, then call UseJava()
once, before the first AddService() call — AddService() resolves eagerly, so a kind: java
service registered after it has already run has nowhere to look up its handler:
using Aspire.Hosting.ServiceSources;
var builder = DistributedApplication.CreateBuilder(args);
builder.UseJava();
var catalog = builder.AddService("catalog");
servicesources.yaml:
services:
catalog:
repository: https://github.com/example/catalog
kind: java
java:
mavenGoal: spring-boot:run
port: 8080
The checkout is cloned exactly as for any other "local" service (path, ref, and
defaultRef all behave identically), then handed to that integration to run.
java: block options
| Field | Required | Description |
|---|---|---|
mavenGoal |
one of these three | Run via the Maven wrapper, e.g. spring-boot:run. |
gradleTask |
one of these three | Run via the Gradle wrapper, e.g. bootRun. |
jarPath |
one of these three | Run a pre-built jar with java -jar, relative to workingDirectory. May climb out of it — a monorepo's shared build output directory — but must stay inside the checkout. |
port |
yes | The port the app listens on. Becomes the service's HTTP endpoint, so consumers can WithReference(...) it. |
workingDirectory |
no (defaults to the repository root) | Where in the checkout the project lives — the directory holding pom.xml / build.gradle, and by default the mvnw/gradlew wrapper too. Must stay inside the checkout. |
wrapperPath |
no (defaults to the wrapper in workingDirectory) |
Where the mvnw/gradlew wrapper script lives, relative to the repository root — for the monorepo that commits a single wrapper at its root while the service itself sits further down. Name it without an extension (gradlew, not gradlew.bat) and it works for the whole team: on Windows the .cmd/.bat wrapper beside it is the one run. Only meaningful with mavenGoal or gradleTask. |
args |
no | Extra arguments for whichever run mode is configured — passed to the Maven wrapper, the Gradle wrapper, or the jar. |
mavenGoal, gradleTask, and jarPath are mutually exclusive: exactly one must be set. A
monorepo service, running a Gradle task with an extra argument:
services:
catalog:
repository: https://github.com/example/monorepo
kind: java
java:
workingDirectory: services/catalog
gradleTask: bootRun
wrapperPath: gradlew
args: ["--args=--spring.profiles.active=dev"]
port: 8080
A multi-project Gradle repository (like a multi-module Maven one) commits a single wrapper at its
root rather than one per project, which is what wrapperPath: gradlew names here — without it the
wrapper is looked for in services/catalog, beside the project.
mavenGoal and gradleTask run the repository's own mvnw/gradlew wrapper, so a JDK must be
on the developer's machine but Maven/Gradle itself need not be. That wrapper has to be in the
checkout — there is no fallback to a system-wide mvn/gradle — so a checkout without one is
reported as such, rather than left to surface as a failure to start the app. On Windows the wrapper
run is mvnw.cmd/gradlew.bat, whether it was found by default or named by wrapperPath: the
extensionless scripts beside them are POSIX shell scripts that Windows cannot exec.
Every problem with the block bar two — unknown properties, a missing or out-of-range port, no run
mode or more than one, a workingDirectory, wrapperPath or jarPath escaping the repository, a
wrapperPath set alongside jarPath — is reported by the AddService("catalog") call itself,
before the service has added anything to the app model. The two exceptions are a workingDirectory
that doesn't exist in the checkout and a wrapper script that isn't there: both need the checkout on
disk, which isn't cloned until the block itself has been checked, so they are reported a moment
later, once the resource is being created.
Reaching the rest of the Java integration. The java: block covers how to start the app; it
deliberately doesn't mirror every modifier the Community Toolkit offers. Anything else is reachable
from the AppHost with As<JavaAppExecutableResource>(), which hands back the real resource builder:
builder.AddService("catalog")
.As<JavaAppExecutableResource>()
.WithMavenBuild() // compile before starting
.WithJvmArgs(["-Xmx512m"])
.WithOtelAgent("/path/to/opentelemetry-javaagent.jar");
Use Configure<T>(...) instead for anything that should survive a developer switching that service
to a non-local source — As<T>() throws if the service no longer resolves to a Java resource,
which is the point when the AppHost genuinely requires one.
UseJava() is exported to Aspire's Type System, so a TypeScript AppHost can call useJava()
before addService(...) the same way.
Implementing a kind
A satellite package implements ILocalResourceKind and registers it from its own extension
method:
public sealed class JavaScriptKind : ILocalResourceKind
{
private sealed class Options
{
public string? AppDirectory { get; set; }
public string? RunScript { get; set; }
}
// Optional, and worth implementing whenever Resolve parses rawConfig: this runs immediately
// before Resolve, and before this service's checkout, so a typo'd options block is reported
// without a half-created resource behind it and without paying for a clone first.
public void Validate(string serviceName, object? rawConfig) =>
LocalKindConfig.Parse<Options>(rawConfig, serviceName);
public IResourceBuilder<IResourceWithServiceDiscovery> Resolve(
IDistributedApplicationBuilder builder, string serviceName, string repoRoot, object? rawConfig)
{
// repoRoot is the already-cloned, already-checked-out directory.
var options = LocalKindConfig.Parse<Options>(rawConfig, serviceName);
...
}
}
public static IDistributedApplicationBuilder UseJavaScript(this IDistributedApplicationBuilder builder) =>
builder.AddLocalKind("javascript", new JavaScriptKind());
LocalKindConfig.Parse<T> turns the opaque options block into a typed object, and rejects an
unknown property or a block that isn't a mapping with a ServiceSourcesConfigurationException
naming the service. AddLocalKind must be called before the AddService() call for a service of
that kind — resolution is eager, so registering later is too late — accepts each kind name at most
once, and cannot re-register "dotnet" or use a
name that collides with a well-known service property (repository, project, defaultRef,
kind, kubernetes, url, container) — a block by one of those names would be read as that
property rather than as the kind's options.
Private repositories
Clone and fetch for a managed checkout (no path override) authenticate the same way, in order:
- Your
gitcredential helper. The managed checkout shells out togit credential fillfor the repository's host, so whatever you already have configured — Git Credential Manager,osxkeychain,libsecret, a cached PAT, a.netrc-backed helper — is reused automatically. Nothing to configure here beyond havinggitonPATHwith a working credential helper (rungit credential fillyourself against the same host to confirm it resolves before wiring it up here). SERVICESOURCES_GIT_USERNAME/SERVICESOURCES_GIT_TOKENenvironment variables, if the helper above yields nothing (e.g. no helper configured, orgitisn't onPATH) — or if what it yielded was refused, see below.SERVICESOURCES_GIT_TOKENalone is enough for hosts that accept any username alongside a personal access token (GitHub, GitLab, Azure DevOps); setSERVICESOURCES_GIT_USERNAMEtoo if your host requires a specific one.
The order is a ladder, not a one-shot choice: if the host refuses the credential your helper supplied, the environment variables are tried next, and only then the request is left unauthenticated. Each credential is offered once per clone or fetch — a refused one is never replayed.
A credential the host actually refuses is also reported back to your helper with
git credential reject, exactly as git itself does, so Git Credential Manager, osxkeychain,
libsecret and friends erase their stored copy and resolve afresh next time instead of serving the
same dead token on every run. That only happens on an outright rejection of the credential
(HTTP 401); a "not found" answer never erases anything, since a repository your credential simply
can't see is at least as likely an explanation as a bad credential. Rotating a token therefore
takes effect on the next resolution — there's no need to restart the AppHost to clear a cached one.
Credentials are never read from servicesources.yaml (committed) or servicesources.local.json
— there's no field for them in either file, by design, so a secret can't accidentally end up in
the committed catalog. The one way to get one in there anyway is to embed it in the repository
URL itself (https://user:token@host/org/repo); git accepts that form, but it commits the token
along with the catalog, so use one of the two mechanisms above instead. Should such a URL be
configured regardless, every message this tool prints strips the userinfo from it first, so the
token doesn't spread from the catalog into your console and logs.
A clone or fetch that fails for what looks like an authentication reason raises an error naming
the service, the repository, and authentication as the likely cause, rather than a generic
"failed to clone" message. This includes a "not found" response: GitHub, GitLab and Azure DevOps
all answer an unauthenticated request for a private repository with 404 rather than 401, so as not
to leak whether it exists, so the error covers both readings — bad credentials, or a repository
the credentials in use can't see. A rate-limited response is deliberately left out, even though
hosts answer it with the same 403 as a token that's missing a scope: there the credential is
fine and the fix is to wait, so it's reported as the transport failure it is.
SSH is not supported. LibGit2Sharp's bundled native binaries don't include an SSH transport,
so a repository written as git@host:org/repo, host:org/repo or ssh://... fails fast at
resolution time with a message pointing at the HTTPS equivalent — use https://host/org/repo
instead. The same check covers an existing checkout whose origin is an SSH remote, before any
fetch is attempted against it.
"kubernetes" source
Point a service at an already-running instance in a Kubernetes dev cluster via
kubectl port-forward, instead of running it locally at all.
servicesources.yaml:
services:
orders:
kubernetes:
service: orders-svc
port: 8080
servicesources.local.json:
{
"services": {
"orders": {
"source": "kubernetes",
"context": "dev-west",
"namespace": "orders",
"port": 8080
}
}
}
Requires kubectl on PATH, authenticated against the named context.
"url" source
Point a service at a fixed, already-known URL — e.g. a Kubernetes ingress, a staging deployment, or any other reachable HTTP(S) endpoint. There's no underlying resource for Aspire to run; the endpoint resolves straight to the configured URL.
Two consequences follow from the service running out of band: the AppHost's
Configure calls are skipped and logged, and a container
can't WithReference it — a project or executable can — which fails with a clear error rather than
a DCP stack trace. See #58.
servicesources.yaml:
services:
orders:
url:
url: https://orders.example.com
servicesources.local.json:
{
"services": {
"orders": { "source": "url" }
}
}
Set url in the developer config instead to override the catalog's URL for just that
developer (e.g. pointing at a personal tunnel or local proxy):
{
"services": {
"orders": { "source": "url", "url": "https://orders.dev.internal" }
}
}
"container" source
Run a published container image locally via Aspire's own container-runtime integration — image pull and lifecycle are managed entirely by Aspire.
servicesources.yaml:
services:
orders:
container:
image: ghcr.io/company/orders
port: 8080
defaultTag: latest
servicesources.local.json:
{
"services": {
"orders": { "source": "container" }
}
}
Set tag in the developer config to override the catalog's defaultTag for just that
developer:
{
"services": {
"orders": { "source": "container", "tag": "v1.4.2" }
}
}
Combining sources on one catalog entry
A single servicesources.yaml entry can carry blocks for every source at once — the catalog
just describes how each source would resolve the service; each developer's
servicesources.local.json picks which one actually applies to them:
services:
orders:
repository: https://github.com/example/orders
project: src/Orders.Api/Orders.Api.csproj
kubernetes:
service: orders-svc
port: 8080
url:
url: https://orders.example.com
container:
image: ghcr.io/example/orders
port: 8080
defaultTag: latest
A developer editing the service picks "local"; one debugging against a shared dev cluster
picks "kubernetes"; one who just needs it reachable picks "url" or "container" — same
catalog entry, same AddService("orders") call in the AppHost, no code changes either way.
Each developer's own servicesources.local.json just names which source applies to them —
editing orders locally:
{ "services": { "orders": { "source": "local" } } }
debugging against a shared dev cluster:
{ "services": { "orders": { "source": "kubernetes", "context": "dev-west", "namespace": "orders", "port": 8080 } } }
or just needing it reachable, not caring how:
{ "services": { "orders": { "source": "url" } } }
Configuring a resolved service
AddService() returns a builder over the real resource Aspire runs, so the AppHost can inject
its own configuration — connection strings, generated secrets, a sibling's endpoint, wait ordering.
Values like these come from the AppHost's own graph and can't be written into
servicesources.yaml/servicesources.local.json.
The resolved resource's type depends on the source, which each developer chooses, so name the capability you need and it is checked at composition time:
var backend = builder.AddService("backend")
.Configure<IResourceWithEnvironment>(r => r
.WithReference(planningDb)
.WithEnvironment("DBPASSWORD", postgres.Resource.PasswordParameter)
.WithEnvironment("ENCRYPTIONKEY", builder.AddParameter("EncryptionKey", new GenerateParameterDefault(), secret: true))
.WithEnvironment("Services__CommonAuth", commonAuth.GetEndpoint("https")))
.Configure<IResourceWithWaitSupport>(r => r.WaitForCompletion(migrationService));
As<T>() is the same cast without the callback, and reaches anything Configure would — including
a satellite kind's own extension methods:
backend.As<JavaScriptAppResource>().WithRunScript("dev");
Configure is skipped for the "url" and "kubernetes" sources, and the skip is logged at
startup. Both resolve to something already running elsewhere — a "url" service has no local
process at all, and a "kubernetes" service is a kubectl port-forward in front of a remote one,
so environment variables applied here would configure kubectl rather than the service. Those
services are expected to be configured wherever they actually run.
The one exception is wait ordering on a "kubernetes" service, which still applies:
Configure<IResourceWithWaitSupport> (and WaitForService / WaitForServiceCompletion) reach a
real, registered kubectl port-forward executable, and holding that back until a migration
finishes is exactly what the AppHost asked for. Only configuration that would land on the wrong
process is dropped. A "url" service skips wait ordering too, since it has no registered resource
for Aspire to hold back.
Skipping rather than failing is deliberate: a developer switching a service to a remote source in
their own servicesources.local.json must not break a Program.cs they don't own. You'll see:
warn: Aspire.Hosting.ServiceSources
Service 'backend': skipped Configure<IResourceWithEnvironment> because its source is
'kubernetes' — it resolves to a 'kubectl port-forward' in front of an already-running
service, so the configuration would reach kubectl rather than the service. ...
As<T>() throws for those sources instead of skipping — it has to return a builder, and handing
back the kubectl executable would silently configure the wrong process. Prefer Configure for
anything that should survive a source switch. It follows the same wait-ordering exception:
As<IResourceWithWaitSupport>() on a "kubernetes" service returns the port-forward's builder
rather than throwing.
From a guest-language AppHost
Requires Aspire CLI 13.6.0 or newer, which is not released yet. Everything below registers correctly on earlier CLIs, but the TypeScript SDK the CLI generates from it does not compile on them - see the compatibility note under the sample for what fails and why.
Configure<T> is generic, and Aspire's Type System projects a generic method with its type
parameter erased — so guest languages get a set of non-generic equivalents instead, one per shape
(overloads don't survive codegen either):
const payments = await builder
.addService('payments')
.withServiceEnvironment('DEMO_INJECTED_BY_APPHOST', 'true')
.withServiceReference(inventory);
| TypeScript | C# equivalent |
|---|---|
withServiceEnvironment(name, value) |
.Configure<IResourceWithEnvironment>(r => r.WithEnvironment(name, value)) |
withServiceEnvironmentFromParameter(name, parameter) |
…WithEnvironment(name, parameter) |
withServiceEnvironmentFromEndpoint(name, endpoint) |
…WithEnvironment(name, endpoint) |
withServiceReference(other) |
…WithReference(other) |
withServiceConnectionString(source) |
…WithReference(source) |
waitForService(dependency) |
.Configure<IResourceWithWaitSupport>(r => r.WaitFor(dependency)) |
waitForServiceCompletion(dependency, { exitCode }) |
…WaitForCompletion(dependency, exitCode) |
withServiceArg(arg) |
.Configure<IResourceWithArgs>(r => r.WithArgs(arg)) |
They delegate to Configure<T>, so out-of-band sources are skipped and logged exactly as above —
including the wait-ordering exception, which waitForService and waitForServiceCompletion inherit.
In C# they're hidden from IntelliSense — use Configure<T>, which reaches every Aspire extension
method rather than just these.
Sample
samples/DemoAppHost is a minimal working AppHost demonstrating all three easily-runnable
sources: orders via a real managed "local" git checkout (a small project cloned from
dotnet/aspire-samples), inventory via the
"url" source (pointing at httpbin.org, a live public test API), and
payments via the "container" source (the nginxdemos/hello hello-world image) — run it to
see the whole flow end to end. ("kubernetes" isn't demoed here since it needs a real cluster
and kubectl; see its section above.)
It also carries a catalog service showing kind: java — a "local" checkout of
Spring PetClinic run with its own Maven
wrapper. builder.UseJava() is wired up, but AddService("catalog") is commented out and the
service is left out of servicesources.local.json.example, since unlike the three above it needs a
JDK. To run it, do both: uncomment the call and add "catalog": { "source": "local" } to your
servicesources.local.json. Leaving it out of that file by default is what keeps the sample from
cloning PetClinic on every run — the first AddService prefetches every "local" entry there,
whether or not you add it.
cd samples/DemoAppHost
cp servicesources.local.json.example servicesources.local.json
aspire run
A TypeScript AppHost equivalent — proving AddService() is correctly exported and registers with
Aspire's Type System from a guest language, and that a resolved service can be
configured from TypeScript — lives in
samples/DemoAppHostTypeScript. Both of its services use the "container" source so that
payments can withServiceReference(inventory): a "url" service runs out of band, and a
container consumer of one is rejected up front. A third resource, the probe
executable, hands the same inventory handle to Aspire's own withReference() and prints the
services__inventory__http__0 variable that injects — so it shows as Exited, not Running, and
that single log line is where you see the native service-discovery path working. (Note: this
sample requires Aspire CLI 13.6.0 or newer — see the compatibility note below the code block.)
cd samples/DemoAppHostTypeScript
npm install
cp servicesources.local.json.example servicesources.local.json
aspire restore
aspire run
Requires Aspire CLI 13.6.0+: on every CLI released so far - 13.4.6 through 13.5.3 -
aspire restore/aspire add correctly registers addService(name: string) in the generated
TypeScript SDK (.aspire/modules/aspire.mts) with no diagnostics — confirming the
[AspireExport] on AddService works — but the generated SDK fails to compile
(TS2552: Cannot find name 'ResourceWithServiceDiscoveryPromise', six errors) because the Aspire
CLI's TypeScript codegen didn't emit a *Promise/*PromiseImpl wrapper pair for extension methods
returning a bare Aspire interface type (IResourceBuilder<IResourceWithServiceDiscovery>) rather
than a concrete resource class.
This was reported as microsoft/aspire#19507 and
fixed by microsoft/aspire#19577, which merged to
main on 2026-08-22 under the 13.6 milestone. No released CLI carries it, 13.5.3 included.
Verified against a build of that PR (13.6.0-pr.19577.gfa0aea2c): the generated SDK type-checks
clean under strict tsc, and the sample runs end-to-end — withReference() on the
addService() result injects the resolved service's discovery variables into the consuming
resource, e.g. services__inventory__http__0=http://localhost:<port> pointing at the running
inventory container. The same sample regenerated with 13.5.1 still reproduces all six
TS2552 errors.
Switching between CLI builds can leave a stale code generator under .aspire/, so remove that
directory before regenerating:
microsoft/aspire#19603.
Status
Early stage, evolving fast. "local", "kubernetes", "url", and "container" sources are
all implemented — see docs/superpowers/ for design and implementation
history, including the phase 2 backlog (repo auto-update, config discovery walk-up,
dependency/infrastructure resolution, and more).
Changes are recorded in CHANGELOG.md; how a release is cut is in
RELEASING.md.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 is compatible. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- CommunityToolkit.Aspire.Hosting.Java (>= 13.3.0)
- KoalaSoft.Aspire.Hosting.ServiceSources (>= 0.3.1 && < 0.4.0)
-
net8.0
- CommunityToolkit.Aspire.Hosting.Java (>= 13.3.0)
- KoalaSoft.Aspire.Hosting.ServiceSources (>= 0.3.1 && < 0.4.0)
-
net9.0
- CommunityToolkit.Aspire.Hosting.Java (>= 13.3.0)
- KoalaSoft.Aspire.Hosting.ServiceSources (>= 0.3.1 && < 0.4.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.3.1 | 45 | 8/27/2026 |
Publishes the two satellite packages, which `0.3.0` could not. Core `0.3.0` is on nuget.org
and is unchanged by this release in everything but its version number — there is no reason to
move a core-only AppHost off it.
### Fixed
- **`KoalaSoft.Aspire.Hosting.ServiceSources.Java` and `.JavaScript` are published again**
(#117). Both satellites were rejected by nuget.org during the `0.3.0` release and exist at
no version on that feed; `0.3.1` is the first release either of them reaches it at. The
`0.3.0` core package published normally in the same run, so a `0.3.0` AppHost using only
core is unaffected.
The cause was the upper bound introduced in `0.3.0` to stop a satellite pairing with a
next-minor core (#79). It closed the range with a prerelease bound so that the next minor's
*prereleases* were excluded along with its release:
```xml
<dependency id="KoalaSoft.Aspire.Hosting.ServiceSources" version="[0.3.0, 0.4.0-0)" />
```
nuget.org's gallery refuses that at push time — `The package manifest contains an invalid
Version: '0.4.0-0'`, HTTP 400 — while the NuGet client, `dotnet pack`, `restore` and GitHub
Packages all accept it ([NuGetGallery#6948], open). `pack` emits only NU5104, a warning. So
the bound was correct on every surface the repository could observe, and wrong on the single
surface a release touches.
The bound is now chosen per build: `-0` on a prerelease build, which is what the GitHub
Packages preview feed receives, and a plain `0.4.0` on a stable one, which is what nuget.org
receives:
```xml
<!-- release build, pushed to nuget.org -->
<dependency id="KoalaSoft.Aspire.Hosting.ServiceSources" version="[0.3.1, 0.4.0)" />
<!-- preview build, pushed to GitHub Packages -->
<dependency id="KoalaSoft.Aspire.Hosting.ServiceSources" version="[0.3.1-alpha.0.7, 0.4.0-0)" />
```
Nothing is lost by the plain bound on nuget.org: every prerelease of these packages goes to
GitHub Packages, so there is no `0.4.0-*` on nuget.org for it to admit. The pairing guarantee
`0.3.0` documented still holds on both feeds.
### Changed
- CI packs the release shape as well as the prerelease one, and fails on a stable package whose
nuspec declares a prerelease version anywhere (#117). Every pack before this ran off a tag
and so was always a prerelease, which is why `0.3.0` passed every check and then failed the
push. `RELEASING.md` records the rest of the process.
Full changelog: https://github.com/flojon/aspire-servicesources/blob/main/CHANGELOG.md