Orchestration with .NET Aspire

🕰️Keeping Up With The Times

Back in May of this year, Microsoft made .NET Aspire, a new stack that streamlines development of .NET cloud-native services, generally available for public consumption. .NET Aspire brings together tools, templates, and NuGet packages that help developers build distributed applications in .NET more easily by orchestrating the integration between them and applying some defaults Microsoft recommend setting up out of the gate.

Since then I, like most .NET developers out in the wild, have been quietly and not so quietly researching ways to leverage this new tooling and developer experience to my advantage.

After a few weeks of on & off reading and watching of Youtube videos, head scratching and bug reporting, I finally decided to get stuck in proper and spend some significant time building up my own .NET Aspire sample project. Bringing together stripped down versions of my IdentityServer and client apps and bringing in some opinionated enhancements, I finally released my sample repo on GitHub at the end of this past July.

🤔What to Include?

Assuming reader, you’re already fairly aware of what .NET Aspire is and what the basic solution structure looks like, I’ll spare you the boring details by skipping over the AppHost and ServiceDefaults projects – I’ve made no major alterations to these and aside from some code-style decisions, they’re both pretty stock. So the decision is what of my portfolio to include? What resources do I bring in?

Since my demo MVC PAR app uses both an identity provider and sample weather API as external services, I thought I’d bring them in and build up a full package – something that anyone could just clone and run out of the gate, without relying on resources outside the solution.

What about other external services? I have a SQL Server instance that I use for persistence in my Identity Provider. There’s an Aspire resource package for that so lets include that too. My Client and Weather API both use REDIS for response caching, lucky me, there’s an Aspire resource for REDIS too!

Ultimately, I ended up with a solution consisting of the base two Aspire projects, my Identity Provider, Client App and sample Weather API. For completeness I’ve added an empty Tests project in there for laters.

🔧Building Up The Aspire AppHost

The AppHost project is the backbone that brings together the projects and orchestrates the creation of resources. Below is the final version (as at time of writing of course) of the AppHost configuration.

// Create a builder for the distributed application
var builder = DistributedApplication.CreateBuilder(args);

// Add services to the distributed application
var sqlServer = builder.AddSqlServer(name: "SqlServer", port: 62949);
var identityServerDb = sqlServer.AddDatabase(name: "IdentityServerDb", databaseName: "IdentityServer");
var redis = builder.AddRedis(name: "RedisCache", port: 6379);

// Add projects to the distributed application
var identityServer = builder.AddProject<Projects.IdentityServer>(name: "identityserver");
var weatherApi = builder.AddProject<Projects.WeatherApi>(name: "weatherapi");
var clientApplication = builder.AddProject<Projects.Client>(name: "clientapp");

// Configure the distributed application

identityServer
    .WithExternalHttpEndpoints()
    .WithReference(identityServerDb, connectionName: "SqlServer")
    .WithReference(redis, connectionName: "Redis");

weatherApi
    .WithExternalHttpEndpoints()
    .WithReference(redis, connectionName: "Redis")
    .WithEnvironment(name: "IdentityProvider__Authority", endpointReference: identityServer.GetEndpoint(name: "https"));

clientApplication
    .WithExternalHttpEndpoints()
    .WithReference(redis, connectionName: "Redis")
    .WithEnvironment(name: "WeatherApi__BaseUrl", endpointReference: weatherApi.GetEndpoint(name: "https"))
    .WithEnvironment(name: "IdentityProvider__Authority", endpointReference: identityServer.GetEndpoint(name: "https"))
    .WithEnvironment(name: "IdentityProvider__ClientId", value: "mvc.par")
    .WithEnvironment(name: "IdentityProvider__ClientSecret", value: "secret");


// Build and run the distributed application
builder
    .Build()
    .Run();

As you can see, I’ve taken a particularly opinionated approach to how I’ve included and organized my projects and resources. For me, breaking them down into sections like this helps me keep a mental track of where things are and what’s using what resource.

SQL Server and REDIS resources both like to assume new port numbers and credentials on each run, so I’ve pinned down the ports on both for my own benefit.

The projects themselves were then added next in the order I see them logically stacking, IdentityServer first, then the API resource which relies on the Identity Provider and finally the Client app which has a dependency on both. It doesn’t matter in which order you register them when doing it this way, but again, helps keep things organized in my head.

Then in the same order, I start tying them together. My client app uses two named HttpClients to get the weather samples and connect to the Identity Provider for PAR support. Aspire sets up Service Discovery by default and would happily have let me get away with named URI’s and the auto-generated connection strings it creates. But I prefer to be much more explicit, so I’ve chosen to specify the connection names when dealing with each resource and opted to set some App Settings manually using the GetEndpoint() methods provided by the Aspire resources.

⚙️Project Configuration

