An order service publishing events through Dapr to a broker and several subscribers

A synchronous request establishes a direct dependency: the caller pauses while the destination handles the task. This approach is suitable when an immediate response is required. However, it is less ideal when multiple independent services need to respond to an event that has already occurred.

An order can be accepted immediately without waiting for notification, fulfillment, analytics, or fraud services to complete. Publishing an order placed event allows these consumers to operate independently, scale separately, and recover from temporary failures. However, it also introduces new responsibilities such as managing event contracts, handling duplicate deliveries, setting retry limits, dealing with poison messages, maintaining order, and managing broker operations.

Dapr pub/sub provides a uniform HTTP or gRPC API in front of the broker. Applications publish data to a specified component and topic, while subscribers receive events at defined endpoints. The component can connect to Redis for development purposes and to Azure Service Bus Topics on AKS, all without needing to include the broker’s client library in the application.

Outcome: Deploy an order publisher and notification subscriber, route both normal and priority CloudEvents, ensure at-least-once delivery, force a message to retry into a dead-letter topic, and prepare the same contract for Azure Service Bus with Workload ID.

This is Part 6 of the Dapr on Kubernetes and AKS series. It follows State Management with Dapr on AKS, but the sample only requires a Kubernetes cluster with Dapr installed.

Follow one order event

A Dapr publisher, message broker, routed subscriber, retries, and dead-letter topic

The same architecture can be reduced to a simple pencil sketch: application and sidecar on the left, broker in the middle, subscriber on the right, and a separate path for messages that exhaust their retry budget.

The order and notification services do not call each other:

  1. A client submits an order to order-api.
  2. The application publishes JSON to its local Dapr sidecar.
  3. Dapr wraps the payload in a CloudEvents 1.0 envelope and writes it to the orders topic.
  4. The subscriber sidecar receives the event through the configured broker subscription.
  5. A declarative Dapr subscription routes priority events to /priority-orders and other orders to /orders.
  6. The notification application returns SUCCESS, RETRY, or DROP behavior through its HTTP response.
  7. A message that still fails after the configured retry policy is published to orders-deadletter.

The publisher knows the Dapr component name orders-pubsub and the topic orders. It does not know the subscriber’s app ID, address, replica count, or programming language.

What Dapr abstracts

Dapr gives applications a common publish and subscribe contract, CloudEvents integration, routing, and acknowledgment behavior. The Component contains the broker-specific connection details:

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: orders-pubsub
  namespace: dapr-demo
spec:
  type: pubsub.redis
  version: v1
  metadata:
    - name: redisHost
      value: redis.dapr-demo.svc.cluster.local:6379
    - name: redisPassword
      value: ""
    - name: enableTLS
      value: "false"
scopes:
  - order-api
  - notification-api

Changing spec.type and metadata can move the API contract to another supported broker. It does not erase differences in ordering, partitions, delivery limits, entity management, retention, throughput, or cost. Broker portability is most useful when the application depends on basic topics and explicit delivery behavior rather than every vendor-specific feature.

Deploy the sample

The learning manifest contains:

  • one Redis instance for the local broker;
  • an orders-pubsub component;
  • order-api, which publishes order events;
  • notification-api, which consumes them;
  • subscriptions for orders and orders-deadletter; and
  • a resiliency policy that retries delivery three times before dead-lettering.

Redis has no authentication or durable volume in this example. It is a disposable learning dependency, not a production broker.

Download the complete pub/sub manifest, then apply it:

kubectl apply \
  --filename pubsub.yaml

kubectl rollout status \
  deployment/redis \
  --namespace dapr-demo

kubectl rollout status \
  deployment/notification-api \
  --namespace dapr-demo

kubectl rollout status \
  deployment/order-api \
  --namespace dapr-demo

Inspect the Dapr resources and injected containers:

kubectl get components.dapr.io,subscriptions.dapr.io,resiliencies.dapr.io \
  --namespace dapr-demo

kubectl get pods \
  --namespace dapr-demo \
  --output jsonpath='{range .items[*]}{.metadata.name}{"  "}{.spec.containers[*].name}{"\n"}{end}'

Check both sidecars when a subscription is missing or the broker cannot be reached:

kubectl logs \
  deployment/order-api \
  --container daprd \
  --namespace dapr-demo \
  --tail 100

kubectl logs \
  deployment/notification-api \
  --container daprd \
  --namespace dapr-demo \
  --tail 100

The publisher sidecar must load the component. The subscriber sidecar must load the component and discover both declarative subscriptions.

The following screenshot shows representative output after a healthy deployment. Kubernetes generates different pod suffixes in your cluster.

Representative terminal output showing the Dapr pub/sub sample running with Redis

Publish an order

Forward local port 3500 to the Dapr sidecar next to order-api:

kubectl port-forward \
  deployment/order-api \
  3500:3500 \
  --namespace dapr-demo

Keep that terminal open. From another terminal, invoke the order application through Dapr:

curl --fail --show-error \
  --request POST \
  --header 'Content-Type: application/json' \
  --data '{
    "orderId": "order-1001",
    "sku": "coffee-001",
    "quantity": 2,
    "priority": false
  }' \
  http://localhost:3500/v1.0/invoke/order-api/method/orders

The publisher returns 202 Accepted with an event ID after its local sidecar accepts the publish operation:

{"eventId": "<generated-id>", "status": "published"}

That response does not mean the notification has been sent. It means the publish call succeeded. The subscriber executes asynchronously.

Follow the subscriber log:

kubectl logs \
  deployment/notification-api \
  --container notification-api \
  --namespace dapr-demo \
  --follow

The event appears at the normal /orders handler. Publish a priority order:

curl --fail --show-error \
  --request POST \
  --header 'Content-Type: application/json' \
  --data '{
    "orderId": "order-1002",
    "sku": "espresso-002",
    "quantity": 1,
    "priority": true
  }' \
  http://localhost:3500/v1.0/invoke/order-api/method/orders

This event reaches /priority-orders. The publisher did not choose that HTTP endpoint. It assigned an event type, and the subscription applied the route.

Route with a declarative subscription

At this point, keep Redis running. The pubsub.yaml file already created the subscription below; you do not need to copy or apply this YAML separately. This section explains what the installed resource does.

Confirm that it exists:

kubectl get subscriptions.dapr.io \
  order-events \
  --namespace dapr-demo \
  --output yaml

Dapr reads this resource for the notification-api sidecar:

apiVersion: dapr.io/v2alpha1
kind: Subscription
metadata:
  name: order-events
  namespace: dapr-demo
spec:
  pubsubname: orders-pubsub
  topic: orders
  routes:
    rules:
      - match: event.type == "com.wolkwacht.order.priority.v1"
        path: /priority-orders
    default: /orders
  deadLetterTopic: orders-deadletter
scopes:
  - notification-api

Read it from top to bottom:

  1. pubsubname selects the Dapr component named orders-pubsub. At this stage, that component points to Redis.
  2. topic tells Dapr to consume events from orders.
  3. The rule sends events whose CloudEvent type is com.wolkwacht.order.priority.v1 to the application’s /priority-orders endpoint.
  4. default sends every other event to /orders.
  5. deadLetterTopic names the topic that receives events that still fail after the retry policy.
  6. scopes ensures that only the sidecar with app ID notification-api loads this subscription.

The subscription connects broker topics to application HTTP endpoints. You do not create a Kubernetes Service for those endpoints: the Dapr sidecar calls the application container over localhost on the annotated app port.

The v2alpha1 subscription API is the current default specification; the older v1alpha1 form is deprecated. Routing expressions can inspect nested JSON in event.data, but a versioned event.type usually produces a clearer contract than routing on incidental payload fields.

Verify both routes before continuing:

# priority: false -> /orders
curl --fail --show-error \
  --request POST \
  --header 'Content-Type: application/json' \
  --data '{"orderId":"route-normal","sku":"coffee-001","quantity":1,"priority":false}' \
  http://localhost:3500/v1.0/invoke/order-api/method/orders

# priority: true -> /priority-orders
curl --fail --show-error \
  --request POST \
  --header 'Content-Type: application/json' \
  --data '{"orderId":"route-priority","sku":"coffee-001","quantity":1,"priority":true}' \
  http://localhost:3500/v1.0/invoke/order-api/method/orders

