A shopping-cart application storing data through a Dapr sidecar in a replaceable state store

Persisting a shopping cart often involves database-related tasks: choosing a client library, setting up a connection pool, managing credentials, serializing the model, retrying transient failures, and deciding on concurrent update policies. When using a different language or database, much of this integration effort varies.

Dapr state management moves the infrastructure-facing layer behind a local HTTP or gRPC API. The application works with keys, values, ETags, and consistency hints. A Dapr Component tells the sidecar which store to use for those operations, such as Redis, Azure Cosmos DB, PostgreSQL, or another supported store.

That separation is helpful, but it does not guarantee perfect portability. Different stores vary in durability, consistency, transactions, TTL, query support, throughput, latency, and cost. Dapr standardizes the application-facing API; it doesn’t ensure that all databases function exactly the same.

Outcome: deploy a small cart API and Redis to Kubernetes, save and read carts using Dapr, reproduce an ETag conflict, use the bulk API, and understand what changes when the component is replaced with Azure Cosmos DB on AKS.

This is Part 5 of the Dapr on Kubernetes and AKS series. It follows Service Invocation with Dapr on AKS, but the sample can also run independently on any Kubernetes cluster with Dapr installed.

Follow one state operation

A cart application using its local Dapr sidecar to reach either Redis or Azure Cosmos DB

The request path is deliberately short:

  1. A client invokes the cart-api application through Dapr.
  2. The application handles the cart operation on port 8080.
  3. It calls http://127.0.0.1:3500/v1.0/state/cartstore.
  4. The sidecar loads the cartstore component scoped to this app ID.
  5. The component translates the request for Redis or Azure Cosmos DB.

The application knows the component name cartstore, but it contains no Redis hostname, Cosmos DB endpoint, database SDK, or cloud credential. Reusing the component name lets the infrastructure change without changing the state API calls.

What the state API guarantees and what it does not

Dapr exposes CRUD, bulk, transaction, and optional query operations. The exact capabilities depend on the component. Before treating a feature as portable, check the state-store capability table.

Two defaults deserve particular attention:

  • Without an ETag, writes use last-write-wins behavior.
  • Without an explicit consistency hint, applications should assume eventual consistency.

This sample requests first-write concurrency and strong consistency. A component can only honor behavior it supports. Application design still needs to account for the selected store and its documented semantics.

Deploy the Redis-backed cart

The learning manifest includes a namespace, a single Redis instance, the cartstore component, and a dependency-free Python cart service. Redis has append-only persistence enabled, but it still runs as a single pod without a persistent volume or authentication. That is suitable for the exercise, not for production.

Download the complete state-management manifest, then apply it:

kubectl apply \
  --filename state-management.yaml

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

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

Confirm that the component is present and the cart pod contains both the application and daprd:

kubectl get components.dapr.io \
  --namespace dapr-demo

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

The component is intentionally scoped:

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

scopes prevents unrelated Dapr applications in the namespace from loading this component. It is useful containment, but it is not a replacement for database authorization, network policy, or separate identities.

Check component initialization before debugging the application:

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

Look for a successful cartstore component initialization. A wrong host, unavailable Redis pod, or malformed component appears in the sidecar log rather than the application log.

Save and retrieve a cart

Forward local port 3500 to the cart sidecar:

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

Keep that terminal open. From a second terminal, add an item by invoking the application through Dapr:

curl --fail --show-error \
  --request POST \
  --header 'Content-Type: application/json' \
  --data '{"sku":"coffee-001","quantity":2}' \
  http://localhost:3500/v1.0/invoke/cart-api/method/carts/alice/items

The response contains the cart returned by the application:

{"cartId": "alice", "items": [{"sku": "coffee-001", "quantity": 2}]}

Read it through the application:

curl --fail --show-error \
  http://localhost:3500/v1.0/invoke/cart-api/method/carts/alice

The application persists the document by posting this shape to its local sidecar:

