How to: Author subscriptions and handle topic messages with the Dapr Messaging .NET SDK

Learn how to author topic handlers, configure streaming, programmatic, and HTTP delivery modes, and wire up reflection-free dispatchers with Dapr.Messaging

This guide demonstrates how to author and manage Dapr topic subscriptions using the modern Dapr.Messaging SDK for .NET. You will learn how to author strongly-typed topic handlers with ITopicHandler<TMessage>, configure delivery modes and routing rules with [DaprTopic], leverage compile-time source generation, and manage dynamic streaming subscriptions.

Prerequisites

<ItemGroup>
  <PackageReference Include="Dapr.Messaging" Version="..." />
</ItemGroup>

Define a topic handler

Topic subscribers implement the strongly-typed ITopicHandler<TMessage> interface (or its generic variant ITopicHandler<TMessage, TResult>) and are decorated with the [DaprTopic] attribute.

Why use ITopicHandler?

  • Strongly-typed payloads: Automatically deserializes incoming JSON or CloudEvent payloads into strongly-typed .NET models (TMessage) without manual boilerplate.
  • Explicit message disposition: The HandleAsync method returns a TopicResponseAction (Success, Retry, or Drop), giving you precise control over acknowledgment, retries, and dead-letter queue routing.
  • Scoped dependency injection: Handlers are resolved from an isolated IServiceScope for each message delivery, enabling clean constructor injection for scoped dependencies like Entity Framework DbContext, repositories, or loggers.
  • Compile-time source generation: Annotated handlers are discovered by the Roslyn source generator at build time to emit reflection-free, Native AOT–compatible dispatchers.

Example: Topic handler with ITopicHandler<TMessage>

using Dapr.Messaging;
using Dapr.Messaging.PublishSubscribe;

public record Order(string Id, decimal Total, string CustomerEmail);

[DaprTopic("pubsub", "orders")]
public sealed class OrderHandler(ILogger<OrderHandler> logger, IInventoryService inventory) 
    : ITopicHandler<Order>
{
    public async Task<TopicResponseAction> HandleAsync(
        Order message, 
        TopicContext context, 
        CancellationToken cancellationToken)
    {
        logger.LogInformation("Processing order {OrderId} from pubsub {PubSub}", message.Id, context.PubsubName);

        try
        {
            await inventory.ReserveStockAsync(message.Id, cancellationToken);
            return TopicResponseAction.Success;
        }
        catch (InventoryUnavailableException ex)
        {
            logger.LogWarning(ex, "Insufficient inventory for order {OrderId}; dropping message", message.Id);
            return TopicResponseAction.Drop;
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "Transient error processing order {OrderId}; retrying", message.Id);
            return TopicResponseAction.Retry;
        }
    }
}

Response actions

The HandleAsync method returns a TopicResponseAction that tells the Dapr runtime how to acknowledge the message:

Response ActionDescription
TopicResponseAction.SuccessThe message was processed successfully and should be acknowledged (deleted) from the pub/sub component.
TopicResponseAction.RetryProcessing failed due to a transient error; Dapr will redeliver the message according to the component’s retry policy.
TopicResponseAction.DropProcessing failed permanently; Dapr will delete the message or route it to a configured dead-letter topic without further retries.

Accessing delivery context with TopicContext

The TopicContext passed to HandleAsync provides rich metadata about the delivery:

public async Task<TopicResponseAction> HandleAsync(Order message, TopicContext context, CancellationToken cancellationToken)
{
    var pubsubName = context.PubsubName;       // Name of the Dapr pub/sub component
    var topicName = context.TopicName;         // Name of the topic
    var messageId = context.MessageId;         // Unique message identifier
    var metadata = context.Metadata;           // Additional component-specific metadata
    var headers = context.Headers;             // CloudEvent headers forwarded with the delivery
    var rawBytes = context.RawPayload;         // Raw payload bytes (when EnableRawPayload is true)
    var cloudEvent = context.CloudEvent;       // Strongly-typed CloudEvent envelope
    
    return TopicResponseAction.Success;
}