Configuring each project to run within the Aspire solution is extremely simple, once the project is imported into the solution from it’s original repo, adding Aspire Orchestration is as easy as clicking “Add Aspire Orchestration Support”. By default, Visual Studio will scaffold up some code, automatically register the project in the AppHost and attempt to link up the Service Defaults into the added project.

Due to how I’ve written each of my projects’ Program.cs, this process partially failed as it wasn’t able to determine where to add the Service Defaults call. Easily sorted however as it’s just an extension over the IHostApplicationBuilder so a quick one-liner adds the opinionated support.

All-in, my projects startup code was included with the minimum of fuss and you can see the addition of the builder.AddServiceDefaults(); about half-way down the snippet below.

using Client.Extensions;
using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.Hosting;
using Serilog;
using System;

Log.Logger = new LoggerConfiguration()
    .WriteTo.Console()
    .CreateLogger();

Log.Information("Starting application host...");

try
{
    var builder = WebApplication
        .CreateBuilder(args);

    builder
        .Host.UseSerilog((ctx, lc) => lc
        .WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level}] {SourceContext}{NewLine}{Message:lj}{NewLine}{Exception}{NewLine}")
        .Enrich.FromLogContext()
        .ReadFrom.Configuration(ctx.Configuration));

    builder
        .AddServiceDefaults();

    builder
        .ConfigureServices()
        .ConfigurePipeline()
        .Run();
}
catch (Exception ex) when (ex.GetType().Name is not "HostAbortedException")
{
    Log.Fatal(ex, messageTemplate: "Unhandled exception");
}
finally
{
    Log.Information(messageTemplate: "Shut down complete");
    Log.CloseAndFlush();
}

⚡Running the Aspire AppHost

The Aspire AppHost project takes the lead as startup project in the solution, so we can select that project and run.
I already have Docker Desktop installed and running, so the AppHost begins to pull down the latest SQL Server and REDIS containers and provision them.

While it’s downloading and provisioning the Docker Container resources, the AppHost spins up a handy dashboard that displays all the resources and projects its currently managing and their current state.

From here, I’m able to monitor the console output of my loggers in each project, view the details of the configuration each resource is running under and even monitor some very low level metrics, enabling me to keep an eye on memory usage and track back issues almost as if I had a fully integrated instance of Application Insights at my disposal – though of course, this is all enabled by the use of OpenTelemetry and the various exporters configured in our Service Defaults.

🪨Road Blocks & Trip Hazards

One particular difficulty I experienced while doing all this was that my Identity Provider project, which references the MSSQL resource, has a migration and seeding step that requires that the SQL Server instance be running and ready for connections. However, what I and a few people have spotted is that while there is a clear dependency defined between resources and projects, the Aspire orchestration fails to take into account load times for some of these resources.

So while the AppHost is busy spinning up the resources and projects, it appears to “fire and forget” when it comes to Docker. So while the SQL container is still warming up, my Identity Provider is starting up and trying to run its seeding process. Consequently, I get a crash on the Identity Server and the project stops running, leaving me with new way to restart the project other than to restart the entire solution, potentially hitting the same issue.

[00:58:26 INF] Starting IdentityServer Host
[00:58:27 INF] Configuring & Starting Application...
[00:58:31 Fatal] 
Unhandled exception
System.InvalidOperationException: An exception has been raised that is likely due to a transient failure. Consider enabling transient error resiliency by adding 'EnableRetryOnFailure' to the 'UseSqlServer' call.
 ---> Microsoft.Data.SqlClient.SqlException (0x80131904): A connection was successfully established with the server, but then an error occurred during the pre-login handshake. (provider: TCP Provider, error: 0 - An existing connection was forcibly closed by the remote host.)

While it would be expected and extremely useful if dependencies naturally waited for resources to finish loading, this is not currently supported in .NET Aspire. At the time of writing, there appears to be some hope on the horizon as mentioned in this article:
“DependsOn”-like relationships between components · Issue #183 · dotnet/aspire (github.com)
Specifically this comment here: #issuecomment-2213830998

A more recent discussion thread has been created here: WaitFor(…): Resource startup sequencing with dependencies · Issue #5275 · dotnet/aspire (github.com)

In the mean time, the only way I’ve found to work around this limitation is to deliberately thread sleep the Identity Provider project during startup for an arbitrary amount of time in order to give the SQL Container enough time to finish booting. It’s a hacky way of avoiding the issue and is still prone to failing if the container isn’t local and needs to be downloaded.

Additionally, I personally feel the dashboard is missing a dedicated “shutdown“, “restart” and/or “reprovision” action for each managed item. One of the defining benefits of building with Aspire is that you should be able to build strong resilient applications. One way of testing that is allowing a developer to randomly shutdown or restart one of the managed resources or projects and seeing how the others react.

📑Further Reading

I would highly recommend getting some experience with .NET Aspire, the simplification of the local development experience and tooling alone make it an invaluable asset. Here are some additional resources I found useful while working on my sample project:

Scroll to Top