
Verteilte .NET-Anwendungen lokal aufzusetzen kostet oft Zeit: Container, Connection-Strings, Startreihenfolge und Observability werden meist von Hand verdrahtet. Aspire beschreibt die gesamte Topologie als versionierbaren C#-Code im AppHost. Datenbanken und Caches starten automatisch, Service Discovery und OpenTelemetry sind ab der ersten Zeile aktiv, und ein zentrales Dashboard bündelt Logs, Traces und Metriken. Das App-Modell lässt sich zudem per Aspire.Hosting.Testing als Ganzes integrationstesten. Ergebnis: schnelleres Onboarding, reproduzierbare Umgebungen und weniger „Bei mir lief es doch". Für Teams, die regelmäßig verteilte .NET-Projekte lokal betreiben, lohnt ein genauer Blick.
Zwei Dinge wird ein Entwickler im Laufe seines Lebens garantiert hören: Das eine sind Datenstrukturen und Algorithmen - das andere ist der Satz „Bei mir lief es doch!", kurz nachdem jemand auf Produktion deployed hat.
Gerne wird das als humorvolles Geplänkel abgetan, als Eigenart der Maschinen und ihrer Spezifikationen: ein anderes Betriebssystem, eine vergessene SDK-Version, die nie jemand aktualisiert hat, weil sich die „Never change a running system"-Mentalität tief in unser Ausreden-Repertoire eingegraben hat. Auf lange Sicht kostet genau das aber erstaunlich viel Zeit - und Zeit ist bekanntlich die einzige Währung, die man mit Geld nicht kaufen kann. Jede Minute, die hier und da fürs Debuggen draufgeht oder dafür, eine neue Kollegin ins laufende Projekt einzuarbeiten, summiert sich.
Egal, ob reines .NET-Projekt oder ein bunter Mix aus JavaScript-Frontend und Python-Backend: Der Kern der meisten Frustration lag (zumindest in der Praxis) immer darin, wie einfach – oder eben nicht – sich ein Projekt lokal aufsetzen und bearbeiten lässt. Ist die Umgebung einmal eingerichtet, läuft sie ja. Nur passt eben nicht alles sauber in eine Projektstruktur: Abhängigkeiten wie Datenbanken oder Caching-Provider gehören zum Projekt dazu, leben aber außerhalb davon.
Mit Containerisierung und Orchestrierung über Docker und Docker Compose wurde das über die Jahre deutlich einfacher – ein besonders willkommener Schritt für Windows-Rechner, auf denen WSL (Windows Subsystem for Linux) zunehmend Fuß fasst, während Hosting-Anbieter auf robuste, enterprise-erprobte und überwiegend Linux-basierte Server setzen. Systeme ließen sich leichter aufsetzen, aber sofern einem nicht ein DevOps-Kollege freundlicherweise ein fertiges Docker-Compose-Skript hinterlegt hatte, musste man die lokale Umgebung doch wieder von Hand konfigurieren. Und damit blieb genau die Fehlerquelle, die sich zum klassischen „Aber bei mir lief es doch!" auswächst.
.NET Aspire (seit Version 13 schlicht Aspire) will das ändern – und zwar nicht nur für .NET-Projekte, sondern auch für die eben erwähnten mehrsprachigen Setups. Man darf sich Aspire als den Sandkasten vorstellen, der bereits alle Bauanleitungen und die spaßigen Integrationen mitbringt, damit sämtliches Spielzeug automatisch „einfach funktioniert" und harmonisch zusammenspielt – ganz ohne, dass man seine lokale Umgebung vorab präparieren muss, nur um einen Bug an einem laufenden System zu testen.
Erreicht wird das, indem Aspire die Docker-Engine zur Orchestrierung nutzt und die gesamte Konfiguration in einem C#-Projekt bündelt (mit .NET 10 sogar als einzelnes file-based Projekt). Jede notwendige Einstellung ist damit vorkonfiguriert und einsatzbereit – starten lässt sich das Ganze mit einem einzigen CLI-Befehl wie aspire run.
Dieser Artikel demonstriert die Möglichkeiten von Aspire rund um Orchestrierung, zeigt einige der mitgelieferten Features und wie man sie in bestehende .NET-Projekte integriert, um Onboarding und Local Development spürbar zu beschleunigen. Der vollständige Quellcode samt aller hier gezeigten Snippets liegt öffentlich auf GitHub: github.com/tavanuka/Aspire.Sample.
Eine moderne Cloud-native-Anwendung besteht selten aus einem einzelnen Prozess. In unserem Beispiel sind es bereits fünf bewegliche Teile:
Von Hand bedeutet das: Container starten, Reihenfolge beachten (die API darf erst los, wenn die Migrationen durch sind), Ports vergeben, Connection-Strings durchreichen, und am Ende noch irgendwie nachvollziehen, welcher der fünf Prozesse gerade beschlossen hat, nicht zu funktionieren. Das ist kein intellektuell anspruchsvolles Problem – es ist nur lästig, fehleranfällig und für jede neue Person im Team aufs Neue zu erklären.
Aspire dreht den Spieß um. Statt einer Sammlung von Skripten und einer README mit zwölf Schritten beschreibt man die gesamte Topologie der Anwendung in C# – in einem eigenen Projekt, dem AppHost. Dieses Projekt ist der zentrale Einstiegspunkt: Man startet es, und Aspire kümmert sich um den Rest.
So sieht das komplette App-Modell unseres Beispiels aus – und ja, das ist tatsächlich alles:
1var builder = DistributedApplication.CreateBuilder(args);
2
3var postgres = builder.AddPostgres("postgres")
4 .WithDataVolume()
5 .WithPgWeb()
6 .WithLifetime(ContainerLifetime.Persistent);
7
8var coreDb = postgres.AddDatabase("core-db");
9
10var cache = builder.AddRedis("cache")
11 .WithClearCommand(); // Custom command that flushes the current cache
12
13// Migration service that separates the concern for population of the database and applying migrations.
14var migrationWorker = builder.AddProject<Projects.Sample_MigrationWorker>("migration-worker")
15 .WithReference(coreDb)
16 .WaitFor(coreDb);
17
18var apiService = builder.AddProject<Projects.Sample_ApiService>("apiservice")
19 .WithReference(migrationWorker)
20 .WaitForCompletion(migrationWorker) // Ensures database is populated before starting the service
21 .WithReference(coreDb)
22 .WaitFor(coreDb)
23 .WithHttpHealthCheck("/health");
24
25builder.AddProject<Projects.Sample_Web>("webfrontend")
26 .WithExternalHttpEndpoints()
27 .WithReference(cache)
28 .WaitFor(cache)
29 .WithReference(apiService)
30 .WaitFor(apiService);
31
32builder.Build().Run();
Lesen Sie diese Datei einmal von oben nach unten – und Sie haben das gesamte System verstanden. Genau das ist der Punkt. Ein paar Details, die mehr leisten, als sie auf den ersten Blick verraten:
docker run von Hand absetzen.Wie findet das Frontend nun die API? Nicht über einen hartkodierten Port, sondern über den logischen Namen aus dem App-Modell:
1builder.Services.AddRefitClient<IWeatherApiClient>()
2 .ConfigureHttpClient(client =>
3 // "https+http://" signalisiert: HTTPS bevorzugt, HTTP als Fallback.
4 client.BaseAddress = new("https+http://apiservice/api/weatherforecast"));apiservice ist exakt der Name, unter dem die API im AppHost registriert ist. Die Auflösung übernimmt die Service Discovery von Aspire. Möglich macht das ein gemeinsames ServiceDefaults-Projekt, das jeder Service mit einem einzigen Aufruf einbindet – builder.AddServiceDefaults(). Darin stecken, ohne dass man sie einzeln verkabeln müsste: Service Discovery, ein Standard-Resilience-Handler (Retry, Timeout, Circuit Breaker), Health Checks und vollständige OpenTelemetry-Instrumentierung für Logs, Metriken und Traces. Observability ist hier also kein Projekt für nächstes Quartal, sondern Standard ab der ersten Zeile.