kubectl logs \
  deployment/notification-api \
  --container notification-api \
  --namespace dapr-demo \
  --tail 20

The log field handler should show /orders for the first event and /priority-orders for the second. If neither appears, inspect the subscriber’s daprd log and confirm the component and subscription are in dapr-demo.

Representative terminal output showing normal and priority events reaching different notification handlers

Understand the CloudEvent envelope

The publisher sends ordinary JSON with Content-Type: application/json. Dapr wraps it in a CloudEvents 1.0 envelope resembling:

{
  "specversion": "1.0",
  "id": "4a493d16-3cf7-4cc0-a95c-b9b9a48cf9aa",
  "source": "order-api",
  "type": "com.wolkwacht.order.placed.v1",
  "datacontenttype": "application/json",
  "topic": "orders",
  "pubsubname": "orders-pubsub",
  "data": {
    "orderId": "order-1001",
    "sku": "coffee-001",
    "quantity": 2,
    "priority": false
  }
}

The sample overrides id, source, and type with publish metadata. The type is a versioned event contract, not the name of the service method that happened to create it. The combination of source and id identifies a distinct event and gives consumers a deduplication key.

CloudEvents also carry content type and tracing context consistently across brokers. Dapr can publish raw payloads for integration with systems that do not use CloudEvents, but raw mode gives up schema-based routing, per-event tracing context, and the standard ID used for deduplication. Prefer CloudEvents unless interoperability requires otherwise.

At-least-once means duplicates are normal

Dapr pub/sub provides at-least-once delivery. A consumer can finish its side effect and crash before its acknowledgement reaches Dapr. The same event may then be delivered again.

The subscriber must therefore be idempotent. Common approaches include:

  • storing the CloudEvent source and id in a durable processed-event table;
  • using the event ID as an idempotency key for an external API;
  • making the domain write conditional on a business version; or
  • designing naturally idempotent operations, such as setting a desired state rather than incrementing it.

The sample remembers IDs in memory only so duplicate handling is visible in the log. That set disappears during a restart and is not shared between replicas. It is explicitly not a production deduplication store.

Avoid promising exactly-once processing merely because one test produces one log entry. Exactly-once business effects require cooperation between message acknowledgement, durable state, and every external side effect. If publishing an event must be atomic with a state change, evaluate Dapr’s transactional outbox pattern with a compatible state store and pub/sub component.

Acknowledge deliberately

For an HTTP subscriber, an empty 2xx response means success. A JSON response can be explicit:

{"status": "SUCCESS"}

The supported outcomes are:

ResponseDapr behavior
2xx with SUCCESS, or an empty 2xxAcknowledge the event
2xx with RETRYRetry delivery
2xx with DROPLog and discard the event
404Log and drop the event
Other non-2xx statusRetry delivery

Use DROP only when discarding the message is an intentional, observable decision. A malformed event may belong in quarantine for investigation rather than being silently removed.

Retry before dead-lettering

With a deadLetterTopic and no resiliency retry policy, a failed delivery is forwarded to the dead-letter topic immediately. This sample applies a bounded retry policy to inbound deliveries from orders-pubsub:

apiVersion: dapr.io/v1alpha1
kind: Resiliency
metadata:
  name: pubsub-retries
  namespace: dapr-demo
spec:
  policies:
    retries:
      pubsubRetry:
        policy: constant
        duration: 1s
        maxRetries: 3
  targets:
    components:
      orders-pubsub:
        inbound:
          retry: pubsubRetry

Trigger the sample’s controlled failure by using the special SKU:

curl --fail --show-error \
  --request POST \
  --header 'Content-Type: application/json' \
  --data '{
    "orderId": "order-failure-demo",
    "sku": "FAIL-DEMO",
    "quantity": 1,
    "priority": false
  }' \
  http://localhost:3500/v1.0/invoke/order-api/method/orders

The normal handler returns a failure, Dapr retries, and the event is eventually published to orders-deadletter. A second declarative subscription delivers it to /deadletters, where the sample logs it successfully.

Watch both application and sidecar logs:

kubectl logs \
  deployment/notification-api \
  --container notification-api \
  --namespace dapr-demo \
  --follow