[
  {
    "key": "cart:alice",
    "value": {
      "cartId": "alice",
      "items": [
        {"sku": "coffee-001", "quantity": 2}
      ]
    },
    "etag": "<etag-from-the-read>",
    "options": {
      "concurrency": "first-write",
      "consistency": "strong"
    }
  }
]

The application uses the raw HTTP API, so no Dapr SDK or database SDK is required. A Dapr SDK can reduce boilerplate in a real service, but it uses the same state contract.

Protect updates with ETags

Two cart replicas can read the same value, make different changes, and write at nearly the same time. With last-write-wins, the later write silently replaces the earlier one. Optimistic concurrency control makes the conflict visible.

Read the state directly through the sidecar and capture its ETag:

ETAG="$(
  curl --silent \
    --dump-header - \
    --output /dev/null \
    'http://localhost:3500/v1.0/state/cartstore/cart:alice?consistency=strong' \
  | awk 'tolower($1) == "etag:" {gsub("\\r", "", $2); print $2}'
)"

printf 'Current ETag: %s\n' "$ETAG"

Use the ETag for an update:

curl --include \
  --request POST \
  --header 'Content-Type: application/json' \
  --data "[{
    \"key\": \"cart:alice\",
    \"value\": {
      \"cartId\": \"alice\",
      \"items\": [{\"sku\": \"tea-002\", \"quantity\": 1}]
    },
    \"etag\": \"$ETAG\",
    \"options\": {
      \"concurrency\": \"first-write\",
      \"consistency\": \"strong\"
    }
  }]" \
  http://localhost:3500/v1.0/state/cartstore

The write succeeds and produces a new version. Run the same command again without refreshing ETAG; the stale version is rejected. That rejection is the protection. The application should re-read, decide whether the changes can be merged, and retry with a bounded policy. Blindly replaying the same update can turn conflict detection back into accidental last-write behavior.

ETags protect one key. They do not turn an arbitrary read-modify-write workflow across several services into a distributed transaction.

Read several carts with the bulk API

Create a second cart:

curl --fail --show-error \
  --request POST \
  --header 'Content-Type: application/json' \
  --data '{"sku":"mug-003","quantity":1}' \
  http://localhost:3500/v1.0/invoke/cart-api/method/carts/bob/items

Fetch both keys in one request to the local sidecar:

curl --fail --show-error \
  --request POST \
  --header 'Content-Type: application/json' \
  --data '{"keys":["cart:alice","cart:bob"],"parallelism":2}' \
  http://localhost:3500/v1.0/state/cartstore/bulk

A bulk endpoint reduces application-to-sidecar round trips. It does not necessarily mean the backend performs one atomic operation. Dapr also has a transaction endpoint for components that advertise transactional support; transaction scope and isolation remain store-specific.

Treat the Query API as an optional capability

Dapr’s state Query API can filter, sort, and page over JSON values, but the API is currently alpha and is not implemented by every state store. It is useful when the query shape is modest and portability matters more than a backend’s native query language.

The endpoint makes its maturity visible:

POST /v1.0-alpha1/state/<store-name>/query

Do not make the sample’s Redis-to-Cosmos DB swap depend on a query until both component versions have been verified against the exact filter and index design. For rich analytics, joins, or backend-specific performance tuning, use the database’s native API behind a repository or dedicated query service. The Dapr Query API documentation should be the compatibility check, not an assumption based on another component.

Replace Redis with Azure Cosmos DB on AKS

The application contract stays cartstore; only the component changes. A minimal Cosmos DB component looks like this:

apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: cartstore
  namespace: dapr-demo
spec:
  type: state.azure.cosmosdb
  version: v1
  metadata:
    - name: url
      value: https://<cosmos-account>.documents.azure.com:443/
    - name: database
      value: carts
    - name: collection
      value: state
    - name: azureClientId
      value: <managed-identity-client-id>
