Building a distributed application with .NET and Aspire
- 7 minutes read - 1338 wordsExploring the packages and patterns needed for a real-world distributed application and how Aspire can help.
Background
I’ve recently been inspired by working with my current client who operates a large distributed application. More specifically, their systems are microservices that interact with each other as well as integrate with a number of external services, both hosted cloud-native and third parties. Their systems serve a pretty high baseline volume of traffic as well as significant spikes due to certain events or times of the year. Because of this, they do all the good things you would expect in this circumstance to ensure high availability, resilience and scalability.
When your system is built up from multiple services (your own, cloud-native and third parties), the local development
experience can often suffer. It can become very complicated, very quickly, trying to spin up all the dependencies and
setting up a working environment. Often this is done using a combination of shell scripts and Docker files, but it
can be challenging to get right and ensure the right configuration for each service.
Now, my client has done just this, with shell scripts and Docker files, and it normally works fine. But it was certainly non-trivial to create in the first place and therefore harder to change and maintain.
Alongside this experience I was seeing lots of talk about .NET Aspire, a new project from Microsoft that aims to make it easier to build and run distributed applications. So, I decided to combine these two things into the aims of the project:
🎯 Use .NET to explore and demonstrate the techniques/patterns/packages needed when building a real-world distributed application
🎯 Use Aspire to see how it can help with building and running a distributed application
Techniques/Patterns/Packages
There are a huge number of things to take into consideration when building a distributed application, particularly one that needs to operate 24/7/365, scale and serve a high volume of traffic. So I broke the things down to ensure I covered all the key areas. Some items are needed just to build out the sample application, whilst others are key to making it work in the real-world.
First, some basic building blocks. The sample application should:
- Have its own RESTful API
- Have its own data store (SQL or non-SQL)
- Make use of an upstream RESTful API (i.e. another microservice)
- Make use of a third-party external API
- Make use of asynchronous messaging
Then come the techniques/patterns (some are code, some are architecture):
- Scalability: auto-scaling, load balancing, caching
- Resilience: timeouts, retries, circuit-breaker, rate limiting, load shedding
- Asynchronous messaging: producing and consuming messages and making that resilient
- Observability: logging, tracing, metrics, monitoring, alerts
- etc.
The full list is in the repo README file alongside how I have (or aim to) fulfill each of them.
How can Aspire help?
I’m hoping it can make the local development experience much easier and do away with custom shell scripts and Docker files. I’m also hoping that the local development setup is the same process (or as near as possible) as the production environment. It’s really frustrating digging deep into an issue to find out it’s not working due to differences in configuration or setup.
But first, what is Aspire anyway?
.NET Aspire provides tools, templates, and packages for building observable, production-ready distributed apps. Build with a code-first app model, develop locally with unified tooling, and deploy anywhere—cloud, Kubernetes, or your servers.
Yeah, but, what is it really?
AppHost orchestration: Define services, dependencies, and configuration in code.
Rich integrations: NuGet packages for popular services with standardized interfaces.
More simply:
- A new application type called the “AppHost”. This bootstraps your application and manages the lifecycle of your dependencies in Docker containers.
- A set of NuGet packages that provide a standardised way to integrate the services together.
I’m not going to regurgitate the Microsoft documentation, it’s best to run through the getting started guide yourself to understand the basics.
What does that look like in the code?
The “AppHost” project is where the orchestration happens. The “getting started” sample referenced above shows the basics, but here is what I have in my projects AppHost at the time of writing to show a more complex example:
|
|
The important lines:
- 42: reference the API project and give it the name
api
(used for service discovery in other projects) - 53: reference the Postgres database resource so its name can be used via service discovery in the API project
- 54-55: reference the database migrations resource (which uses EF Core migrations) and wait for its completion
- 56-61: reference the other dependent resources and wait for them to be ready
Aspire configures all the service discovery between the projects (using environment variables) and allows the
referencing projects to just use the simple names to connect to the services they need. For example, this is how the
API project configures a HttpClient
to call the “other microservice” (the SpatialApi
project) just using the name
configured in the AppHost
project:
builder.Services.AddHttpClient<CoordinateConverterClient>(client =>
{
client.BaseAddress = new("https://spatial-api");
});
Similarly, the API project can use the name to connect to the Postgres database:
var connectionString = configuration.GetConnectionString("api-database");
Those names are then auto-magically expanded from the environment variables set by the AppHost
project into the real values.
Running Locally
Running the AppHost
project spins up all of your dependencies, respecting the relationships between them, waits for
them to become ready, and then runs the application.
This shows the Docker-based dependencies that are managed by Aspire.
Aspire also includes a dashboard that shows the status of the dependencies and the application (your project and code) itself:
Better than that, Aspire’s service defaults configure your projects to output logs and metrics to the dashboard. This means that you can see the logs and metrics in real-time when running locally, without having to run any additional tools.
The dashboards’ collectors for those structured logs and metrics & tracing (via OpenTelemetry) are super helpful for local development, but they are in-memory only and therefore not suitable for wider use. However, the output is standard and can just be pointed at any other supporting collector that is ready for production use, e.g. AppInsights, Prometheus, Graphana, etc.
The screenshots below show the Aspire dashboard visualising the console logs or the projects/containers, the structured log output, the traces and metrics:
The fact that Aspire gives you all of this out-of-the-box and allows you to visualise it so easily for local development is fantastic. It’s a huge time saver. And when deployed to production, it’s just a matter of pointing the collectors at the right place.
Testing Locally
Testing the application locally is just as easy as running it locally. And not just unit tests, but integration tests as well, utilising all of your dependencies, all spun up and managed by Aspire.
Check out the testing documentation as well as the integration tests in my repo.
Deployment
Now, full disclosure, I haven’t got as far as deploying my project yet. However, the documentation calls out two different targets for deployment:
- unsurprisingly, being Microsoft, to Azure Container Apps using the Aspire CLI or Azure Developer CLI (azd)
- and to Kubernetes, and hence a cloud-agnostic deployment using Aspir8
I’m hoping the experience of deploying to Kubernetes is as good as to Azure Container Apps. Whilst Aspire is a .NET-only set of tooling, I’d hate for it to be restricted to just Azure.
Conclusion
This project has two aims for me: applying essential, real-world distributed application patterns in modern .NET as well as exploring Aspire and how it can kick-start projects, leverage some of those patterns and keep the local development experience first class.
You can find my work-in-progress repo here
I hope it helps you too.