Dapr for Beginners: Building Pub/Sub Microservices with Dapr

Dapr for Beginners: Building Pub/Sub Microservices with Dapr

Introduction: From Direct Calls to Events

In the previous article, we built a small two-service application with Dapr.

The flow was direct:

Order Service
→ calls Inventory Service through Dapr service invocation
→ waits for the response
→ returns the result

That was the right shape for that example.

The Order Service needed an immediate answer:

Is there enough stock?
Can this order be placed?
What quantity remains?

When a service needs an answer before it can continue, direct service invocation makes sense.

But not every part of a system needs that kind of request/response flow.

After an important business action happens, other services may need to react to it.

For example, once an order has been accepted, different parts of the system may need to do their own work:

Inventory updates its stock record.
Notifications sends a confirmation email.
Analytics records the order.
Fulfilment starts the shipping process.

The Order Service does not always need to call each one directly and wait for every reaction to finish.

Sometimes it only needs to announce:

OrderPlaced

Then interested services can react independently.

That is the communication shape we will build in this article.

Instead of this:

Order Service
→ calls Inventory Service
→ waits for Inventory Service to respond

we will build this:

Order Service
→ publishes OrderPlaced event

Inventory Service
→ subscribes to OrderPlaced
→ reacts when the event arrives

This does not mean pub/sub should replace every direct service call.

If the Order Service must know whether the order can be accepted, service invocation is still the better fit. But if the order has already been accepted and other services only need to react, pub/sub gives us a cleaner shape.

In this article, we will keep the same simple domain from the previous article: orders and inventory.

But we will change the communication pattern.

The Order Service will publish an OrderPlaced event through Dapr’s pub/sub API. The Inventory Service will subscribe to that event and update its inventory state when the event arrives.

The important shift is this:

Service invocation
→ one service asks another service for an immediate answer

Pub/sub
→ one service announces that something happened
→ other services react independently

By the end, you will have a small event-driven Dapr application running locally.

What We Are Building

We will build a small event-driven version of the same orders and inventory example.

This time, the Order Service will not call the Inventory Service directly.

Instead, the flow will look like this:

Order Service
→ publishes OrderPlaced event through Dapr pub/sub

Inventory Service
→ subscribes to OrderPlaced
→ receives the event
→ updates inventory state through Dapr state management

The application still has two services:

Order Service
→ Node.js API
→ receives an order request
→ publishes an OrderPlaced event

Inventory Service
→ Python Flask API
→ subscribes to OrderPlaced
→ reduces stock when the event arrives
→ stores inventory through Dapr state management

Dapr sits between the services and the local infrastructure:

Order Service
→ local Dapr sidecar
→ pub/sub component
→ Inventory Service sidecar
→ Inventory Service
→ Dapr state API
→ statestore

Locally, Dapr can use the default Redis pub/sub component created by dapr init.

That is useful because the application code does not need to talk directly to Redis. The Order Service publishes to Dapr. Dapr handles the configured pub/sub backend. The Inventory Service receives the event through Dapr.

The event will be simple:

{
  "orderId": "order-1001",
  "itemId": "item1",
  "quantity": 10
}

When the Inventory Service receives that event, it will reduce the quantity for item1.

So if the inventory starts like this:

item1
→ quantity: 100

after the OrderPlaced event is processed, it should become:

item1
→ quantity: 90

The important part is not the inventory logic itself.

The important part is the communication pattern:

Service invocation
→ the caller asks another service for an answer

Pub/sub
→ the publisher announces an event
→ subscribers react independently

In this article, we are focusing on the second pattern.

How Dapr Pub/Sub Fits into This App

Dapr pub/sub gives the application a consistent way to publish and receive events.

The Order Service does not publish directly to Redis.

The Inventory Service does not subscribe directly to Redis.

Both services talk to their local Dapr sidecars.

The Order Service publishes an event through Dapr’s pub/sub API:

POST /v1.0/publish/<pubsub-name>/<topic>

In this walkthrough, that will become:

POST /v1.0/publish/pubsub/OrderPlaced

There are two important names in that URL.

pubsub
→ the Dapr pub/sub component name

OrderPlaced
→ the topic name

The component name tells Dapr which configured pub/sub backend to use.

The topic name tells Dapr what kind of event is being published.

On the subscriber side, the Inventory Service tells Dapr which topic it wants to receive.

It does that by exposing a Dapr subscription endpoint:

GET /dapr/subscribe

That endpoint returns a small piece of metadata that says:

I want to subscribe to the OrderPlaced topic
from the pubsub component.
When an event arrives, send it to this route.

Then Dapr delivers matching events to the Inventory Service over HTTP.

