Not interested in reading the full article? Check out the complete code sample on GitHub instead.
Dapr provides a set of building blocks that abstract concepts commonly used in distributed systems. This includes secured synchronous and asynchronous communication between services, caching, workflows, resiliency, secret management and much more. Not having to implement these features yourself eliminates boilerplate, reduce complexity and allows you to focus on developing your business features.
You might be curious to see if Dapr can help you. Perhaps out of simple curiosity, because managing your services has become complex, or because you were asked to perform a proof of concept. Regardless of the reason, you have decided to learn and try Dapr.
You begin by understanding the basic concepts, perhaps even watching some video tutorials or online courses. You install the CLI and the required dependencies, hoping to quickly integrate the SDK into your applications. It takes you some time to understand how to set everything up. Before you know it, you’ve spent most of the day executing Dapr commands and writing YAML configuration.
Don’t get me wrong, if you end up using Dapr in production, you will need to go through this learning process. However, in a situation where your time is limited and you just want to experiment, it can be frustrating to spend so much time on initial setup. Not to mention that you haven’t yet determined the impact on the local development experience (troubleshooting, debugging, onboarding, etc.). Maybe some of your colleagues will initially be reluctant and believe that you’re making their work more complicated than it already is.
Which option will offer you the best local development experience? Will it be the Multi-Run template? Dapr with Docker Compose? Or Dapr with Kubernetes via Minikube, Kind, Docker Desktop, or Helm?
In this article, I’ll show you how to use Dapr with .NET Aspire for an unparalleled local development experience. We will create a few ASP.NET Core and Node.js services that will leverage service invocation, state management, and pub/sub. The benefits are:
- A representation of your distributed system through compile-time constant, testable code.
- A centralized OpenTelemetry web dashboard to browse your traces, logs and metrics.
- A simple way to attach Dapr sidecars to your applications.
- Little or no YAML configuration files.
Using .NET Aspire for Dapr will reduce the onboarding time for your developers. They can focus on using Dapr for feature development and spend less time setting up their local environment. Thanks to the integration with OpenTelemetry, it will be easier to troubleshoot interactions between multiple applications locally, which is usually done in cloud environments after the code is deployed.
Example of a Dapr distributed system with .NET Aspire
The goal of our Dapr experiment with .NET Aspire is to create three services and the .NET Aspire host project, which acts as the orchestrator:
- Alice, an ASP.NET Core service that uses Dapr’s service invocation to retrieve weather data from another service and caches it using the state store.
- Bob, an ASP.NET Core service that returns fake weather data, then publishes a “weather forecast requested” event using pub/sub.
- Carol, a Node.js Express web application that subscribes to “weather forecast requested” events.
The complete code ready for use is available in this GitHub repository ⭐. The README will guide you to install the prerequisites and start the services. There’s too much code to show in this post, so I will only provide a few snippets and screenshots.
The code below is the .NET Aspire host project where we declare these services, Dapr components and their relationships, no YAML involved:
using Aspire.Hosting.Dapr;
using Microsoft.Extensions.Hosting;
var builder = DistributedApplication.CreateBuilder(args);
var stateStore = builder.AddDaprStateStore("statestore");
var pubSub = builder.AddDaprPubSub("pubsub");
builder.AddProject<Projects.AspireDaprDemo_AliceService>("alice")
.WithDaprSidecar()
.WithReference(stateStore)
.WithReference(pubSub);
builder.AddProject<Projects.AspireDaprDemo_BobService>("bob")
.WithDaprSidecar()
.WithReference(stateStore)
.WithReference(pubSub);
builder.AddNpmApp("carol", Path.Combine("..", "AspireDaprDemo.CarolService"), "watch")
.WithHttpEndpoint(port: 3000, env: "PORT")
.WithEnvironment("NODE_TLS_REJECT_UNAUTHORIZED", builder.Environment.IsDevelopment() ? "0" : "1")
.WithDaprSidecar()
.WithReference(stateStore)
.WithReference(pubSub);
builder.Build().Run();
When launched, the Aspire starts all the services and offers a complete view of the distributed system in the dashboard:
In this example, the Alice service exposes an endpoint /weatherforecast
that triggers the interactions described above. Here’s what the OpenTelemetry trace looks like when invoking this endpoint:
A developer joining the development team could quickly understand how the different components of the distributed system interact with each other. In this screenshot, we can see that the flaky Bob service returned an error and that Dapr automatically retried the operation.
To achieve this result, it is important that your applications are correctly instrumented with the OpenTelemetry SDK. Otherwise, only Dapr spans would have appeared.
.NET Aspire offers a better way to visualize OpenTelemetry traces than the default Zipkin instance provided with Dapr because not only are the traces visually clearer, but the dashboard also includes logs and metrics.
Dapr with .NET Aspire is configuration-free and easy to use
Normally, to configure Dapr, you need to create YAML configuration files that describe the applications, sidecars, and networking details such as TCP ports. With .NET Aspire, this is not required.
The communication between Alice and Bob, whose names were declared in the Aspire host project, is as simple as this thanks to the Dapr SDK (link to the full code):
// - "bob" is the name of the service declared in the Aspire host project
// - "weatherforecast" is the HTTP endpoint to invoke (URL path)
// - "client" is an instance of DaprClient available through dependency injection
var forecasts = await client.InvokeMethodAsync<WeatherForecast[]>(HttpMethod.Get, "bob", "weatherforecast");
No URLs were configured in appsettings.json
or environment variables. Using the service name bob
is the only constant required. Dapr is responsible for routing the request to the correct service.
The same is true for the state store and pub/sub. Connection details are only known to the Dapr sidecars, so the applications don’t need to worry about them. This avoids the tedious management of configuration files.
Imagine having 10 services in your distributed system, along with 4 environments: local, dev, stg, and prod. This could represent 40 potential configuration files to maintain, with dozens of URLs and connection strings. Thanks to Dapr, you no longer have to worry about this.
Using the state store and pub/sub is just as simple:
// Retrieve the weather forecast from the state store "statestore" declared in the Aspire host
var cachedForecasts = await client.GetStateAsync<CachedWeatherForecast>("statestore", "cache");
// [...]
// Save the weather forecast in the state store under the key "cache"
await client.SaveStateAsync("statestore", "cache", new CachedWeatherForecast(forecasts, DateTimeOffset.UtcNow));
// Publish an event "WeatherForecastMessage" to the pub/sub "pubsub" declared in the Aspire host, with the topic "weather"
await client.PublishEventAsync("pubsub", "weather", new WeatherForecastMessage("Weather forecast requested!"));
This is a snippet of the Carol service which subscribes to the “weather” topic. Remember that both .NET Aspire and Dapr are language-agnostic:
// Events are received through HTTP POST requests (push delivery model)
app.post("/subscriptions/weather", (req, res) => {
console.log("Weather forecast message received:", req.body.data);
res.sendStatus(200);
});
How does .NET Aspire work with Dapr?
Using the WithDaprSidecar
method on a resource instructs .NET Aspire to start an instance of the dapr
executable.
// [...]
.WithDaprSidecar()
.WithReference(stateStore)
.WithReference(pubSub);
The arguments passed to dapr
depend on the number of components referenced by the service and the options that may be passed during the invocation of the methods above.
Here is the dapr
command that is executed for the Alice service on my computer:
C:\dapr\dapr.exe run --app-id alice --resources-path C:\src\aspire-dapr-demo\resources --resources-path C:\Users\simmo\AppData\Local\Temp\aspire-dapr.xkxkzt4n.xcz\statestore --resources-path C:\Users\simmo\AppData\Local\Temp\aspire-dapr.xkxkzt4n.xcz\pubsub --app-port 5555 --dapr-grpc-port 58341 --dapr-http-port 58342 --metrics-port 58343 --app-channel-address localhost
Two key points to remember here:
- The YAML code for built-in components in .NET Aspire, such as state store and pub/sub, is automatically generated in a temporary folder.
- By default, random ports are assigned, so you don’t have to remember them or worry about possible collisions.
If you want to learn more, the magic happens in the DaprDistributedApplicationLifecycleHook class in the .NET Aspire source code.
Subsequently, the orchestrated applications are passed environment variables that allow the Dapr SDK to communicate with the sidecars. This can be seen in the details of the resources on the Aspire dashboard:
Handling more complex Dapr scenarios
In this experiment, we used two Dapr components supported natively by .NET Aspire. However, it’s possible to declare other types of components with the AddDaprComponent
method:
builder.AddDaprComponent("localsecretstore", "secretstores.local.file", new DaprComponentOptions
{
LocalPath = "/path/to/component-config.yaml"
});
It’s also possible to declare resources such as resilience policies and assign them to sidecars:
builder.AddProject<Projects.AspireDaprDemo_AliceService>("alice")
.WithDaprSidecar(new DaprSidecarOptions
{
ResourcesPaths = "/path/to/resources-directory"
})
.WithReference(stateStore)
.WithReference(pubSub);