kubectl logs \
  deployment/notification-api \
  --container daprd \
  --namespace dapr-demo \
  --tail 150

A dead-letter topic is not a bin nobody checks. Define ownership, retention, alerts, inspection tooling, and a safe replay process. Replaying a poison event without changing the data or consumer only creates another failure loop.

Scale the subscriber

Replicas with the same Dapr app ID act as instances of one logical subscriber. Scale the notification deployment:

kubectl scale \
  deployment/notification-api \
  --replicas 3 \
  --namespace dapr-demo

kubectl rollout status \
  deployment/notification-api \
  --namespace dapr-demo

Publish several orders through the order-api sidecar. This assumes the port-forward to localhost:3500 from the earlier step is still running:

for ORDER_NUMBER in $(seq 1 12); do
  curl --fail --show-error --silent \
    --request POST \
    --header 'Content-Type: application/json' \
    --data "{\"orderId\":\"scale-${ORDER_NUMBER}\",\"sku\":\"coffee-001\",\"quantity\":1,\"priority\":false}" \
    http://localhost:3500/v1.0/invoke/order-api/method/orders

  printf '\n'
done

Each request should return a JSON response with "status":"published". Give the subscriber a few seconds to process the events, then compare the logs from all three pods:

kubectl logs \
  --selector app=notification-api \
  --container notification-api \
  --namespace dapr-demo \
  --prefix \
  --tail 100

The broker and component distribute work across the logical subscriber’s replicas. A different app ID subscribing to the same topic represents another logical consumer and receives its own copy according to the broker’s consumer-group model.

Do not infer global ordering from a small test. Parallel handlers, retries, partitions, and replica scaling can change processing order. When order matters, define the ordering key and check the chosen component’s guarantees and configuration.

Replace Redis with Azure Service Bus

This section is an optional second path. Complete the Redis exercise first so you know that the applications, Dapr subscriptions, routes, and failure handler work. Then change only the broker component.

The end state is:

  • the same order-api and notification-api Deployments;
  • the same Dapr subscriptions and resiliency policy;
  • the same component name, orders-pubsub;
  • Azure Service Bus instead of Redis; and
  • one demo Workload Identity used by both pods.

You do not delete the Redis component before applying the Service Bus component. Both definitions have the same Kubernetes name, so kubectl apply updates that object in place. Remove the Redis Deployment and Service only after Service Bus works.

Step 1: Create the Service Bus resource group and namespace

The remaining steps expect an Azure Service Bus namespace to exist. Choose a globally unique namespace name and create it before configuring Dapr:

SERVICE_BUS_RESOURCE_GROUP="<service-bus-resource-group>"
SERVICE_BUS_NAMESPACE="<globally-unique-service-bus-namespace>"
AZURE_LOCATION="westeurope"

az group create \
  --name "$SERVICE_BUS_RESOURCE_GROUP" \
  --location "$AZURE_LOCATION"

az servicebus namespace create \
  --resource-group "$SERVICE_BUS_RESOURCE_GROUP" \
  --name "$SERVICE_BUS_NAMESPACE" \
  --location "$AZURE_LOCATION" \
  --sku Standard

The Standard tier is required because this walkthrough uses Service Bus topics and subscriptions; the Basic tier does not support them. The Service Bus namespace may be in the same resource group as AKS or in a different one.

Confirm that the namespace is ready:

az servicebus namespace show \
  --resource-group "$SERVICE_BUS_RESOURCE_GROUP" \
  --name "$SERVICE_BUS_NAMESPACE" \
  --query '{name:name,status:status,sku:sku.name}' \
  --output table

The status should be Active and the SKU should be Standard.

Step 2: Set the remaining Azure values

Keep SERVICE_BUS_RESOURCE_GROUP and SERVICE_BUS_NAMESPACE from Step 1, then set the AKS cluster and managed identity values:

AKS_RESOURCE_GROUP="<resource-group-containing-aks>"
AKS_CLUSTER_NAME="<aks-cluster-name>"
IDENTITY_NAME="dapr-pubsub-demo"

Confirm that AKS has its OIDC issuer and Workload Identity enabled:

