Dapr for Beginners: Building Your First Dapr Microservices App

Dapr for Beginners: Building Your First Dapr Microservices App

Introduction: From Dapr Concepts to a Working App

In the previous article, we looked at why microservices can become painful.

Splitting one application into smaller services can give you independent deployments, clearer ownership, and more flexibility. But it also creates distributed-system problems. Services need to call each other, store state, handle retries, emit telemetry, read secrets, and connect to infrastructure.

That is where Dapr comes in.

Dapr gives your services a consistent runtime API for common distributed-system capabilities. Your application talks to its local Dapr sidecar, and Dapr talks to the infrastructure behind it.

In this article, we will move from the idea to a working example.

We will build a small two-service application:

Order Service
→ calls Inventory Service through Dapr service invocation

Inventory Service
→ stores inventory quantities through Dapr state management

This example uses two Dapr building blocks:

Service invocation
→ one service calls another by app ID

State management
→ one service stores and retrieves state through Dapr

The services will be intentionally simple.

The point is not to build a full e-commerce platform. The point is to prove the Dapr model with real code:

Your service
→ talks to its local Dapr sidecar
→ uses a Dapr building block
→ reaches another service or backing store

By the end, you will have two services running locally with Dapr sidecars. The Order Service will place an order by calling the Inventory Service. The Inventory Service will store and update inventory through Dapr’s state API.

That is the first practical step toward building distributed applications the Dapr way.

What We Are Building

We will build a small microservices application with two services:

Order Service
→ receives an order request
→ calls the Inventory Service through Dapr

Inventory Service
→ stores product quantities
→ uses Dapr state management

The application flow is simple.

First, we add stock for an item:

item1
→ quantity: 100

Then we place an order for part of that stock:

Order 10 units of item1

The Order Service will call the Inventory Service to check the available quantity. If enough stock exists, it will ask the Inventory Service to reduce the quantity.

After the order is placed, the inventory should drop:

item1
→ quantity: 90

The architecture looks like this:

Dapr Microservices Architecture

There are two important Dapr ideas in this diagram.

The first is service invocation.

The Order Service does not call the Inventory Service by hard-coded IP address or direct host name. It calls its local Dapr sidecar and asks Dapr to invoke the service with the app ID:

inventory-service

The second is state management.

The Inventory Service does not write directly to Redis or another database client. It calls its local Dapr sidecar and asks Dapr to save or retrieve inventory state.

So the two service responsibilities are clear:

Order Service
→ business action: place an order
→ Dapr building block: service invocation

Inventory Service
→ business action: manage stock
→ Dapr building block: state management

This is the point of the example.

The application code stays small and focused. Dapr handles more of the service-to-service and state-management plumbing around it.

How Dapr Fits into This App

The architecture has two services, but Dapr is the layer that changes how those services communicate and store data.

The Order Service still needs to ask the Inventory Service whether stock is available. The Inventory Service still needs somewhere to store inventory quantities.

Dapr does not make those needs disappear.

What Dapr changes is where the infrastructure knowledge lives.

Without Dapr, the Order Service might need to know the direct URL of the Inventory Service:

http://inventory-service-host:5001/inventory/item1

It may also need custom retry logic, timeout handling, service discovery, and environment-specific configuration.

With Dapr, the Order Service talks to its own local Dapr sidecar instead:

http://localhost:<dapr-http-port>/v1.0/invoke/inventory-service/method/inventory/item1

That may look like just another HTTP call, and in one sense it is.

But the important difference is the boundary.

The Order Service is no longer calling a specific host or IP address for the Inventory Service. It is asking Dapr to invoke a service by app ID:

inventory-service

That is what service invocation means here:

One service asks Dapr to call another service by name.

The same idea applies to state.

The Inventory Service still saves and reads inventory quantities, but it does not talk directly to Redis or another database client. It talks to its local Dapr sidecar through the Dapr state API.

So the pattern is:

Order Service
→ Dapr service invocation API
→ Inventory Service

Inventory Service
→ Dapr state management API
→ configured state store

Dapr also does not mean there is no operational cost.

You still need the Dapr runtime. You still need sidecars. You still need Dapr components that tell Dapr what state store, pub/sub broker, secret store, or backend to use.