Startet man den AppHost, öffnet sich automatisch das Aspire-Dashboard – eine Weboberfläche, die alle Ressourcen, ihre Logs, verteilten Traces und Metriken an einer Stelle bündelt. Das übliche Jonglieren mit einem halben Dutzend Terminals reduziert sich auf einen einzigen Browser-Tab.
Das Schöne daran: Das Dashboard ist erweiterbar. In unserem Beispiel hängt am Redis-Cache ein eigener Button namens „Clear cache". Statt sich im laufenden Betrieb in einen Container einzuloggen und FLUSHALL zu tippen, klickt man im Dashboard. Realisiert wird das über eine Custom Command – und dank C#-13-Extension-Members liest sich die Registrierung erfreulich sauber:
1public static class RedisResourceBuilderExtensions
2{
3 extension(IResourceBuilder<RedisResource> builder)
4 {
5 public IResourceBuilder<RedisResource> WithClearCommand()
6 {
7 var commandOptions = new CommandOptions
8 {
9 UpdateState = OnUpdateResourceState,
10 IconName = "TrayItemRemove",
11 IconVariant = IconVariant.Filled
12 };
13
14 return builder.WithCommand("clear-cache", "Clear cache",
15 context => OnRunClearCacheCommandAsync(builder, context),
16 commandOptions);
17 }
18 }
19
20 private static async Task<ExecuteCommandResult> OnRunClearCacheCommandAsync(
21 IResourceBuilder<RedisResource> builder, ExecuteCommandContext context)
22 {
23 var cs = await builder.Resource.GetConnectionStringAsync()
24 ?? throw new InvalidOperationException($"Unable to get the '{context.ResourceName}' connection string.");
25
26 await using var c = await ConnectionMultiplexer.ConnectAsync(cs);
27 await c.GetDatabase().ExecuteAsync("FLUSHALL");
28
29 return CommandResults.Success();
30 }
31}Das UpdateState-Delegate ist dabei ein netter Detail-Fanatiker-Moment: Der Button ist nur dann aktiv, wenn der Cache laut Health-Status auch wirklich gesund ist. Man kann also nicht versehentlich einen Cache leeren, der gerade gar nicht erreichbar ist.

