What makes .NET Aspire essential for .NET development
In November 2023, the .NET world was hit with a pleasant shock.
Sure, we all knew .NET 8 and C# 12 were on their way. Those updates had been on the roadmap for a while. But what nobody saw coming was the debut of .NET Aspire. There were no teasers, no previews, no early blog posts. Microsoft kept it completely under wraps. Then, without warning, they dropped it, and it made waves.
Within days, the .NET community was buzzing. Influencers, bloggers, open-source contributors — everyone was talking about Aspire. Why? Because .NET Aspire addressed one of the most frustrating pain points in modern software development: building and debugging distributed applications locally, and it did so with remarkable elegance.
.NET Aspire is a cloud-native application stack purpose-built for simplifying the development of modern, distributed .NET applications. While it’s especially powerful for cloud-native systems, you don’t have to be building Kubernetes-scale solutions to benefit from it. Even a simple monolithic app, say, one that connects to a database or a message broker, can take full advantage of Aspire's streamlined developer experience.
In essence, Aspire makes integrating external services into your local development workflow feel almost effortless. It helps you simulate a production-like environment on your machine, making it far easier to build, run, and debug systems that span multiple services. And I don't think anything similar exists in any other software ecosystem outside .NET. At least, I am not familiar with anything like it.
What really sets Aspire apart is its unified developer experience. It offers a curated stack of preconfigured components and tools designed to work together seamlessly. No more struggling with misconfigured Docker containers or manually wiring up infrastructure in development. Aspire brings everything into a single, shared debugger process and allows you to monitor and interact with all the pieces of your application in one place, whether that's a distributed microservice architecture or a monolith with a few key dependencies.
Once you’ve worked with Aspire, going back feels like stepping into the stone age. Let me demonstrate to you why that is by walking you through the structure of a .NET Aspire starter project.
Getting started with the Aspire Starter Project
You can find the instructions on how to get started with Aspire via this page. Let’s assume we followed these instructions and created a new project based on the Aspire Starter Project template. We should end up with a .NET Solution containing the following projects:
A Blazor web application with a user interface that pulls data from an external REST API endpoint.
A REST API application from which the Blazor application pulls data.
The Aspire Host project which acts as the orchestrator that coordinates the other two applications.
Service defaults class library, which has dependencies that both hosted projects share.
Once you have created such a project, one thing that I would suggest you do is update all NuGet packages to the latest version. At the time of writing, the default Aspire project doesn't pull the latest versions of these packages. However, there have been some very significant improvements recently. One of them is the improvement to the orchestration dashboard, as you shall see shortly.
Once you have updated the NuGet packages, you can launch the host project the same way you would launch any .NET project. The project you are looking for is the one that has the .AppHost suffix in it. Once it's running, you should be able to see the following dashboard in the browser:
This dashboard shows the services that our distributed application consists of. Those services that come with an HTTP endpoint have a link to their homepage, so we can effortlessly navigate to them right from the dashboard.
On top of all of these, the dashboard comes with structured logs, metrics, etc. Our entire distributed system can be managed and monitored from one place. If you have built distributed systems before, this is probably very different from what you are used to. There wasn't much you had to do to launch your first application. All you needed was the .NET SDK and a code editor that can run .NET. For some things in Aspire, you also need Docker, but that's it.
Your entire distributed system can be launched with a click of a button or a simple dotnet run command. You don't have to spend hours configuring your environment. You don't have to set up multiple IDE instances and multiple startup projects.
If you wondered why I so strongly insisted on updating the NuGet packages, here's why:
This is what the old Aspire dashboard used to look like. Yes, functionally, it may be pretty much the same. But visually, the new version is much more appealing and is easier to follow. With two or three services, it doesn't make much difference besides aesthetics, but once you start running large production-grade distributed systems, the newer dashboard will be significantly easier to navigate.
So, let's see how the code is structured in an Aspire project that makes it so easy to use. You'll be surprised to see how little of it there is.
How service orchestration happens
If we open the Program.cs file of the Aspire host project, we will see that there's not much in it. That's all we need:
var builder = DistributedApplication.CreateBuilder(args);
var cache = builder.AddRedis("cache");
var apiService = builder.AddProject<Projects.AspireDemoApp_ApiService>("apiservice");
builder.AddProject<Projects.AspireDemoApp_Web>("webfrontend")
.WithExternalHttpEndpoints()
.WithReference(cache)
.WaitFor(cache)
.WithReference(apiService)
.WaitFor(apiService);
builder.Build().Run();
Here's what the code does. First, we create a cache variable, which represents a Redis server running inside our distributed app. This will be used for output caching in the user-facing web application. This one-liner is typically all we have to do to add an infrastructure component to our distributed application setup! It's similar for things like databases and message brokers.
What's beautiful is that we don't have to configure connection strings or URLs for these services. Aspire will connect services automatically by using its built-in service discovery mechanism.
Next, we create an instance of our REST API service, which is represented by the apiService variable. This service is built from one of the project references in the solution. The project itself is a standard ASP.NET Core web application with a REST API. Only that it has some additional libraries that make it work with the Aspire service discovery mechanism. Other than that, it's a completely standard ASP.NET Core web app.
Finally, we are creating a front-end Blazor app from another project reference. This one is more involved, so let's break it down.
WithExternalHttpEndpoints() invocation ensures that the URL of the application is accessible to the public. If we don't call this method while registering an application, its access will be restricted strictly to the internal network.
WithReference(cache) ensures that the application can connect to the Redis cache by using Aspire's own service discovery mechanism. With this mechanism in place, the web application doesn't need to know the exact address of the Redis service. It can reference it by the name it's been registered under, which, in this case, is cache.
WaitFor(cache) ensures that the web application doesn't get started until the Redis service is ready.
WithReference(apiService) allows the Blazor UI app to discover the address of the REST API via the service discovery mechanism.
WaitFor(apiService) ensures that the front-end is not started before the API application is ready.
So, this is how the services are coordinated. Very intuitive and simple. And since we mentioned service discovery several times, let's see some examples of how it works.
Service discovery in .NET Aspire
To see how the Blazor app talks to other services, we can open the Program.cs file of the front-end Blazor app. Here's an example of how it registers Redis output caching:
builder.AddRedisOutputCache("cache");
As you can see, no connection string. We merely reference the name that the Redis cache service has been registered under by the Aspire host. The underlying system itself will resolve the address for us. Also, please note that it would only be possible if we pass the Redis service reference into this service instance, like we did in the example above. Otherwise, the underlying middleware will not have a clue what cache is.
To register an HTTP client for the REST API, we have the following code:
builder.Services.AddHttpClient<WeatherApiClient>(client =>
{
client.BaseAddress = new("https+http://apiservice");
});
Once again, we aren't using any specific address. We are using the name of apiservice, which is the name we registered the REST API service under in the Aspire host. You may have noticed that it uses a strange schema, which is https+http://. This is a special schema used by .NET service discovery mechanism. It tells the middleware that we prefer HTTPS, but if it's unavailable, we are OK to fall back to HTTP.
In this case, it doesn't matter that much because the API application doesn't expose any external endpoints. It's all on the internal network.
So, how do the UI app and the API apps use service discovery? After all, while the Aspire host is a special type of project, these two are just standard .NET applications. Well, it's simple. It's all facilitated by the service defaults class library, which both applications reference.
The significance of the service defaults library
Service defaults is the class library project with the ServiceDefaults suffix in the name. It has a number of shared dependencies and extension methods that are invoked from the Program.cs files of both the UI and API applications.
For example, the service discovery mechanism is facilitated by Microsoft.Extensions.ServiceDiscovery NuGet package. The service discovery mechanism is then enabled by running this code:
builder.Services.AddServiceDiscovery();
builder.Services.ConfigureHttpClientDefaults(http =>
{
// Turn on resilience by default
http.AddStandardResilienceHandler();
// Turn on service discovery by default
http.AddServiceDiscovery();
});
But this is not the only thing the service defaults project does. It has inbuilt telemetry middleware facilitated by OpenTelemetry. It has a configuration for the health check endpoint. It adds a subset of very useful functionality out of the box. This is why, for example, even the basic starter project already comes with logs, metrics, traces, and so on.
Wrapping up
This was a very basic overview of .NET Aspire. However, while we only covered the starter app, we already saw how useful Aspire is for developing distributed applications.
In my project at Microsoft, switching to .NET Aspire was an absolute game changer! The onboarding of new staff members used to take two days, and now it takes less than an hour. It makes things that much easier. And that is in a large-scale production-grade distributed system!
Also, if you are intrigued by .NET Aspire, you may find my book on it very useful. Moreover, it's currently cheap because it's still in early access. However, the good news is that you can make it even cheaper by using the sazanavets45 discount code.
If you are interested in getting to know more about Aspire or if you want to support me so I can write more informative articles like this, you can get your copy by clicking on the image below:
I hope you found today's article helpful, and I'll speak to you next time!