But that cost is usually cheaper than scattering infrastructure glue across every service.

The trade-off is:

Without Dapr
→ each service owns more infrastructure-specific code

With Dapr
→ services use Dapr APIs
→ Dapr components connect those APIs to infrastructure

That is the practical value we are trying to prove in this article.

The code will still make HTTP calls.

But those calls go to Dapr’s local API, not directly to every service or backend dependency.

Set Up the Local Dapr Environment

To run the example locally, we need four things:

Docker
→ runs the local containers Dapr uses behind the scenes

Dapr CLI
→ starts services with Dapr sidecars

Python
→ runs the Inventory Service

Node.js
→ runs the Order Service

The Dapr CLI is the command we will use throughout the walkthrough.

First, check whether it is already installed:

dapr --version

If Dapr is not installed, install the Dapr CLI.

On Windows PowerShell:

powershell -Command "iwr -useb https://raw.githubusercontent.com/dapr/cli/master/install/install.ps1 | iex"

On macOS with Homebrew:

brew install dapr/tap/dapr-cli

On Linux:

wget -q https://raw.githubusercontent.com/dapr/cli/master/install/install.sh -O - | /bin/bash

After installation, check again:

dapr --version

At this point, you have the Dapr CLI installed.

But the CLI by itself is only the command-line tool. We still need to initialise the local Dapr runtime.

Run:

dapr init

This prepares your machine for local Dapr development.

It creates the local runtime setup that Dapr uses when you run services in self-hosted mode. For this walkthrough, the important part is that Dapr gives us a local state store we can use without wiring the application directly to a database.

You can check what Dapr started with:

docker ps

You should see Dapr-related containers running.

The exact list may vary by Dapr version, but you will commonly see containers for local state/pub-sub support and other Dapr runtime services.

The important point is this:

Dapr CLI installed
→ you can run dapr commands

dapr init completed
→ local Dapr runtime is ready

docker ps shows Dapr containers
→ local support services are running

Once this is ready, we can build the first service: the Inventory Service. It will use Dapr state management to store product quantities without talking directly to the backing state store.

Build the Inventory Service

The Inventory Service is a small Python Flask API.

It will expose endpoints for reading and updating inventory quantities, but it will not talk directly to Redis or a database. Instead, it will call the Dapr state API.

Create the service folder:

mkdir inventory-service
cd inventory-service

Create and activate a Python virtual environment:

python -m venv venv
.\venv\Scripts\Activate.ps1

On macOS or Linux, use:

python3 -m venv venv
source venv/bin/activate

A virtual environment keeps the Python packages for this service separate from the rest of your machine.

Now install the packages:

pip install flask==3.0.3 requests

Flask gives us a small HTTP API. The requests package lets the Inventory Service call its local Dapr sidecar.

Now create a file called inventory_service.py:

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"


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
    })


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

There are two normal Flask endpoints:

GET /inventory/<item_id>
→ returns the current quantity for an item

POST /inventory/<item_id>
→ updates the quantity for an item

The important part is how the service stores and reads data.

The service does not import a Redis client.

It does not know the Redis hostname.

It does not open a database connection.

Instead, it calls the Dapr state API through its local sidecar:

url = f"http://localhost:{DAPR_HTTP_PORT}/v1.0/state/{DAPR_STATE_STORE_NAME}/{item_id}"

and:

url = f"http://localhost:{DAPR_HTTP_PORT}/v1.0/state/{DAPR_STATE_STORE_NAME}"

The state store name is:

DAPR_STATE_STORE_NAME = "statestore"

That name matters because Dapr uses a component called statestore in the local environment created by dapr init.

So the Inventory Service only knows this:

I need to save and read state through Dapr.

Dapr handles where that state is stored.

Next, we will run this service with Dapr so it gets its own sidecar.

Run the Inventory Service with Dapr

Now we will run the Inventory Service with its own Dapr sidecar.

Make sure you are still inside the inventory-service folder and that your virtual environment is active:

(venv) PS C:\path\to\inventory-service>

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 starts two things together:

Inventory Service
→ the Python Flask app

Dapr sidecar
→ the local Dapr runtime beside the app

There are three important flags in the command.

--app-id inventory-service

This gives the service a Dapr name.