Configure subscriptions with [DaprTopic]

The [DaprTopic] attribute specifies which topic to subscribe to and how messages are delivered.

Positional constructor arguments

The [DaprTopic] constructor requires two positional string arguments:

  1. pubsubName (1st argument): The name of the Dapr pub/sub component (as defined in your component YAML’s metadata.name, e.g., "pubsub" or "messagebus").
  2. topicName (2nd argument): The name of the topic or queue to subscribe to (e.g., "orders" or "orders.us").
// Subscribes to the "orders" topic on the "pubsub" component
[DaprTopic("pubsub", "orders")]
public class OrderHandler : ITopicHandler<Order> { /* ... */ }

Attribute definition and properties

[AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = false)]
public sealed class DaprTopicAttribute(string pubsubName, string topicName) : Attribute
{
    public string PubsubName { get; }
    public string TopicName { get; }
    public DeliveryMode Delivery { get; set; } = DeliveryMode.Streaming;
    public string? Route { get; set; }
    public string? Match { get; set; }
    public int Priority { get; set; }
    public string? DeadLetterTopic { get; set; }
    public bool EnableRawPayload { get; set; }
    public bool BulkSubscribe { get; set; }
    public int MaxMessagesCount { get; set; } = 100;
    public int MaxAwaitDurationMs { get; set; } = 1000;
    public string[]? MetadataKeys { get; set; }
}

Property applicability by delivery mode

Not all properties on [DaprTopic] are relevant to every delivery mode. The table below outlines which properties apply to each mode:

PropertyDescriptionDeliveryMode.StreamingDeliveryMode.ProgrammaticDeliveryMode.Http
PubsubNameName of the Dapr pub/sub componentRequiredRequiredRequired
TopicNameName of the topicRequiredRequiredRequired
DeliveryDelivery mode (Streaming, Programmatic, Http)Optional (Default)OptionalOptional
RouteTarget HTTP path for event dispatchingIgnoredIgnoredApplicable
MatchCEL expression for content-based routingIgnoredApplicableApplicable
PriorityPriority order for matching CEL rulesIgnoredApplicableApplicable
DeadLetterTopicFallback topic for dropped/failed messagesApplicableApplicableApplicable
EnableRawPayloadForward raw bytes without CloudEvent parsingApplicableApplicableApplicable
BulkSubscribeEnable batch message deliveryIgnoredApplicableApplicable
MaxMessagesCountMaximum batch size for bulk subscriptionsIgnoredApplicableApplicable
MaxAwaitDurationMsMaximum wait duration (ms) for batch assemblyIgnoredApplicableApplicable
MetadataKeysKeys linking [DaprTopicMetadata] to this topicApplicableApplicableApplicable

The three delivery modes

DeliveryMode controls the communication protocol between the Dapr sidecar and your application:

  1. DeliveryMode.Streaming (Default):

    • The application initiates a persistent bidirectional gRPC stream (SubscribeTopicEventsAlpha1) to the Dapr sidecar.
    • Messages are pulled by the application with client-side backpressure buffering.
    • No inbound HTTP or gRPC endpoints need to be mapped for streaming subscriptions.
    • Ignores HTTP routing properties like Route.
    [DaprTopic("pubsub", "orders", Delivery = DeliveryMode.Streaming)]
    public class StreamingOrderHandler : ITopicHandler<Order> { /* ... */ }
    
  2. DeliveryMode.Programmatic:

    • The Dapr sidecar pushes messages to your application’s AppCallback gRPC service.
    • Subscriptions are discovered automatically at startup via ListTopicSubscriptions and invoked via OnTopicEvent.
    • Requires calling app.MapDaprMessaging() on the endpoint pipeline.
    • Supports CEL routing rules (Match, Priority) and BulkSubscribe. Ignores Route.
    [DaprTopic("pubsub", "orders", Delivery = DeliveryMode.Programmatic)]
    public class ProgrammaticOrderHandler : ITopicHandler<Order> { /* ... */ }
    
  3. DeliveryMode.Http:

    • The Dapr sidecar discovers subscriptions by calling GET /dapr/subscribe and pushes events via POST <route>.
    • Requires calling app.MapDaprMessaging() on the endpoint pipeline.
    • Route defines the HTTP path where events are posted (defaults to the topic name if omitted).
    [DaprTopic("pubsub", "orders", Delivery = DeliveryMode.Http, Route = "/api/orders")]
    public class HttpOrderHandler : ITopicHandler<Order> { /* ... */ }
    