az aks show \
  --resource-group "$AKS_RESOURCE_GROUP" \
  --name "$AKS_CLUSTER_NAME" \
  --query '{oidcIssuer:oidcIssuerProfile.issuerUrl,workloadIdentity:securityProfile.workloadIdentity.enabled}'

If either value is missing, enable both features:

az aks update \
  --resource-group "$AKS_RESOURCE_GROUP" \
  --name "$AKS_CLUSTER_NAME" \
  --enable-oidc-issuer \
  --enable-workload-identity

Step 3: Create the Service Bus entities

With the namespace active, create the two topics and broker subscriptions expected by the sample:

The Service Bus component used later disables automatic entity management. Pre-create the topics and broker subscriptions so the runtime identity does not need broad entity-management permissions:

az servicebus topic create \
  --resource-group "$SERVICE_BUS_RESOURCE_GROUP" \
  --namespace-name "$SERVICE_BUS_NAMESPACE" \
  --name orders

az servicebus topic subscription create \
  --resource-group "$SERVICE_BUS_RESOURCE_GROUP" \
  --namespace-name "$SERVICE_BUS_NAMESPACE" \
  --topic-name orders \
  --name notification-api

az servicebus topic create \
  --resource-group "$SERVICE_BUS_RESOURCE_GROUP" \
  --namespace-name "$SERVICE_BUS_NAMESPACE" \
  --name orders-deadletter

az servicebus topic subscription create \
  --resource-group "$SERVICE_BUS_RESOURCE_GROUP" \
  --namespace-name "$SERVICE_BUS_NAMESPACE" \
  --topic-name orders-deadletter \
  --name notification-api

There are two kinds of subscription here:

  • The Azure Service Bus subscription named notification-api stores and delivers messages for one broker consumer.
  • The Dapr Subscription resources already in Kubernetes map those messages to /orders, /priority-orders, and /deadletters.

You need both layers. Creating only the Service Bus namespace and topics is not enough when automatic entity management is disabled.

Step 4: Create and authorize the managed identity

If you opened a new terminal since setting the values in Steps 1 and 2, set them again. Stop immediately when any required value is empty:

: "${SERVICE_BUS_RESOURCE_GROUP:?Set SERVICE_BUS_RESOURCE_GROUP before continuing}"
: "${SERVICE_BUS_NAMESPACE:?Set SERVICE_BUS_NAMESPACE before continuing}"
: "${IDENTITY_NAME:?Set IDENTITY_NAME before continuing}"

printf 'Resource group: %s\nNamespace: %s\nIdentity: %s\n' \
  "$SERVICE_BUS_RESOURCE_GROUP" \
  "$SERVICE_BUS_NAMESPACE" \
  "$IDENTITY_NAME"

Create one identity for this walkthrough and capture its IDs:

IDENTITY_CLIENT_ID="$(
  az identity create \
    --resource-group "$SERVICE_BUS_RESOURCE_GROUP" \
    --name "$IDENTITY_NAME" \
    --query clientId \
    --output tsv
)"

IDENTITY_PRINCIPAL_ID="$(
  az identity show \
    --resource-group "$SERVICE_BUS_RESOURCE_GROUP" \
    --name "$IDENTITY_NAME" \
    --query principalId \
    --output tsv
)"

SERVICE_BUS_ID="$(
  az servicebus namespace show \
    --resource-group "$SERVICE_BUS_RESOURCE_GROUP" \
    --name "$SERVICE_BUS_NAMESPACE" \
    --query id \
    --output tsv
)"

Confirm that neither result is empty before continuing:

printf 'Client ID: %s\nPrincipal ID: %s\n' \
  "$IDENTITY_CLIENT_ID" \
  "$IDENTITY_PRINCIPAL_ID"

The order sidecar publishes, while the notification sidecar receives and may publish to the dead-letter topic. For the simple shared identity, assign both data-plane roles:

az role assignment create \
  --assignee-object-id "$IDENTITY_PRINCIPAL_ID" \
  --assignee-principal-type ServicePrincipal \
  --role "Azure Service Bus Data Sender" \
  --scope "$SERVICE_BUS_ID"

az role assignment create \
  --assignee-object-id "$IDENTITY_PRINCIPAL_ID" \
  --assignee-principal-type ServicePrincipal \
  --role "Azure Service Bus Data Receiver" \
  --scope "$SERVICE_BUS_ID"

