Introduction to Dapr for .NET Developers

If you have built more than one microservice in .NET, you already know the pattern. You add a Redis client for caching, a Kafka or RabbitMQ client for messaging, a Key Vault SDK for secrets, and before long your business logic is sitting under a pile of infrastructure wiring. Every new service repeats the same plumbing, and every provider change means touching application code. Dapr, short for Distributed Application Runtime, exists to pull that plumbing out of your codebase and put it behind a consistent set of APIs.

Dapr is a CNCF graduated project, which is a reasonable signal that it has moved past the experimental stage and is running in real production systems. In this article I will walk through what Dapr actually is, how the sidecar model works, and the two building blocks you will reach for most often as a .NET developer: service invocation and pub/sub. I will also cover where it fits with .NET Aspire, and where I think the operational cost is worth paying and where it is not.

What Dapr Actually Gives You

At its core, Dapr provides standardized building blocks that abstract away the complexity of common microservice patterns: state storage, messaging, service to service calls, secrets, and a handful of others. Instead of coding directly against Redis or Kafka or Azure Key Vault, you code against a Dapr API, and Dapr talks to whichever backing service you have configured.

Here is what a typical pre-Dapr setup looks like in a .NET service. You end up wiring in the concrete infrastructure client directly.

// Pre-Dapr approach - direct infrastructure dependencies
builder.Services.AddStackExchangeRedisCache(options => { options.Configuration = "redis:6379"; });
 
builder.Services.AddSingleton<IMessageBroker>(provider => new KafkaMessageBroker("kafka:9092"));
 
builder.Services.AddSingleton<ISecretManager>(provider =>
    new AzureKeyVaultClient(new Uri("https://myvault.vault.azure.net")));

Nothing wrong with this on day one, but notice that your service now knows it is talking to Redis, Kafka, and Key Vault specifically. If you move from Kafka to Azure Service Bus later, or from Redis to Cosmos DB, this code changes and every place that depends on these abstractions has to be retested.

The Dapr version replaces all three concrete clients with one client and named component references.

// Dapr approach - simple, consistent APIs
builder.Services.AddDaprClient();
 
// Later in code:
// State management (could be Redis, Cosmos DB, etc.)
await daprClient.SaveStateAsync("statestore", "customer-123", customerData);
 
// Pub/sub (could be Kafka, RabbitMQ, etc.)
await daprClient.PublishEventAsync("pubsub", "orders", orderData);
 
// Secrets (could be Azure Key Vault, HashiCorp Vault, etc.)
var secret = await daprClient.GetSecretAsync("secretstore", "api-keys");

The strings “statestore”, “pubsub”, and “secretstore” are component names, not provider names. Which actual technology backs each one is decided in a YAML component file that sits outside your application code. Swap Redis for Cosmos DB, and this C# does not change at all, only the component configuration does. That decoupling is the entire value proposition of Dapr in one code snippet.

The Sidecar Pattern

Dapr does not run inside your process. It runs as a separate process, the sidecar, alongside your application, and your application talks to it over localhost using HTTP or gRPC. The sidecar is the one that actually talks to Redis, Kafka, Azure Service Bus, or whatever backing service you have configured.

The Dapr sidecar sits alongside your application and mediates access to building blocks and backing services. Source: Dapr.
The Dapr sidecar sits alongside your application and mediates access to building blocks and backing services. Source: Dapr.

This separation is what makes Dapr language agnostic. A Go service and a .NET service can both talk to the same sidecar API and get the same guarantees. It also means cross-cutting concerns like mTLS encryption between services, retries, and distributed tracing are handled by the sidecar process, not by code you write and maintain in every service.

The trade-off is operational. Every instance of your service now runs with a companion process next to it. On Kubernetes this is one extra container per pod, which Dapr injects automatically once you annotate the deployment. Locally, the Dapr CLI starts and stops the sidecar for you when you run ‘dapr run’. It is not free, but it is not particularly heavy either, and most teams running Dapr in production do not report the sidecar itself as a bottleneck.

The Building Blocks

Dapr organizes its capabilities into building blocks, each with its own standardized API. The ones you will use on almost every project are service invocation, state management, and pub/sub. Beyond that there are workflows for long running processes, bindings for connecting to external triggers, actors for the virtual actor pattern, secrets, configuration, distributed locks, cryptography, scheduled jobs, and a newer conversation block for talking to LLMs with built-in prompt caching and PII handling.

Dapr's building blocks and the backing services they commonly integrate with. Source: Dapr.
Dapr’s building blocks and the backing services they commonly integrate with. Source: Dapr.

You will not use all twelve blocks in a typical project, and that is fine. Most teams start with service invocation and pub/sub, add state management once they need it, and only reach for actors or workflows once the domain genuinely calls for them. Adopting Dapr does not mean adopting the whole surface area at once.

Service Invocation