Other services can use this app ID when they want Dapr to invoke the Inventory Service.

--app-port 5001

This tells Dapr where the actual Flask app is listening.

In our code, the Flask app starts on port 5001:

app.run(host="0.0.0.0", port=5001)
--dapr-http-port 3500

This tells Dapr which local HTTP port to use for this service’s sidecar.

Our Python code calls Dapr on that port:

DAPR_HTTP_PORT = os.getenv("DAPR_HTTP_PORT", "3500")

So the local relationship is:

Flask app
→ listens on port 5001

Dapr sidecar
→ listens on port 3500

Inventory Service code
→ calls Dapr at localhost:3500

Dapr
→ forwards incoming service invocation requests to the Flask app at port 5001

Keep this terminal open.

The Inventory Service is now running with Dapr. In the next section, we will test state management by adding inventory and reading it back through Dapr.

Test Dapr State Management

The Inventory Service is now running with its Dapr sidecar.

Now let’s test whether it can save and read inventory state.

Open a new terminal window and keep the Inventory Service terminal running.

We will call the service through Dapr, not directly through the Flask app.

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

This request goes to the Dapr sidecar on port 3500.

Dapr then invokes the Inventory Service, which is listening on port 5001.

The request path is:

curl
→ Dapr sidecar on localhost:3500
→ Inventory Service app ID: inventory-service
→ /inventory/item1
→ Inventory Service on port 5001
→ Dapr state API
→ statestore

You should see a response like this:

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

Now read the inventory value back:

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

You should see:

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

That proves the first Dapr building block is working.

The Inventory Service saved and retrieved state through Dapr.

The important detail is that the Inventory Service did not talk directly to Redis. It called the Dapr state API using the state store name:

statestore

Dapr handled the configured backend behind that name.

So far, we have proved this part of the application:

Inventory Service
→ local Dapr sidecar
→ Dapr state API
→ statestore

Next, we will build the Order Service and use Dapr service invocation to call the Inventory Service.

Build the Order Service

Now that the Inventory Service is running and storing state through Dapr, we can build the second service.

The Order Service is a small Node.js API.

Its job is to:

Receive an order request
Check the available inventory
Reduce the inventory if enough stock exists
Return a success or failure response

The Order Service will not call the Inventory Service directly by host name or IP address.

It will call its own local Dapr sidecar and ask Dapr to invoke the Inventory Service by app ID:

inventory-service

Create a new folder for the Order Service.

Open a new terminal window and run:

mkdir order-service
cd order-service

Initialize a Node.js project:

npm init -y

Install the packages:

npm install express axios

Express gives us a small HTTP API.

Axios lets the Order Service call its local Dapr sidecar.

Now create a file called order_service.js:

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 INVENTORY_SERVICE = "inventory-service";

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

  try {
    const inventoryResponse = await axios.get(
      `http://localhost:${DAPR_HTTP_PORT}/v1.0/invoke/${INVENTORY_SERVICE}/method/inventory/${itemId}`
    );

    const available = inventoryResponse.data.quantity || 0;

    if (available < quantity) {
      return res.status(400).json({
        message: `Not enough inventory for item '${itemId}'. Only ${available} units available.`
      });
    }

    const remaining = available - quantity;

    await axios.post(
      `http://localhost:${DAPR_HTTP_PORT}/v1.0/invoke/${INVENTORY_SERVICE}/method/inventory/${itemId}`,
      {
        quantity: remaining
      }
    );

    res.json({
      message: `Order placed for ${quantity} units of item '${itemId}'.`,
      remaining
    });
  } catch (error) {
    console.error("Error processing order:", error.message);

    res.status(500).json({
      message: "Internal error processing order.",
      error: error.message
    });
  }
});

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

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

The important part is the Dapr service invocation URL:

/v1.0/invoke/<app-id>/method/<endpoint>

In our code, that becomes:

`http://localhost:${DAPR_HTTP_PORT}/v1.0/invoke/${INVENTORY_SERVICE}/method/inventory/${itemId}`

The Order Service is not calling the Inventory Service directly.

It is calling Dapr on its own local sidecar port:

localhost:3501

Then it asks Dapr to invoke the app ID:

inventory-service

and call this endpoint on that service:

/inventory/<item_id>