For production, use separate publisher and subscriber identities and narrower scopes. The shared identity keeps this migration walkthrough understandable.

Step 5: Connect the Kubernetes service account to the identity

Create a federated credential whose subject exactly matches the namespace and service-account name used below:

AKS_OIDC_ISSUER="$(
  az aks show \
    --resource-group "$AKS_RESOURCE_GROUP" \
    --name "$AKS_CLUSTER_NAME" \
    --query oidcIssuerProfile.issuerUrl \
    --output tsv
)"

: "${AKS_OIDC_ISSUER:?AKS returned no OIDC issuer; enable OIDC and Workload Identity in Step 2}"

printf 'AKS OIDC issuer: %s\n' "$AKS_OIDC_ISSUER"

az identity federated-credential create \
  --resource-group "$SERVICE_BUS_RESOURCE_GROUP" \
  --identity-name "$IDENTITY_NAME" \
  --name dapr-pubsub \
  --issuer "$AKS_OIDC_ISSUER" \
  --subject system:serviceaccount:dapr-demo:dapr-pubsub-wi \
  --audiences api://AzureADTokenExchange

Download the Workload Identity service-account template, replace its client-ID placeholder, confirm that no placeholder remains, and apply it:

: "${IDENTITY_CLIENT_ID:?Set IDENTITY_CLIENT_ID from Step 4 before continuing}"

sed -i.bak \
  -e "s/<managed-identity-client-id>/${IDENTITY_CLIENT_ID}/g" \
  servicebus-workload-identity.yaml

if grep --line-number --extended-regexp '<[^>]+>' servicebus-workload-identity.yaml; then
  echo "Resolve the placeholders shown above before continuing."
else
  echo "No unresolved placeholders"
fi

kubectl apply \
  --filename servicebus-workload-identity.yaml

Attach that service account to both demo Deployments. These patches trigger new pods:

kubectl patch deployment order-api \
  --namespace dapr-demo \
  --type merge \
  --patch '{
    "spec": {
      "template": {
        "metadata": {
          "labels": {"azure.workload.identity/use": "true"},
          "annotations": {"dapr.io/sentry-request-jwt-audiences": "api://AzureADTokenExchange"}
        },
        "spec": {"serviceAccountName": "dapr-pubsub-wi"}
      }
    }
  }'

kubectl patch deployment notification-api \
  --namespace dapr-demo \
  --type merge \
  --patch '{
    "spec": {
      "template": {
        "metadata": {
          "labels": {"azure.workload.identity/use": "true"},
          "annotations": {"dapr.io/sentry-request-jwt-audiences": "api://AzureADTokenExchange"}
        },
        "spec": {"serviceAccountName": "dapr-pubsub-wi"}
      }
    }
  }'

kubectl rollout status deployment/order-api --namespace dapr-demo
kubectl rollout status deployment/notification-api --namespace dapr-demo

Step 6: Replace the Dapr component

Download the Azure Service Bus component template. Replace both placeholders:

  • <service-bus-namespace> with $SERVICE_BUS_NAMESPACE;
  • <managed-identity-client-id> with $IDENTITY_CLIENT_ID.

The following command performs both replacements and keeps the original template in servicebus-pubsub.yaml.bak:

sed -i.bak \
  -e "s/<service-bus-namespace>/${SERVICE_BUS_NAMESPACE}/g" \
  -e "s/<managed-identity-client-id>/${IDENTITY_CLIENT_ID}/g" \
  servicebus-pubsub.yaml

Do not apply the component until this validation command prints No unresolved placeholders:

if grep --line-number --extended-regexp '<[^>]+>' servicebus-pubsub.yaml; then
  echo "Resolve the placeholders shown above before continuing."
else
  echo "No unresolved placeholders"
fi

The resulting component should contain the real namespace hostname and managed-identity client ID, not the example values shown here:

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: orders-pubsub
  namespace: dapr-demo
spec:
  type: pubsub.azure.servicebus.topics
  version: v1
  metadata:
    - name: namespaceName
      value: my-service-bus.servicebus.windows.net
    - name: azureClientId
      value: 11111111-2222-3333-4444-555555555555
    - name: disableEntityManagement
      value: "true"
    - name: maxConcurrentHandlers
      value: "10"