Service invocation handles service to service calls with automatic discovery, mTLS, retries, and tracing built in. This is the block that replaces hand-rolled HttpClient calls with service discovery logic, Polly retry policies, and manual trace propagation.

Service invocation flow between two Dapr-enabled services. Source: Dapr.
Service invocation flow between two Dapr-enabled services. Source: Dapr.

Here is a checkout endpoint that invokes an order-processing service through Dapr rather than calling it directly over HTTP.

// Client application making a request
using Dapr.Client;
 
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDaprClient();
 
var app = builder.Build();
 
app.MapGet("/checkout/{itemId}", async (int itemId, DaprClient daprClient) =>
{
    // Create order data
    var orderData = new OrderData(itemId, DateTime.UtcNow);
 
    // Invoke the order-processing service
    var result = await daprClient.InvokeMethodAsync<OrderData, string>(
        "order-processor",
        "process-order",
        orderData);
 
    return Results.Ok(new { Message = $"Order {itemId} processed: {result}" });
});
 
await app.RunAsync();
 
public record OrderData(int ItemId, DateTime OrderedAt);

And the receiving side, which looks like a normal minimal API endpoint because that is exactly what it is.

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
 
app.MapPost("/process-order", (OrderData order) =>
{
    Console.WriteLine($"Processing order {order.ItemId} placed at {order.OrderedAt}");
    return $"Order {order.ItemId} confirmation: #{Guid.NewGuid().ToString()[..8]}";
});
 
await app.RunAsync();
 
public record OrderData(int ItemId, DateTime OrderedAt);

The first argument to InvokeMethodAsync, “order-processor”, is the Dapr app ID of the target service, not a hostname or URL. Your checkout service’s sidecar resolves that app ID, forwards the call to the order service’s sidecar, which then hands it off to the order service itself over localhost. The response takes the reverse path back. You get service discovery, retries, mTLS, and tracing for free, and the receiving endpoint itself has no idea Dapr is involved at all, which is a nice property when you want to keep your domain code clean.

One thing worth knowing before you rely on this in production: invocation over Dapr adds two extra network hops compared to a direct call, since traffic goes through both sidecars. For latency-sensitive paths inside a single service boundary, that overhead is usually negligible, but it is worth measuring rather than assuming, especially on a Kubernetes cluster with network policies in the mix.

Publish and Subscribe

The pub/sub block gives you asynchronous messaging with at-least-once delivery, which lets services stay decoupled and keep working even when a downstream consumer is temporarily unavailable.

Publish and subscribe flow with Dapr, including CloudEvent wrapping at the sidecar. Source: Dapr.
Publish and subscribe flow with Dapr, including CloudEvent wrapping at the sidecar. Source: Dapr.

Before any code, pub/sub needs a component file that tells Dapr which message broker to use. Here is a Redis-backed pub/sub component.

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: order-events
spec:
  type: pubsub.redis
  version: v1
  metadata:
    - name: redisHost
      value: localhost:6379
    - name: redisPassword
      value: ''

This file goes in a components directory, typically ./components locally, and Dapr picks it up at startup. The name field, order-events, is what your application code references, and it has to match exactly on both the publishing and subscribing sides or messages silently stop flowing. This is the single most common mistake I see when people set up Dapr pub/sub for the first time: a typo or casing mismatch between the component name in YAML and the topic name string in code, with no compile-time check to catch it.

Switching from Redis to RabbitMQ or Azure Service Bus later means changing spec.type and the metadata block in this file. No C# changes at all.

Here is the publisher side.

// Publisher service
using Dapr.Client;
 
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDaprClient();
 
var app = builder.Build();
 
app.MapPost("/create-order", async (OrderRequest request, DaprClient daprClient) =>
{
    var orderEvent = new OrderCreatedEvent(
        request.OrderId,
        request.CustomerId,
        request.Items,
        DateTime.UtcNow
    );
 
    // Publish event to "orders" topic
    await daprClient.PublishEventAsync("order-events", "orders", orderEvent);
 
    return Results.Accepted();
});
 
await app.RunAsync();
 
public record OrderRequest(Guid OrderId, string CustomerId, List<string> Items);
public record OrderCreatedEvent(Guid OrderId, string CustomerId, List<string> Items, DateTime CreatedAt);

And the subscriber, using the Topic attribute to bind a handler to a topic.

// Subscriber service
using Dapr;
using Microsoft.AspNetCore.OutputCaching;
 
var builder = WebApplication.CreateBuilder(args);
 
// Add Dapr event handling
builder.Services.AddDapr();
builder.Services.AddControllers();
 
var app = builder.Build();
 
// Required for Dapr pub/sub
app.UseCloudEvents();
app.MapSubscribeHandler();
 