scopes:
  - cart-api

Download the Cosmos DB component template. Replace the placeholders before applying it.

For AKS, prefer Microsoft Entra Workload ID over a Cosmos DB account key. The important operational steps are:

  1. Enable OIDC issuer and Workload ID on the AKS cluster.
  2. Create a user-assigned managed identity and federated credential for a Kubernetes service account.
  3. Grant the identity an appropriate Cosmos DB data-plane role at the narrowest practical scope.
  4. Add azure.workload.identity/use: "true" to the cart pod template and set its serviceAccountName.
  5. Add dapr.io/sentry-request-jwt-audiences: "api://AzureADTokenExchange" when required by the Dapr Workload ID configuration you operate.
  6. Remove the Redis component before applying another cartstore definition.

The component deliberately contains no masterKey. Dapr’s current Azure authentication guidance recommends Workload ID on AKS, and the Cosmos DB component reference documents the data-store fields.

Reusing an application service account for every component gives that pod the union of all permissions. Prefer workload identities aligned with application and data boundaries, and keep the component scoped to the app IDs that actually need it.

Portability has a boundary

Switching the component is easy YAML. Proving the new backend is suitable is the real migration. Compare at least:

ConcernRedis learning deploymentAzure Cosmos DB production candidate
DurabilityOne ephemeral pod in this sampleManaged, replicated service with configurable policies
AuthenticationNone in this sampleMicrosoft Entra Workload ID and data-plane RBAC
ConcurrencyETags supportedETags supported
TransactionsSupported with Redis-specific constraintsSupported within documented Cosmos DB boundaries
ConsistencyRequested through Dapr; backend semantics still matterSelect and validate an account consistency model
QueryOptional capability; verify component supportOptional Dapr API plus native Cosmos DB queries
Cost modelPod resources and operationsProvisioned/serverless throughput, storage, and regions

Test failure behavior too. A component can preserve API syntax while changing latency, throttling signals, retry requirements, maximum item size, and transaction boundaries. Portability is strongest for simple key-value access and weakest where the application relies on a backend-specific capability.

Production rules for stateful Dapr applications

Before moving this pattern beyond a tutorial:

  • Pin application and dependency images to reviewed digests.
  • Use a managed, highly available store rather than the sample Redis pod.
  • Scope components and identities to explicit app IDs.
  • Keep credentials out of component YAML; use Workload ID where supported.
  • Use ETags for read-modify-write flows and handle conflicts deliberately.
  • Choose consistency from a business invariant, not from a generic preference.
  • Confirm transactions, TTL, query, item-size, and encryption requirements against the selected component.
  • Monitor sidecar errors, backend latency, throttling, conflicts, and retry volume.
  • Back up and restore the data store using its native controls; Dapr is not a backup system.
  • Load-test the application, sidecar, and backend together.

Clean up

Stop port forwarding with Ctrl+C, then remove the learning environment:

kubectl delete namespace dapr-demo

If you created Cosmos DB resources, identities, or role assignments, remove them separately when they are no longer needed. Deleting the Kubernetes namespace does not delete external Azure resources.

The state-management pattern

The valuable boundary is not “any database can replace any other database.” It is narrower and more useful: the application uses a consistent local API for common state operations, while the platform owns component configuration, connectivity, identity, and store selection.

The practical rules are:

  1. Keep the component name stable and the backend configuration outside application code.
  2. Check the capability matrix before relying on transactions, TTL, queries, actors, or workflows.
  3. Use ETags when concurrent updates must not be silently overwritten.
  4. Treat consistency as an explicit application requirement.
  5. Use bulk operations to reduce calls, not as an assumption of atomicity.
  6. Validate operational semantics whenever the state store changes.

Next in the series, we will publish an order event through Dapr pub/sub and consume it with a notification service backed by Azure Service Bus.

Return to the Dapr on Kubernetes and AKS series index.

Sources