scopes:
  - order-api
  - notification-api

Apply it. Because its name and namespace match the Redis component, this changes that existing Kubernetes object from pubsub.redis to pubsub.azure.servicebus.topics:

kubectl apply \
  --filename servicebus-pubsub.yaml

kubectl get component orders-pubsub \
  --namespace dapr-demo \
  --output jsonpath='{.spec.type}{"\n"}{.spec.metadata[?(@.name=="namespaceName")].value}{"\n"}{.spec.metadata[?(@.name=="azureClientId")].value}{"\n"}'

The output must show the Service Bus component type, your real namespace hostname, and the managed-identity client ID:

pubsub.azure.servicebus.topics
my-service-bus.servicebus.windows.net
11111111-2222-3333-4444-555555555555

Restart both Deployments so this walkthrough does not depend on component hot-reload behavior:

kubectl rollout restart \
  deployment/order-api \
  deployment/notification-api \
  --namespace dapr-demo

kubectl rollout status deployment/order-api --namespace dapr-demo
kubectl rollout status deployment/notification-api --namespace dapr-demo

Step 7: Verify Service Bus before removing Redis

First inspect the sidecars for component initialization or authorization errors:

kubectl logs \
  deployment/order-api \
  --container daprd \
  --namespace dapr-demo \
  --since 5m

kubectl logs \
  deployment/notification-api \
  --container daprd \
  --namespace dapr-demo \
  --since 5m

The earlier restart ended the old port-forward session. Start it again:

kubectl port-forward \
  deployment/order-api \
  3500:3500 \
  --namespace dapr-demo

From another terminal, publish a new order:

curl --fail --show-error \
  --request POST \
  --header 'Content-Type: application/json' \
  --data '{"orderId":"service-bus-1001","sku":"coffee-001","quantity":1,"priority":false}' \
  http://localhost:3500/v1.0/invoke/order-api/method/orders

kubectl logs \
  deployment/notification-api \
  --container notification-api \
  --namespace dapr-demo \
  --tail 20

The order should appear in the notification log just as it did with Redis. The application URLs, topic, subscription routes, and payload did not change; only the orders-pubsub component changed.

Representative terminal output showing the Dapr component switched to Azure Service Bus and an event reaching the subscriber

If authorization fails immediately after role assignment, allow time for Azure RBAC propagation and restart the Deployments again. Also confirm that the federated credential subject, Kubernetes namespace, and service-account name match exactly.

Step 8: Remove the unused Redis workload

Only after a Service Bus event has reached the subscriber, remove the now-unused Redis workload:

kubectl delete deployment redis \
  --namespace dapr-demo

kubectl delete service redis \
  --namespace dapr-demo

Do not delete the orders-pubsub component: it now contains the Service Bus configuration.

Step 9: Prove that messaging still works without Redis

Keep the port-forward from Step 7 running. If it has stopped, start it again in a separate terminal:

kubectl port-forward \
  deployment/order-api \
  3500:3500 \
  --namespace dapr-demo

Publish an order with a unique ID after Redis has been removed:

curl --fail-with-body --show-error \
  --max-time 40 \
  --request POST \
  --header 'Content-Type: application/json' \
  --data '{"orderId":"service-bus-after-redis","sku":"coffee-001","quantity":1,"priority":false}' \
  http://localhost:3500/v1.0/invoke/order-api/method/orders

Give the subscriber a few seconds to process the event, then search the logs from all subscriber replicas:

kubectl logs \
  --selector app=notification-api \
  --container notification-api \
  --namespace dapr-demo \
  --prefix \
  --since 2m \
  | grep service-bus-after-redis

The output should contain service-bus-after-redis. Because the Redis Deployment and Service no longer exist, this final event confirms that publishing and delivery are using Azure Service Bus.

If the event does not appear, inspect both Dapr sidecars before changing anything else:

kubectl logs \
  deployment/order-api \
  --container daprd \
  --namespace dapr-demo \
  --since 5m