// Subscribe to "orders" topic
app.MapPost("/events/orders", [Topic("order-events", "orders")] async (OrderCreatedEvent orderEvent) =>
{
    Console.WriteLine($"Processing order {orderEvent.OrderId} for customer {orderEvent.CustomerId}");
    await ProcessOrderAsync(orderEvent);
    return Results.Ok();
});
 
await app.RunAsync();
 
async Task ProcessOrderAsync(OrderCreatedEvent orderEvent)
{
    // Process the order
    await Task.Delay(100); // Simulate work
}
 
public record OrderCreatedEvent(Guid OrderId, string CustomerId, List<string> Items, DateTime CreatedAt);

app.MapSubscribeHandler() is easy to forget, and without it Dapr has no way to discover which topics your service subscribes to, so the endpoint just never gets called even though the code looks correct. Also remember that pub/sub in Dapr is at-least-once delivery, not exactly-once, so your handler needs to be idempotent if duplicate processing would cause a problem, for example by checking whether an order ID has already been processed before acting on it.

Dapr with .NET Aspire

If you are already using .NET Aspire to orchestrate local development, Dapr fits in as a sidecar attached to each Aspire resource. Aspire handles .NET-specific orchestration and service discovery within your solution, while Dapr handles the language-agnostic building blocks and cross-service concerns. They are complementary rather than competing tools.

using CommunityToolkit.Aspire.Hosting.Dapr;
 
// Program.cs in the Aspire AppHost project
var builder = DistributedApplication.CreateBuilder(args);
 
// Add Aspire service and configure Dapr
var orderService = builder.AddProject<Projects.OrderService>("orderservice")
    .WithDaprSidecar(new DaprSidecarOptions
    {
        AppId = "order-api",
        Config = "./dapr/config.yaml",
        ResourcesPaths = ["./dapr/components"]
    });
 
// Add another service that can communicate with the order service via Dapr
var checkoutService = builder.AddProject<Projects.CheckoutService>("checkoutservice")
    .WithDaprSidecar(new DaprSidecarOptions
    {
        AppId = "checkout-api",
        Config = "./dapr/config.yaml",
        ResourcesPaths = ["./dapr/components"]
    })
    // Reference the order service by its Dapr app ID
    .WithReference(orderService);
 
builder.Build().Run();

This uses the CommunityToolkit.Aspire.Hosting.Dapr package. Worth flagging explicitly: the older Aspire.Hosting.Dapr package is deprecated, so if you are following an older tutorial or Stack Overflow answer, check which package it references before copying code. Each AddProject call gets its own WithDaprSidecar configuration with its own AppId, Config file, and ResourcesPaths, and WithReference wires up the Aspire-level dependency so the AppHost knows checkoutservice depends on orderservice starting first.

Once wired up, calls between services flow through their respective sidecars exactly as they would outside Aspire, and you get full distributed traces in the Aspire dashboard showing the hop from one sidecar to the next.

A distributed trace in the Aspire dashboard showing a request flowing from the checkout service through Dapr to the order service.
A distributed trace in the Aspire dashboard showing a request flowing from the checkout service through Dapr to the order service.

Is Dapr Worth the Overhead

This is the question that matters more than any code sample. Dapr adds a sidecar per instance, a component configuration layer to maintain, and a new set of concepts your team has to learn. If you are running a single monolithic API with a database and nothing else, you almost certainly do not need it, and adding Dapr there is pure overhead with no corresponding benefit.

Where Dapr earns its keep is when you already have several services talking to each other, and especially when you expect the underlying infrastructure to change over the life of the project, for example moving from Redis pub/sub in a dev cluster to Azure Service Bus in production, or migrating between cloud providers. The abstraction pays for itself when you actually exercise the flexibility it gives you. If you pick Redis on day one and never touch that decision again, you paid the abstraction tax without collecting the benefit.

On Kubernetes, Dapr is a reasonably mature choice with wide community support and a graduated CNCF status behind it, but you are still adding an operator, sidecar injection, and component YAML to your deployment surface. Teams that already run a service mesh like Istio or Linkerd should think carefully about overlap before adding Dapr on top, since some of the mTLS and observability benefits duplicate what a mesh already provides. For teams building their first few microservices and wanting sane defaults for service calls and messaging without hand-rolling retry and discovery logic, Dapr is a solid starting point and considerably less work than wiring up the equivalent yourself.

Where to Go Next

The building blocks covered here, service invocation and pub/sub, are the two you will use on nearly every Dapr project. State management, actors, and workflows are worth exploring once your service actually needs persistent state coordination or long running processes that span multiple services, rather than adopting them upfront just because they exist.

If you want a structured, hands-on way to learn the rest of the building blocks, Dapr University runs free lessons covering state management, service invocation, and pub/sub with practical exercises rather than just conceptual overviews.

Leave a Reply

Discover more from Behind the Stack

Subscribe now to keep reading and get access to the full archive.

Continue reading