So the local flow is:

Order Service
→ calls local Dapr sidecar
→ publishes to topic: OrderPlaced
→ Dapr uses pubsub component
→ Dapr delivers event to Inventory Service
→ Inventory Service updates state through Dapr

This is still local development.

The default pubsub component created by dapr init can use Redis behind the scenes.

But the application code does not need to know Redis details.

The code only knows:

Publish this event to Dapr.

Subscribe to this topic through Dapr.

Update inventory state through Dapr.

That is the same Dapr idea from the previous article, now applied to events instead of direct service calls.

Update the Inventory Service to Subscribe to Events

We will start by updating the Inventory Service.

In the previous article, the Inventory Service exposed normal inventory endpoints and used Dapr state management to save and read quantities.

We will keep that part.

But now the Inventory Service will also subscribe to an OrderPlaced event.

When the event arrives, it will reduce the inventory quantity for the item in the order.

Open inventory_service.py and replace the code with this version:

from flask import Flask, request, jsonify
import os
import requests

app = Flask(__name__)

DAPR_HTTP_PORT = os.getenv("DAPR_HTTP_PORT", "3500")
DAPR_STATE_STORE_NAME = "statestore"
DAPR_PUBSUB_NAME = "pubsub"
ORDER_PLACED_TOPIC = "OrderPlaced"


def get_inventory_quantity(item_id):
    """Fetch the current inventory quantity for an item through Dapr state management."""
    url = f"http://localhost:{DAPR_HTTP_PORT}/v1.0/state/{DAPR_STATE_STORE_NAME}/{item_id}"

    response = requests.get(url)

    if response.status_code == 200 and response.content:
        return response.json()

    return 0


def set_inventory_quantity(item_id, quantity):
    """Save the inventory quantity for an item through Dapr state management."""
    url = f"http://localhost:{DAPR_HTTP_PORT}/v1.0/state/{DAPR_STATE_STORE_NAME}"

    payload = [
        {
            "key": item_id,
            "value": quantity
        }
    ]

    response = requests.post(url, json=payload)

    return response.status_code == 204


@app.route("/inventory/<item_id>", methods=["GET"])
def get_inventory(item_id):
    quantity = get_inventory_quantity(item_id)

    return jsonify({
        "itemId": item_id,
        "quantity": quantity
    })


@app.route("/inventory/<item_id>", methods=["POST"])
def update_inventory(item_id):
    data = request.json or {}
    quantity = data.get("quantity", 0)

    saved = set_inventory_quantity(item_id, quantity)

    if not saved:
        return jsonify({
            "error": "Failed to save inventory quantity"
        }), 500

    return jsonify({
        "itemId": item_id,
        "quantity": quantity
    })


@app.route("/dapr/subscribe", methods=["GET"])
def subscribe():
    return jsonify([
        {
            "pubsubname": DAPR_PUBSUB_NAME,
            "topic": ORDER_PLACED_TOPIC,
            "route": "orders"
        }
    ])


@app.route("/orders", methods=["POST"])
def handle_order_placed():
    event = request.json or {}

    data = event.get("data", event)

    item_id = data.get("itemId")
    quantity_ordered = data.get("quantity", 0)
    order_id = data.get("orderId", "unknown")

    if not item_id:
        return jsonify({
            "error": "OrderPlaced event is missing itemId"
        }), 400

    current_quantity = get_inventory_quantity(item_id)
    new_quantity = current_quantity - quantity_ordered

    saved = set_inventory_quantity(item_id, new_quantity)

    if not saved:
        return jsonify({
            "error": "Failed to update inventory"
        }), 500

    print(
        f"Processed OrderPlaced event. "
        f"orderId={order_id}, itemId={item_id}, "
        f"ordered={quantity_ordered}, remaining={new_quantity}"
    )

    return jsonify({
        "message": "Inventory updated from OrderPlaced event",
        "orderId": order_id,
        "itemId": item_id,
        "previousQuantity": current_quantity,
        "orderedQuantity": quantity_ordered,
        "remainingQuantity": new_quantity
    })


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5001)

Most of this service should look familiar.

These endpoints are still here:

GET /inventory/<item_id>
→ read the current quantity

POST /inventory/<item_id>
→ set the current quantity

Those let us seed and check inventory during the walkthrough.

The new part is this endpoint:

@app.route("/dapr/subscribe", methods=["GET"])
def subscribe():
    return jsonify([
        {
            "pubsubname": DAPR_PUBSUB_NAME,
            "topic": ORDER_PLACED_TOPIC,
            "route": "orders"
        }
    ])