So the request path is:

Order Service
→ local Dapr sidecar on port 3501
→ inventory-service app ID
→ Inventory Service endpoint

The Order Service uses that path twice:

First:
GET inventory quantity

Then:
POST updated inventory quantity

At this point, the Order Service code is ready.

Next, we will run it with Dapr so it gets its own sidecar and can invoke the Inventory Service.

Place an Order End to End

Now both services are running with their own Dapr sidecars.

We already stocked item1 with 100 units when we tested the Inventory Service. Now we will place an order for 10 units.

Open a third terminal window and run:

curl.exe -X POST `
  -H "Content-Type: application/json" `
  -d '{\"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 on port 3501.

The request path is:

curl
→ Order Dapr sidecar on localhost:3501
→ Order Service on port 5002
→ Order Dapr sidecar
→ Inventory Service by app ID: inventory-service
→ Inventory Service checks and updates state through Dapr

If enough stock exists, you should see a response like this:

{
  "message": "Order placed for 10 units of item 'item1'.",
  "remaining": 90
}

Now confirm that the inventory was updated.

Call the Inventory Service again through Dapr:

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

You should see:

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

That proves the full flow is working.

The Order Service received the order request. It used Dapr service invocation to call the Inventory Service. The Inventory Service used Dapr state management to read and update the inventory quantity.

So the end-to-end path is:

Order request
→ Order Service
→ Dapr service invocation
→ Inventory Service
→ Dapr state management
→ statestore

This is the first complete Dapr application flow in the series.

Stop the Dapr Services

At this point, the application has done its job.

You have tested the Inventory Service, placed an order through the Order Service, and confirmed that the inventory quantity was updated through Dapr state management.

Now you can stop the two 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 the application processes and their Dapr sidecars.

If you want to confirm that no Dapr applications are still running, use:

dapr list

You should not see inventory-service or order-service listed as running apps.

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

If you want to stop and 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 again later, you will need to run:

dapr init

again.

For this walkthrough, 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 hands-on Dapr mental model from what we just built.

The Dapr Hands-On Mental Model

You have now built and tested a small Dapr application.

The application had two services:

Inventory Service
→ Python Flask API
→ stores inventory quantities through Dapr state management

Order Service
→ Node.js API
→ calls Inventory Service through Dapr service invocation

Each service ran as a normal application, but each one also had its own Dapr sidecar.

That is the key pattern:

Your application
→ talks to its local Dapr sidecar

Dapr sidecar
→ handles the distributed-system capability

In this walkthrough, there were a few important Dapr concepts.

The app ID was the Dapr name for each service:

inventory-service
order-service

The app port was where the actual application listened:

Inventory Service
→ app port 5001

Order Service
→ app port 5002

The Dapr HTTP port was where the local sidecar listened:

Inventory Dapr sidecar
→ port 3500

Order Dapr sidecar
→ port 3501

The service invocation URL let one service call another by app ID:

/v1.0/invoke/<app-id>/method/<endpoint>

The state store name told Dapr which state component to use:

statestore

So the complete mental model is:

Application code
→ calls local Dapr API
→ uses a building block
→ Dapr connects to the right service or backend

The Inventory Service did not need to know Redis details.

The Order Service did not need to know the Inventory Service’s direct network location.

Both services used Dapr APIs, and Dapr handled the plumbing around them.

That is the practical value of what you built.

Where This Leads

In this article, we used Dapr in a direct request/response flow.

The Order Service received a request, called the Inventory Service through Dapr service invocation, waited for the response, and then returned the result.

That is the right shape when one service needs an immediate answer:

Can this order be placed?

Is there enough stock?

What quantity remains?

In those cases, the caller needs the answer before it can continue.

But not every workflow needs to work that way.

Sometimes a service does not need to wait for another service to finish its work.

It only needs to say:

Something happened.

For example, after an order is placed, other parts of the system may need to react:

Send a confirmation email.
Update analytics.
Start fulfilment.
Notify another system.

The Order Service may not need to call each one directly and wait for them all to finish.

That raises the next design question:

What if a service could announce that something happened,
and other services could react independently?

That is where the next Dapr building block comes in.

In the next article, we will move from direct service invocation to Dapr’s publish/subscribe model.