In dieselbe Kerbe schlagen Kleinigkeiten wie WithPgWeb() (eine Web-UI für PostgreSQL, ebenfalls per Klick im Dashboard erreichbar) und die Einbindung einer Scalar-API-Referenz, die die OpenAPI-Endpunkte der API hübsch dokumentiert. Nichts davon ist welterschütternd – aber in Summe sind es genau die Reibungspunkte, die sonst täglich Zeit fressen.


Ein deklaratives App-Modell hat einen willkommenen Nebeneffekt: Man kann es als Ganzes testen. Mit dem Paket Aspire.Hosting.Testing lässt sich der komplette Anwendungsgraph in einem Test hochfahren – inklusive Container. Im Beispiel-Repository kommt dafür xUnit v3 zum Einsatz; wer es lieber moderner und ganz ohne Attribut-Magie mag, kann genauso gut zu TUnit greifen – Aspire.Hosting.Testing ist bewusst Test-Framework-agnostisch und schreibt einem nichts vor.
1[Fact]
2public async Task GetWebResourceRootReturnsOkStatusCode()
3{
4 var cancellationToken = TestContext.Current.CancellationToken;
5
6 var appHost = await DistributedApplicationTestingBuilder
7 .CreateAsync<Projects.Sample_AppHost>(cancellationToken);
8
9 appHost.Services.ConfigureHttpClientDefaults(clientBuilder =>
10 clientBuilder.AddStandardResilienceHandler());
11
12 await using var app = await appHost.BuildAsync(cancellationToken)
13 .WaitAsync(DefaultTimeout, cancellationToken);
14 await app.StartAsync(cancellationToken).WaitAsync(DefaultTimeout, cancellationToken);
15
16 var httpClient = app.CreateHttpClient("webfrontend");
17 await app.ResourceNotifications
18 .WaitForResourceHealthyAsync("webfrontend", cancellationToken)
19 .WaitAsync(DefaultTimeout, cancellationToken);
20
21 var response = await httpClient.GetAsync("/", cancellationToken);
22
23 Assert.Equal(HttpStatusCode.OK, response.StatusCode);
24}Der Test spricht für sich: Dieselbe Projects.Sample_AppHost-Definition, die auch im Entwicklungsbetrieb läuft, wird hier in der Testumgebung gestartet. CreateHttpClient("webfrontend") liefert einen Client, der – wieder über den logischen Namen – direkt auf das Frontend zeigt. WaitForResourceHealthyAsync wartet geduldig, bis die Ressource gesund ist, bevor der erste Request abgeht. Keine geratenen Timeouts, kein „in der CI ist es halt manchmal zu langsam".
1[Theory]
2[InlineData("migration-worker", "core-db")]
3[InlineData("apiservice", "core-db")]
4[InlineData("apiservice", "migration-worker")]
5[InlineData("webfrontend", "cache")]
6[InlineData("webfrontend", "apiservice")]
7public async Task ResourceHasReferenceToExpectedResource(string resourceName, string referencedResourceName)
8{
9 // Arrange
10 var cancellationToken = TestContext.Current.CancellationToken;
11 var appHost = await DistributedApplicationTestingBuilder.CreateAsync<Projects.Sample_AppHost>(cancellationToken);
12
13 await using var app = await appHost.BuildAsync(cancellationToken);
14
15 var model = app.Services.GetRequiredService<DistributedApplicationModel>();
16
17 // Act
18 var resource = model.Resources.Single(r => r.Name == resourceName);
19 var references = resource.Annotations
20 .OfType<ResourceRelationshipAnnotation>()
21 .Select(a => a.Resource.Name);
22
23 // Assert
24 Assert.Contains(referencedResourceName, references);
25}Weitere Testfälle und Integrationstests finden Sie im Quellcode-Repository im Projekt Sample.AppHost.Tests.
Wer das Repository selbst ausprobieren möchte, braucht erfreulich wenig:
Danach genügt im Wurzelverzeichnis ein einziger Befehl:
aspire run
# alternativ, ganz ohne CLI:
dotnet run --project Sample.AppHostDas Dashboard öffnet sich von selbst, alle Container fahren hoch, und die Anwendung steht. Kein zwölfstufiges README-Ritual.
.NET Aspire löst kein Problem, das sich mit genügend Disziplin und einer langen README nicht auch von Hand lösen ließe. Aber genau darum geht es: Aspire nimmt einem die Disziplin ab, die ohnehin niemand gerne aufbringt. Die Topologie der Anwendung lebt an einer Stelle, in einer Sprache, die das Team bereits spricht. Container, Connection-Strings, Startreihenfolge und Observability sind keine Tribal-Knowledge-Themen mehr, sondern Code – versioniert, reviewbar und testbar.
Für das Onboarding ist das ein spürbarer Unterschied. „Klone das Repo, installiere das .NET-SDK und eine Container-Runtime, User Secrets einrichten, dann aspire run" ist eine Anleitung, die auf einen Bierdeckel passt und an einem Vormittag nicht scheitert. Der eingangs gefürchtete Satz „Bei mir lief es doch!" verliert dabei seinen Schrecken – denn auf allen Maschinen läuft jetzt nachweislich dasselbe Modell.
Die Einschätzung nach diesem Proof of Concept: Aspire ist kein Hochglanz-Selbstzweck, sondern angenehm pragmatisch. Es glänzt nicht durch das, was es hinzufügt, sondern durch das, was es einem erspart. Wer regelmäßig verteilte .NET-Anwendungen lokal startet, sollte sich das genauer ansehen.
Probieren Sie es aus: Das vollständige Beispiel finden Sie unter github.com/tavanuka/Aspire.Sample. Einmal klonen, einmal starten – und dann selbst entscheiden, ob die zwölf Terminals fehlen.

Unser Geschäftsführer Tibor Csizmadia und unser Kundenbetreuer Jens Walter stehen Ihnen persönlich zur Verfügung. Profitieren Sie von unserer langjährigen Erfahrung und erhalten Sie eine kompetente Beratung in einem unverbindlichen Austausch.