This is how the Inventory Service tells Dapr:

Subscribe me to the OrderPlaced topic
from the pubsub component.

When an event arrives,
send it to my /orders route.

The other new part is the /orders endpoint:

@app.route("/orders", methods=["POST"])
def handle_order_placed():

That is the route Dapr will call when an OrderPlaced event arrives.

The handler reads the event data:

data = event.get("data", event)

That line keeps the code simple for local testing. If Dapr delivers the message as a CloudEvent, the actual event payload is inside data. If the payload arrives directly, the handler can still read it.

Then the Inventory Service reduces stock:

current_quantity = get_inventory_quantity(item_id)
new_quantity = current_quantity - quantity_ordered
set_inventory_quantity(item_id, new_quantity)

The important part is that the Inventory Service is now doing two Dapr-related things:

Subscribing to events
→ through Dapr pub/sub

Saving inventory
→ through Dapr state management

So this service is no longer only a state API example.

It is now an event subscriber.

Next, we will update the Order Service so it publishes an OrderPlaced event instead of directly calling the Inventory Service.

Update the Order Service to Publish Events

Now we will update the Order Service.

In the previous article, the Order Service called the Inventory Service directly through Dapr service invocation.

This time, it will publish an OrderPlaced event through Dapr pub/sub.

The Order Service will not wait for the Inventory Service to finish processing the event.

It will publish the event and return a response.

Open order_service.js and replace the code with this version:

const express = require("express");
const axios = require("axios");

const app = express();

app.use(express.json());

const DAPR_HTTP_PORT = process.env.DAPR_HTTP_PORT || "3501";
const DAPR_PUBSUB_NAME = "pubsub";
const ORDER_PLACED_TOPIC = "OrderPlaced";

app.post("/order", async (req, res) => {
  const { orderId, itemId, quantity } = req.body;

  if (!orderId || !itemId || !quantity) {
    return res.status(400).json({
      error: "orderId, itemId, and quantity are required"
    });
  }

  const event = {
    orderId,
    itemId,
    quantity
  };

  try {
    await axios.post(
      `http://localhost:${DAPR_HTTP_PORT}/v1.0/publish/${DAPR_PUBSUB_NAME}/${ORDER_PLACED_TOPIC}`,
      event,
      {
        headers: {
          "Content-Type": "application/json"
        }
      }
    );

    res.json({
      message: "OrderPlaced event published",
      event
    });
  } catch (error) {
    console.error("Failed to publish OrderPlaced event:", error.message);

    res.status(500).json({
      error: "Failed to publish OrderPlaced event",
      details: error.message
    });
  }
});

const PORT = process.env.PORT || 5002;

app.listen(PORT, () => {
  console.log(`Order Service running on port ${PORT}`);
});

The important part is this Dapr pub/sub URL:

`http://localhost:${DAPR_HTTP_PORT}/v1.0/publish/${DAPR_PUBSUB_NAME}/${ORDER_PLACED_TOPIC}`

That becomes:

http://localhost:3501/v1.0/publish/pubsub/OrderPlaced

This says:

Publish an event
→ using the pubsub component
→ to the OrderPlaced topic

The Order Service does not call the Inventory Service by app ID in this version.

It does not know which service will handle the event.

It only publishes the event:

{
  "orderId": "order-1001",
  "itemId": "item1",
  "quantity": 10
}

Then Dapr takes care of delivering the event to any subscribers.

In this walkthrough, the Inventory Service subscribes to OrderPlaced and updates inventory when the event arrives.

So the Order Service has changed from this:

Call Inventory Service directly.
Wait for the result.
Return the remaining quantity.

to this:

Publish OrderPlaced.
Return that the event was published.
Let subscribers react independently.

Next, we will run both services with Dapr and test the event flow.

Run the Inventory Service with Dapr

Now we will run the updated Inventory Service with Dapr.

This service needs to do two things:

Subscribe to OrderPlaced events
→ through Dapr pub/sub

Store inventory quantities
→ through Dapr state management

Open a terminal in the inventory-service folder.

If you are using the Python virtual environment from the previous article, activate it first:

.\venv\Scripts\Activate.ps1

Now start the Inventory Service with Dapr:

dapr run `
  --app-id inventory-service `
  --app-port 5001 `
  --dapr-http-port 3500 `
  python inventory_service.py

This command gives the Inventory Service its own Dapr sidecar again.

The important pieces are:

--app-id inventory-service
→ the Dapr name of this service

--app-port 5001
→ the port where the Flask app listens

--dapr-http-port 3500
→ the local HTTP port for this service’s Dapr sidecar

When the service starts, Dapr will call the Inventory Service’s subscription endpoint:

/dapr/subscribe

That is how Dapr discovers that this service wants to subscribe to the OrderPlaced topic.

So the Inventory Service is now ready for two kinds of work:

Normal inventory API calls
→ /inventory/<item_id>

Pub/sub event delivery from Dapr
→ /orders

Keep this terminal open.

Before we publish an order event, we also need some inventory to work with.

Open another terminal and stock item1 with 100 units:

curl.exe -X POST `
  -H "Content-Type: application/json" `
  -d '{\"quantity\": 100}' `
  http://localhost:3500/v1.0/invoke/inventory-service/method/inventory/item1

You should see:

{
  "itemId": "item1",
  "quantity": 100
}

Now the Inventory Service is running, subscribed to OrderPlaced, and has starting stock for item1.

Next, we will run the Order Service with Dapr so it can publish the event.

Run the Order Service with Dapr

Now we will run the updated Order Service with Dapr.

This service has one main job in this version:

Receive an order request
→ publish an OrderPlaced event through Dapr pub/sub

Open a new terminal in the order-service folder.

Start the Order Service with Dapr:

dapr run `
  --app-id order-service `
  --app-port 5002 `
  --dapr-http-port 3501 `
  node order_service.js

This gives the Order Service its own Dapr sidecar.

The important pieces are:

--app-id order-service
→ the Dapr name of this service

--app-port 5002
→ the port where the Node.js app listens

--dapr-http-port 3501
→ the local HTTP port for this service’s Dapr sidecar

The Order Service code publishes to Dapr on port 3501:

http://localhost:3501/v1.0/publish/pubsub/OrderPlaced

That request means:

Use the pubsub component.
Publish to the OrderPlaced topic.
Let Dapr deliver the event to subscribers.

So now both services are running:

Inventory Service
→ app port 5001
→ Dapr HTTP port 3500
→ subscribes to OrderPlaced
→ uses Dapr state management

Order Service
→ app port 5002
→ Dapr HTTP port 3501
→ publishes OrderPlaced

Keep both terminals open.

Next, we will publish an order and watch the Inventory Service react.

Publish an Order and Watch Inventory React

Now both services are running with Dapr.

The Inventory Service is subscribed to the OrderPlaced topic.

The Order Service is ready to publish an OrderPlaced event.

Open a third terminal and send an order request to the Order Service through Dapr:

curl.exe -X POST `
  -H "Content-Type: application/json" `
  -d '{\"orderId\": \"order-1001\", \"itemId\": \"item1\", \"quantity\": 10}' `
  http://localhost:3501/v1.0/invoke/order-service/method/order

This request goes to the Order Service through its Dapr sidecar.

The Order Service then publishes an OrderPlaced event through Dapr pub/sub.

You should see a response like this:

{
  "message": "OrderPlaced event published",
  "event": {
    "orderId": "order-1001",
    "itemId": "item1",
    "quantity": 10
  }
}

Now look at the terminal running the Inventory Service.

You should see a log message showing that the OrderPlaced event was processed:

Processed OrderPlaced event. orderId=order-1001, itemId=item1, ordered=10, remaining=90

That proves the Inventory Service received the event and reacted to it.

Now confirm the inventory value:

curl.exe `
  http://localhost:3500/v1.0/invoke/inventory-service/method/inventory/item1

You should see:

{
  "itemId": "item1",
  "quantity": 90
}

That proves the full event-driven flow is working.

The request path looks like this:

curl
→ Order Dapr sidecar on localhost:3501
→ Order Service on port 5002
→ Order Service publishes OrderPlaced
→ Order Dapr sidecar
→ pubsub component
→ Inventory Service receives the event
→ Inventory Service updates state through Dapr
→ statestore

The Order Service did not call the Inventory Service directly.

It published an event.

Dapr delivered the event to the subscriber.

The Inventory Service reacted and updated its state.

This is the key shift from the previous article.

What Changed from Service Invocation

In the previous article, the Order Service used Dapr service invocation.

That meant the Order Service asked the Inventory Service a direct question and waited for the answer:

Order Service
→ Dapr service invocation
→ Inventory Service
→ response comes back to Order Service

That pattern is useful when the caller needs an immediate decision.

For example:

Is there enough stock?
Can this order be accepted?
What quantity remains?

In this article, we changed the shape.

The Order Service no longer asks the Inventory Service for an immediate answer.

Instead, it publishes an event:

OrderPlaced

The Inventory Service subscribes to that event and reacts when it arrives.

So the flow becomes:

Order Service
→ Dapr pub/sub
→ OrderPlaced event
→ Inventory Service reacts later

That is the important design difference.

Service invocation
→ one service asks another service for something now

Pub/sub
→ one service announces that something happened
→ other services react independently

This does not mean pub/sub is better than service invocation.

It means they solve different communication problems.

Use service invocation when the caller needs a response before it can continue.

Use pub/sub when the publisher does not need to know who reacts, how many services react, or exactly when they finish.

That is the main lesson from this version of the application.

We used the same domain, the same two services, and the same Dapr sidecar model.

But by changing the communication pattern, we changed the relationship between the services.

Stop the Dapr Services

At this point, the event-driven flow has done its job.

You published an OrderPlaced event from the Order Service, the Inventory Service received it, and the inventory quantity was updated through Dapr state management.

Now you can stop the running services.

Go back to the terminal running the Order Service and press:

CTRL + C

Then go back to the terminal running the Inventory Service and press:

CTRL + C

That stops each application process and its Dapr sidecar.

You can confirm that no Dapr apps are still running with:

dapr list

You should not see order-service or inventory-service listed.

The local Dapr support containers created by dapr init, such as Redis, Zipkin, placement, or scheduler, may still be running. That is fine if you plan to keep working with Dapr.

If you want to remove the local Dapr runtime containers as well, run:

dapr uninstall

Be careful with this command. It removes the local Dapr runtime setup from your machine. If you want to use Dapr locally again later, you will need to run:

dapr init

again.

The simple cleanup model is:

CTRL + C
→ stops each app and its Dapr sidecar

dapr list
→ checks whether any Dapr apps are still running

dapr uninstall
→ removes the local Dapr runtime containers if you no longer need them

Next, we will step back and capture the Dapr Pub/Sub mental model from what we just built.

The Dapr Pub/Sub Mental Model

You have now built the same small domain in two different ways.

In the previous article, the communication was direct:

Order Service
→ asks Inventory Service a question
→ waits for the answer

In this article, the communication became event-driven:

Order Service
→ publishes OrderPlaced
→ Inventory Service reacts

The Dapr pieces were:

Order Service
→ publishes an event to its local Dapr sidecar

Dapr pub/sub API
→ receives the publish request

pubsub component
→ connects Dapr to the configured broker

OrderPlaced topic
→ identifies the type of event

Inventory Service
→ subscribes to the topic

Inventory Service route
→ receives the event from Dapr

Dapr state API
→ saves the updated inventory quantity

statestore component
→ connects Dapr to the configured state backend

The important part is that both services still talked to Dapr locally.

The Order Service did not talk directly to Redis or to the Inventory Service.

The Inventory Service did not subscribe directly to Redis either. It exposed a subscription route, received events from Dapr, and used Dapr state management to update inventory.

So the model is:

Publisher service
→ local Dapr sidecar
→ pub/sub building block
→ pubsub component
→ topic
→ subscriber service

And for the inventory update:

Subscriber service
→ local Dapr sidecar
→ state management building block
→ statestore component

That is the practical Dapr Pub/Sub model:

Your code publishes and subscribes through Dapr APIs.

Dapr components connect those APIs to the real backend.

Locally, that backend can be Redis.

Later, the same idea can be backed by another message broker, such as Azure Service Bus, by changing the Dapr component configuration rather than rewriting the application around a different broker SDK.

Where This Leads

You have now seen two important Dapr communication patterns.

The first was direct service invocation:

Order Service
→ asks Inventory Service for an immediate answer

The second was publish and subscribe:

Order Service
→ publishes OrderPlaced
→ Inventory Service reacts independently

Both patterns are useful.

They simply solve different problems.

Use service invocation when the caller needs a response before it can continue.

Use pub/sub when a service only needs to announce that something happened and let other services react.

In this article, everything still ran locally. Dapr used the local components created by dapr init, such as the default Redis-backed pub/sub and state store.

That is a good way to learn the model.

But the same idea becomes more powerful when the backing infrastructure changes.

The application code talks to Dapr:

Publish this event.
Subscribe to this topic.
Save this state.

Dapr components decide what infrastructure sits behind those APIs.

Locally, that might be Redis.

In Azure, it could be Azure Service Bus for pub/sub and another managed service for state.

That is where this series can go next:

Local Dapr app
→ Redis-backed pub/sub and state

Azure-hosted Dapr app
→ Azure-backed pub/sub and state

The important point is the same one we have been building toward:

Your application code stays focused on the workflow.

Dapr gives it consistent APIs for distributed-system capabilities.

The backend infrastructure can change behind those APIs.

In the next article, we can take this Dapr application into Azure and look at how the same pub/sub model maps to managed cloud services.