kubectl logs \
  --selector app=notification-api \
  --container daprd \
  --namespace dapr-demo \
  --prefix \
  --since 5m

To roll back, reapply the complete Redis manifest and restart the two applications:

kubectl apply --filename pubsub.yaml

kubectl rollout restart \
  deployment/order-api \
  deployment/notification-api \
  --namespace dapr-demo

Service Bus or Event Hubs?

Both have Dapr pub/sub components, but they target different workloads:

RequirementAzure Service Bus TopicsAzure Event Hubs
Primary modelEnterprise messaging and competing consumersPartitioned event stream and replay
Typical dataOrders, payments, workflow events, commandsTelemetry, logs, clickstreams, high-volume events
Consumer positionBroker-managed message deliveryConsumer-managed checkpoints per partition
OrderingSessions and broker semanticsOrdering within a partition
Failure operationsDelivery count and broker dead-letter capabilitiesRetention, checkpoints, and consumer recovery patterns
Dapr setupNamespace plus topics/subscriptionsNamespace plus event hubs, consumer groups, and checkpoint storage

For the order-notification example, Service Bus is the clearer default. Event Hubs becomes attractive when the event stream itself is the product: high throughput, partition-aware consumers, retention, and replay. Dapr’s Event Hubs component requires a consumer group for each subscriber app ID and uses Azure Storage for checkpointing. Swapping the component therefore preserves the publish API but changes the operating model substantially.

Production rules for Dapr pub/sub

Before using this pattern for business-critical events:

  • Treat at-least-once delivery and duplicate events as normal.
  • Version event types and evolve payloads compatibly.
  • Store deduplication state durably when side effects are not naturally idempotent.
  • Bound retries and add jitter where retry storms are possible.
  • Monitor publish failures, delivery latency, retry volume, dead-letter growth, and consumer lag.
  • Put alerts and ownership around dead-letter topics.
  • Define ordering requirements explicitly; do not assume cluster-wide FIFO behavior.
  • Use Workload ID and data-plane RBAC rather than connection strings.
  • Scope Dapr components, subscriptions, topics, and identities.
  • Load-test sidecars, broker quotas, handler concurrency, and downstream dependencies together.
  • Pin container images to reviewed digests in production.
  • Use an outbox when state changes and event publication must succeed as one business operation.

Clean up

Stop port forwarding with Ctrl+C, then delete the sample namespace:

kubectl delete namespace dapr-demo

That command does not remove the Azure resources created in this walkthrough.

If the Service Bus resource group is shared with other workloads, delete only the demo namespace and managed identity:

az servicebus namespace delete \
  --resource-group "$SERVICE_BUS_RESOURCE_GROUP" \
  --name "$SERVICE_BUS_NAMESPACE"

az identity delete \
  --resource-group "$SERVICE_BUS_RESOURCE_GROUP" \
  --name "$IDENTITY_NAME"

Deleting the Service Bus namespace also removes its orders and orders-deadletter topics, their subscriptions, and role assignments scoped to that namespace. Deleting the managed identity removes its federated credential.

If you created a dedicated resource group for this walkthrough and it contains nothing you want to keep, you can delete the entire group instead of running the two commands above:

az group delete \
  --name "$SERVICE_BUS_RESOURCE_GROUP"

Azure CLI asks for confirmation before deleting the resource group. Do not use this option when the group contains shared resources.

The pub/sub pattern

Dapr removes broker client code from the publisher and subscriber, but asynchronous design still belongs to the application team. The durable contract is the event: what happened, which version describes it, how duplicates are recognized, and what a consumer may safely do more than once.

The practical rules are:

  1. Publish facts through the local sidecar rather than embedding a broker SDK in every service.
  2. Use versioned CloudEvent types as explicit contracts.
  3. Keep routing declarative when it is operational configuration rather than business logic.
  4. Design every consumer for duplicate delivery.
  5. Retry transient failures within a bounded budget, then dead-letter with ownership.
  6. Select Service Bus or Event Hubs from the delivery model, not from the similarity of their Azure names.

Next in the series, we will combine Dapr bindings, Azure Key Vault-backed secrets, and virtual actors, and explain when each building block is useful—and when it is unnecessary.

Return to the Dapr on Kubernetes and AKS series index.

Sources