Content-based routing with CEL

You can filter and route messages based on Common Expression Language (CEL) expressions using the Match and Priority properties:

[DaprTopic("pubsub", "orders", Match = "event.type == \"priority\"", Priority = 1)]
public sealed class PriorityOrderHandler : ITopicHandler<Order> { /* ... */ }

[DaprTopic("pubsub", "orders", Priority = 2)]
public sealed class StandardOrderHandler : ITopicHandler<Order> { /* ... */ }

Dead-letter topics and bulk subscription

  • Dead-letter topic: Set DeadLetterTopic = "orders-dlq" to route dropped messages to a dead-letter queue.
  • Bulk subscription: Set BulkSubscribe = true, MaxMessagesCount, and MaxAwaitDurationMs to receive batches of messages.
  • Raw payloads: Set EnableRawPayload = true to receive the unparsed event payload without CloudEvent envelope parsing.

Topic metadata with [DaprTopicMetadata]

You can attach key-value metadata to your topic subscription using the [DaprTopicMetadata] attribute:

[DaprTopic("pubsub", "orders", MetadataKeys = ["rawPayload"])]
[DaprTopicMetadata("rawPayload", "true")]
public sealed class RawOrderHandler : ITopicHandler<byte[]> { /* ... */ }

Handling multiple topics on a single handler

[DaprTopic] allows multiple attributes on a single handler class. The handler processes messages from each configured topic:

[DaprTopic("pubsub", "orders.us")]
[DaprTopic("pubsub", "orders.eu")]
[DaprTopic("pubsub", "orders.asia")]
public sealed class MultiRegionOrderHandler : ITopicHandler<Order>
{
    public Task<TopicResponseAction> HandleAsync(Order message, TopicContext context, CancellationToken cancellationToken)
    {
        Console.WriteLine($"Received order from topic: {context.TopicName}");
        return Task.FromResult(TopicResponseAction.Success);
    }
}

What the source generator produces

When you build your project, the Dapr.Messaging.Generators Roslyn source generator discovers all [DaprTopic] classes implementing ITopicHandler<TMessage> and emits:

Generated ArtifactPurpose
Typed DispatchersDirect, strongly-typed invokers that deserialize message payloads with System.Text.Json and invoke HandleAsync without reflection-based handler discovery.
IDaprMessagingSubscriberRegistryA centralized registry containing TopicSubscriptionDescriptor definitions for all discovered topics.
AddDaprMessaging()An IServiceCollection extension method generated into your assembly that automatically registers options, the publishing client, handlers, dispatchers, subscriber registries, and required hosting services into your DI container.

Handler discovery and dispatch registration are generated at compile time. Message deserialization currently uses runtime System.Text.Json metadata, so Native AOT and trimming scenarios require additional validation and an application-owned JsonSerializerContext.

Register at startup

Register Dapr messaging services in Program.cs using builder.Services.AddDaprMessaging():

var builder = WebApplication.CreateBuilder(args);

// Register the complete Dapr messaging stack (options, publisher client, and source-generated subscribers)
builder.Services.AddDaprMessaging(options =>
{
    // Optional: configure Dapr sidecar connection options
    options.DaprGrpcEndpoint = "http://localhost:50001";
});

var app = builder.Build();

// Automatically map the endpoints required by the discovered delivery modes
app.MapDaprMessaging();

app.Run();

Dynamic streaming subscriptions

In addition to declarative [DaprTopic] handlers, DaprPublishSubscribeClient allows creating imperative, dynamic streaming subscriptions at runtime:

var messagingClient = app.Services.GetRequiredService<DaprPublishSubscribeClient>();

// Configure subscription options
var options = new DaprSubscriptionOptions(
    new MessageHandlingPolicy(
        TimeoutDuration: TimeSpan.FromSeconds(10), 
        DefaultResponseAction: TopicResponseAction.Retry))
{
    DeadLetterTopic = "orders-dlq",
    MaximumQueuedMessages = 500,
    MaximumCleanupTimeout = TimeSpan.FromSeconds(15),
    ErrorHandler = async (DaprException ex) =>
    {
        Console.WriteLine($"Subscription error: {ex.Message}");
    }
};

// Start the dynamic subscription
await using var subscription = await messagingClient.SubscribeAsync(
    "pubsub", 
    "dynamic-orders", 
    options, 
    async (TopicMessage message, CancellationToken ct) =>
    {
        var payload = Encoding.UTF8.GetString(message.Data.Span);
        Console.WriteLine($"Received dynamic message: {payload}");
        return TopicResponseAction.Success;
    });

// SubscribeAsync is typed as IAsyncDisposable; the runtime handle also exposes completion.
await ((IDaprSubscription)subscription).Completion;

Analyzers and diagnostics

Dapr.Messaging ships with Roslyn analyzers that enforce correct messaging practices at build time:

IDTitleSeverityDescription
DAPR1610Duplicate topic with conflicting delivery modesErrorTriggered when the same (pubsubName, topicName) is configured with different DeliveryMode values across [DaprTopic] attributes.
DAPR1611Handler must implement ITopicHandler<T>ErrorTriggered when [DaprTopic] is placed on a class that does not implement ITopicHandler<TMessage> or ITopicHandler<TMessage, TResult>.
DAPR1612Message type not registered in JsonSerializerContextWarningWarns when a message type is not covered by [JsonSerializable] in a Native AOT compilation.
DAPR1613Map endpoints for programmatic subscriptionsWarningWarns when programmatic subscriptions are configured without app.MapDaprAppCallback() or app.MapDaprMessaging(). Includes an automated Code Fix.
DAPR1614Do not call DaprMessagingRegistration directlyWarningWarns when DaprMessagingRegistration is called directly from application code instead of calling services.AddDaprMessaging().
DAPR1615Remove unused subscriber registrationWarningWarns when subscriber registration or endpoint mapping is present without matching HTTP or programmatic topic subscribers.
DAPR1616Incomplete [DaprTopic] configurationWarningWarns when [DaprTopic] opts into a feature (like BulkSubscribe = true) without setting its required companion properties (MaxMessagesCount, MaxAwaitDurationMs).
DAPR1617Ignored [DaprTopic] configurationWarningWarns when [DaprTopic] sets a property that is ignored for the selected delivery mode or feature set (e.g., Route or Match on non-HTTP topics).

For a complete reference of all Roslyn analyzers, diagnostic severities, and available automated code fixes across the Dapr .NET SDK, see Dapr source code analyzers and generators.

Best practices

  • Prefer DeliveryMode.Streaming for background workers, console apps, and services where you want bidirectional streaming and client-side backpressure without hosting an inbound HTTP or gRPC server.
  • Prefer DeliveryMode.Programmatic for high-throughput gRPC services where the Dapr sidecar pushes events directly into ASP.NET Core gRPC endpoints.
  • Always handle transient vs permanent errors: Return TopicResponseAction.Retry for transient errors (network timeouts, database locks) and TopicResponseAction.Drop for unrecoverable errors (poison messages, schema violations) to route them to a dead-letter topic.
  • Inject scoped services: ITopicHandler<TMessage> instances are resolved per message within an isolated IServiceScope, ensuring safe resolution of scoped dependencies like Entity Framework DbContext.
  • Validate Native AOT explicitly: Generated dispatchers currently use runtime System.Text.Json metadata; test Native AOT and trimming with your target SDK version and message types.

Next steps