Part 2 ended on a trade: Dapr removes the part of your code that names a backing service, and a second process in every replica is the price. The interesting question is what the first half costs on the C# side, because "no vendor names" is not the same as "no vendor constraints". Two projects are enough to find out: one that calls through Dapr, and one that only gets called.
Two services, two app IDs
Start with the two project files, because the difference between them is the argument.
<!-- OrderApi/OrderApi.csproj -->
<Project Sdk="Microsoft.NET.Sdk.Web">
<ItemGroup>
<PackageReference Include="Dapr.AspNetCore" />
<PackageReference Include="Dapr.Client" />
</ItemGroup>
</Project>
<!-- InventoryApi/InventoryApi.csproj -->
<Project Sdk="Microsoft.NET.Sdk.Web">
<!-- No Dapr package reference, on purpose. inventory-api is reached BY app ID, it does not
reach anything by app ID, so it never touches the SDK. Being callable through Dapr costs
a target service exactly zero lines of code. -->
</Project>
orders-api owns order state and calls out. inventory-api answers stock checks and calls nothing, so it carries no Dapr package, no Dapr type, and no Dapr configuration. Being addressable as inventory-api is a property of how the process was started, not of how it was written: the app ID is a runtime label the sidecar owns.
That is also why this is two projects rather than one binary started twice under two app IDs: running one assembly twice would hide the asymmetry that matters, which is that only one of the two services is coupled to Dapr at all. Versions are pinned centrally at Dapr.Client and Dapr.AspNetCore 1.18.5, the same pair Part 2 used; AddDaprClient lives in Dapr.AspNetCore and the DaprClient it registers in Dapr.Client, which is why orders-api, which only wants the client, still takes both packages. Both projects, the run file and the components folder are in DaprWebApiDemo in azure-functions-samples, and its README carries the local run loop.
The wiring on the calling side is two registrations in Program.cs:
var builder = WebApplication.CreateBuilder(args);
// Source-generated JSON, front of the chain. The reflection resolver stays behind it because
// the framework serialises types this app never declares (ProblemDetails, most obviously).
builder.Services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.TypeInfoResolverChain.Insert(0, OrderApiJsonContext.Default));
// The same generated metadata, handed to Dapr. AddDaprClient reads DAPR_HTTP_PORT /
// DAPR_GRPC_PORT from the environment, so 3500 and 50001 are never written down here.
builder.Services.AddDaprClient(dapr =>
dapr.UseJsonSerializationOptions(OrderApiJsonContext.Default.Options));
AddDaprClient is the whole registration, and Part 2 covered the two environment variables behind it. The remaining lines of Program.cs build the HttpClient that reaches inventory-api.
The callee's Program.cs is the same file with the Dapr line removed:
var builder = WebApplication.CreateBuilder(args);
// The only wiring this service needs. No AddDaprClient, because it never calls out through a
// sidecar; it only gets called through one.
builder.Services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.TypeInfoResolverChain.Insert(0, InventoryApiJsonContext.Default));
var app = builder.Build();
app.MapStockEndpoints();
await app.RunAsync();
One JSON context, wired two different ways
Both services declare their wire types up front, which on orders-api looks like this:
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web)]
[JsonSerializable(typeof(CreateOrderRequest))]
[JsonSerializable(typeof(Order))]
[JsonSerializable(typeof(OrderLine))]
[JsonSerializable(typeof(OutOfStockResponse))]
[JsonSerializable(typeof(ErrorResponse))]
[JsonSerializable(typeof(ConfirmConflictResponse))]
[JsonSerializable(typeof(CancelOrderRequest))]
[JsonSerializable(typeof(CancelOrderResponse))]
[JsonSerializable(typeof(OrderCancellation))]
[JsonSerializable(typeof(CancelConflictResponse))]
[JsonSerializable(typeof(StockCheckRequest))]
[JsonSerializable(typeof(StockCheckResponse))]
[JsonSerializable(typeof(StockLine))]
[JsonSerializable(typeof(StockShortfall))]
[JsonSerializable(typeof(DaprErrorBody))]
public sealed partial class OrderApiJsonContext : JsonSerializerContext;
The two registrations above hand that context to their serializers in different shapes. Dapr gets UseJsonSerializationOptions(OrderApiJsonContext.Default.Options), a hard swap: the only things the client serialises are state values and invocation payloads, every one of them is on the list above, and there is nothing left for a fallback to catch. Minimal API instead gets the context inserted at the front of the resolver chain, because the framework serialises types this file will never list: ProblemDetails, the bare strings that come out of Results.BadRequest, and whatever the next middleware decides to write. Swap the reflection resolver out there and you break responses you never wrote.
The asymmetry decides how a mistake reaches you. Add a type to a response, forget the matching [JsonSerializable] line, and the Dapr path fails on it immediately while the HTTP path quietly falls through to reflection and works. The quiet side is the one that breaks when somebody publishes trimmed or AOT.
JsonSerializerDefaults.Web on the attribute is the other line to read twice. It is what keeps the generated metadata in step with ASP.NET Core's own defaults: camelCase names, case-insensitive reads, numbers accepted from strings. Leave it off and nothing fails to compile; a property just silently stays null at runtime, on the far side of a service call, where you will look for it last.
Starting both, with a sidecar each
Two apps means two sidecars, and the CLI has a multi-app run file for exactly that:
version: 1
common:
env:
ASPNETCORE_ENVIRONMENT: Development
apps:
- appID: orders-api
appDirPath: .
appProtocol: http
appPort: 5100
resourcesPaths: ./components/local
command: ["dotnet", "run", "--project", "./OrderApi", "--no-build", "--", "--urls", "http://localhost:5100"]
- appID: inventory-api
appDirPath: .
appProtocol: http
appPort: 5101
resourcesPaths: ./components/local
command: ["dotnet", "run", "--project", "./InventoryApi", "--no-build", "--", "--urls", "http://localhost:5101"]
dotnet build
dapr run -f .
dapr run -f . reads that file, starts both projects with a daprd sidecar each, and points both at components/local. dapr stop -f . takes the set back down. Two details in the YAML cost time if you skip them: every relative path is resolved against appDirPath, which is why both apps keep appDirPath: . and select their project on the command line instead, and appPort is a declaration rather than an instruction. It tells the sidecar which port to call your app on; the --urls argument after the -- is what makes Kestrel actually listen there. The two are set separately and nothing checks that they agree, so keep them in view of each other.
The component behind resourcesPaths is unchanged from Part 2: a state.redis component named orderstore locally, a state.azure.cosmosdb component with the same metadata.name in components/azure, and no C# that knows the difference. One line in it belongs to this section's point rather than to Part 2's:
scopes:
- orders-api
inventory-api is deliberately absent. It holds no state, so it has no reason to be able to reach the store, and without a scopes list every Dapr app in the environment loads every component. The service that costs zero lines of Dapr code also gets zero Dapr permissions.
Calling a service you cannot name
Service invocation in the current SDK is a client you register rather than a method you call. DaprClient.CreateInvokeHttpClient hands back a configured HttpClient, and from there the call site is plain ASP.NET Core: a path, a DTO, PostAsJsonAsync. (The InvokeMethodAsync overloads that used to do this are marked [Obsolete] as of 1.17 and point at the same factory, so new code and migrated code land in the same place.)
// Service invocation runs over an ordinary HttpClient. CreateInvokeHttpClient sets BaseAddress
// to http://inventory-api and installs the handler that rewrites the request into
// {daprEndpoint}/v1.0/invoke/inventory-api/method/{path}. One client per target app ID is the
// documented pattern (an app ID containing an uppercase letter only works when it is passed
// here), so the app ID doubles as the DI key.
builder.Services.AddKeyedSingleton<HttpClient>(
InventoryClient.AppId,
(_, key) => DaprClient.CreateInvokeHttpClient(appId: (string)key!));
builder.Services.AddSingleton<InventoryClient>();
InventoryClient.AppId is the constant "inventory-api", and under Dapr's service invocation model that is the entire address. No host, no port, no service discovery configuration anywhere in orders-api.
A second factory does the same thing, which matters when you are reading someone else's code. DaprClient.CreateInvokeHttpClient(appId) is static. daprClient.CreateInvokableHttpClient(appId) is an instance method, and its implementation is a one-line delegation to the static one: it passes along the DaprClient's own HTTP endpoint and API token header. The two are alternatives rather than an old form and a new one. Reach for the instance form when the DaprClient you already hold was configured with a non-default endpoint or an API token, which saves repeating both at every call site. For a client built once at startup, as above, the static form is the one that fits.
What the handler does, and what it refuses to do
The InvocationHandler that factory installs is a DelegatingHandler with a short job list. It reads the request URI, treats the host as the target app ID, and rewrites the request onto the sidecar's invoke route. If an API token is configured it adds a dapr-api-token header for the duration of the call. Then it passes the response back to you completely unmodified: no status inspection, no exception, no wrapping of any kind.
That last part is the one to plan around. A non-2xx from the target arrives as an ordinary HttpResponseMessage, an unreachable sidecar as an ordinary HttpRequestException, and nothing in between knows what an app ID is. Error translation is your code's job, and the sample gives it a home. (Teams arriving from InvokeMethodAsync give up InvocationException and its .AppId, .MethodName and .Response in the move; the SDK does not offer a replacement.)
public async Task<Result<StockCheckResponse>> CheckStockAsync(
StockCheckRequest request,
CancellationToken cancellationToken)
{
try
{
// A plain POST. "/stock/check" is the route on the target app; the app ID lives in
// the BaseAddress, and the handler turns the pair into
// {daprEndpoint}/v1.0/invoke/inventory-api/method/stock/check.
using var response = await http.PostAsJsonAsync(
"/stock/check",
request,
OrderApiJsonContext.Default.StockCheckRequest,
cancellationToken);
if (!response.IsSuccessStatusCode)
{
return await TranslateFailureAsync(response, cancellationToken);
}
var body = await response.Content.ReadFromJsonAsync(
OrderApiJsonContext.Default.StockCheckResponse, cancellationToken);
return body is null
? Fail(InvocationFailure.InvalidResponse, (int)response.StatusCode, "inventory-api returned an empty body.")
: new Result<StockCheckResponse>.Success(body);
}
catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
// HttpClient reports its own timeout as a cancellation, so the guard is what
// separates "we gave up" from "the caller went away".
logger.LogWarning(ex, "Stock check for {OrderId} timed out.", request.OrderId);
return Fail(InvocationFailure.Timeout, null, "inventory-api did not answer in time.");
}
catch (HttpRequestException ex)
{
// Connection-level: daprd itself is not listening. Nothing was routed.
logger.LogError(ex, "Could not reach the Dapr sidecar for a stock check on {OrderId}.", request.OrderId);
return Fail(InvocationFailure.SidecarUnreachable, null, "The Dapr sidecar is not reachable.");
}
}
InventoryClient is the only class in orders-api that knows inventory-api exists. It owns the wire contract and the error translation, and Fail is a one-liner that wraps an InvocationError into Result<StockCheckResponse>.Failure.
The when (!cancellationToken.IsCancellationRequested) guard on the first catch earns its keep. HttpClient reports its own timeout as a TaskCanceledException, the same type you get when the incoming request is abandoned and ASP.NET Core cancels the token. Without the guard, a caller who closed their browser and an upstream service that stopped answering produce the same log line and the same response.
Failures as values
public abstract record Result<TValue>
{
private Result()
{
}
public sealed record Success(TValue Value) : Result<TValue>;
public sealed record Failure(InvocationError Error) : Result<TValue>;
}
public sealed record InvocationError(InvocationFailure Kind, int? StatusCode, string Message);
public enum InvocationFailure
{
TargetUnreachable,
SidecarUnreachable,
UpstreamError,
InvalidResponse,
Timeout,
}
The private constructor on Result<TValue> is what makes this worth typing out rather than reaching for a bool and an out parameter. No type outside the file can derive from it, so Success and Failure are the only cases that can ever exist, and a switch over them is exhaustive by construction rather than by convention. The create-order handler leans on that directly:
var stock = await inventory.CheckStockAsync(stockRequest, cancellationToken);
switch (stock)
{
case Result<StockCheckResponse>.Failure(var error):
return ToProblem(error);
case Result<StockCheckResponse>.Success({ Available: false } answer):
logger.LogInformation("Rejected order {OrderId}: inventory short on {Count} line(s).",
request.OrderId, answer.Shortfalls.Count);
return Results.Conflict(new OutOfStockResponse(request.OrderId, answer.Shortfalls));
}
"Inventory said no" and "inventory could not be asked" sit side by side in one statement instead of being split across a try and a catch twenty lines apart. That is the argument for the result pattern here: through Dapr, a call failing is not exceptional. The target app ID has no address yet, the target is mid-restart, the sidecar is still warming up. Those are Tuesday afternoon, not an incident.
The five InvocationFailure cases are deliberately coarser than HTTP status codes, because the caller's question is never "was that a 502 or a 504". It is "retry, give up, or blame the payload". Which is exactly why the client bothers to open the sidecar's error body at all:
private async Task<Result<StockCheckResponse>> TranslateFailureAsync(
HttpResponseMessage response,
CancellationToken cancellationToken)
{
var status = (int)response.StatusCode;
var raw = await response.Content.ReadAsStringAsync(cancellationToken);
var daprError = TryReadDaprError(raw);
if (string.Equals(daprError?.ErrorCode, DirectInvokeErrorCode, StringComparison.Ordinal))
{
logger.LogWarning(
"Sidecar could not route to {AppId}: {Detail}", AppId, daprError?.Message ?? raw);
return Fail(InvocationFailure.TargetUnreachable, status, $"Dapr could not route to '{AppId}'.");
}
logger.LogWarning("inventory-api answered {Status}: {Body}", status, raw);
return Fail(InvocationFailure.UpstreamError, status, $"inventory-api answered {status}.");
}
When the sidecar cannot route to an app ID it answers HTTP 500 with a JSON body whose errorCode is ERR_DIRECT_INVOKE. Read only the status and that is indistinguishable from inventory-api throwing on your request. The distinction is the whole point: in the first case nothing ran on the far side, so the call is worth making again once the target registers; in the second, the target ran and rejected you, and repeating it changes nothing. DirectInvokeErrorCode is a constant on the client holding that string, and TryReadDaprError is best-effort, catching JsonException and returning null, because a target app is free to return whatever it likes in an error body and non-JSON there is normal.
Those two categories then have to reach the HTTP caller as different answers:
private static IResult ToProblem(InvocationError error) => error.Kind switch
{
InvocationFailure.TargetUnreachable or InvocationFailure.SidecarUnreachable =>
Results.Problem(
title: "Inventory is unavailable.",
detail: error.Message,
statusCode: StatusCodes.Status503ServiceUnavailable),
InvocationFailure.Timeout =>
Results.Problem(
title: "Inventory did not answer in time.",
detail: error.Message,
statusCode: StatusCodes.Status504GatewayTimeout),
_ => Results.Problem(
title: "Inventory rejected the stock check.",
detail: error.Message,
statusCode: StatusCodes.Status502BadGateway),
};
A routing failure becomes 503 and an error the target produced becomes 502, which is that same distinction reaching your caller as advice about whether to try again. Both were HTTP 500 on the wire a moment ago. Retries, backoff, and circuit breakers on top of this belong to Dapr's own resiliency policies rather than to your handler, and Part 5 covers them where the migration story makes the trade-offs concrete.
If you would rather speak gRPC
Calls between sidecars go over gRPC no matter what, so choosing gRPC for your own leg changes how your process talks to the process next to it and nothing about the hop between services. The factory is DaprClient.CreateInvocationInvoker(appId), which is static and returns a Grpc.Core.CallInvoker that injects the target app ID (and the API token) into outgoing gRPC metadata. You hand that invoker to a client generated from the target service's .proto, which is the real .NET-side cost: HTTP invocation needs a DTO and an HttpClient, gRPC invocation needs a contract both sides agree on and a build step that generates from it. The sample stays on HTTP.
The token nobody checks
The header everyone reaches for first is the wrong one. dapr-app-id is an outbound routing header. You put it on a request to your own sidecar to say which app you want to reach, as an alternative to rewriting the URL. It is an instruction, not a claim, and Dapr never verifies it on the way in. An endpoint filter that reads dapr-app-id and rejects anything unexpected blocks precisely nobody, because any caller who can reach your app port can set that header to any string they like.
The primitive that does mean something is dapr-api-token, and the part that catches people out is the token asymmetry. One header name, two directions, two environment variables:
-
DAPR_API_TOKENsecures your app's outbound calls to its own sidecar. The SDK reads that variable itself and attaches the header, including on theHttpClientfrom the previous section. No application code inorders-apitouches it. -
APP_API_TOKENsecures the sidecar's inbound calls into your app: service invocation arriving from another app, pub/sub deliveries, input bindings. The runtime sends it on the samedapr-api-tokenheader, and checking it is entirely your problem.
Dapr's guidance for the second one is a sentence: look for the header. There is no ASP.NET Core code on the page, which is presumably why so few services have any check at all.
In a Minimal API, an endpoint filter is where it goes, and a class rather than a lambda, because the check needs configuration and a logger.
var orders = app.MapGroup("/orders")
.AddEndpointFilter<DaprApiTokenFilter>();
orders.MapCreateOrder();
orders.MapGetOrder();
orders.MapConfirmOrder();
orders.MapCancelOrder();
Applying it at the group rather than per endpoint is what makes it hard to bypass: AddEndpointFilter covers every endpoint added to the group afterwards, so a new slice cannot forget the token check by forgetting a line. ASP.NET Core constructs the filter through ActivatorUtilities, so IConfiguration and ILogger<T> arrive by constructor injection with no registration of the filter type anywhere.
public sealed class DaprApiTokenFilter : IEndpointFilter
{
private const string HeaderName = "dapr-api-token";
private readonly byte[]? expectedToken;
private readonly ILogger<DaprApiTokenFilter> logger;
public DaprApiTokenFilter(IConfiguration configuration, ILogger<DaprApiTokenFilter> logger)
{
var token = configuration["APP_API_TOKEN"];
expectedToken = string.IsNullOrEmpty(token) ? null : Encoding.UTF8.GetBytes(token);
this.logger = logger;
if (expectedToken is null)
{
logger.LogWarning("APP_API_TOKEN is not set; inbound Dapr calls are not authenticated.");
}
}
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(next);
if (expectedToken is null)
{
return await next(context);
}
var presented = context.HttpContext.Request.Headers[HeaderName];
// Exactly one value. A repeated header is a caller trying something.
if (presented.Count != 1 || !Matches(presented[0], expectedToken))
{
logger.LogWarning(
"Rejected {Method} {Path}: missing or invalid dapr-api-token.",
context.HttpContext.Request.Method,
context.HttpContext.Request.Path);
return Results.Unauthorized();
}
return await next(context);
}
private static bool Matches(string? candidate, byte[] expected)
{
if (string.IsNullOrEmpty(candidate))
{
return false;
}
// FixedTimeEquals is false for a length mismatch without leaking where the difference
// is. A token is a shared secret, so the ordinary string comparison is the wrong tool.
return CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(candidate), expected);
}
}
Two decisions in there will make you stop and squint.
CryptographicOperations.FixedTimeEquals instead of == is the smaller one. An ordinary string comparison returns as soon as two bytes differ, and that difference in timing is a signal a patient caller can measure. The presented.Count != 1 test next to it rejects a request carrying the header twice: a client with a legitimate token sends it once, and a duplicated header is somebody probing which value the framework picks.
The larger decision is that the filter fails open. With APP_API_TOKEN unset there is no expected token, and every request goes through with a warning logged once. That looks backwards for a security filter, and it is the only behaviour that works: with no token configured, the sidecar has nothing to send, so a filter that closed would reject Dapr itself and leave you debugging 401s from your own runtime. It also keeps a plain curl against the app port working on a laptop. Anywhere that is not a laptop, set the variable.
The filter is constructed once per endpoint rather than once per request, so that "not authenticated" warning appears at startup and never again. Four routes in the group, four warnings, then silence: a registration receipt, not a repeated alarm.
What this prevents is not "a caller with the wrong app ID". Your application listens on its own port, and the sidecar is one more client of that port. Anything else with network access to the process, a misconfigured ingress rule, a pod in the same namespace, a colleague's port-forward, reaches your endpoints directly and bypasses Dapr entirely: no mTLS, no access control policy, no trace. The one thing such a caller cannot produce is a token it was never given.
Order state, and the write that loses
Every order endpoint touches state, and the calls look nearly alike without being alike. Creating an order writes a value that did not exist a second ago. Confirming one reads a value, changes a field, and writes it back. The second shape is the one a key-value store cannot make safe on its own, and the SDK's method names tell you so before the documentation does.
The create handler ends on a single call:
// State management. SaveStateAsync has no ETag parameter at all: an unconditional
// write is last-write-wins by construction. That is the right call for a create, and
// the wrong call for the confirm in the sibling slice.
//
// The metadata is the Cosmos DB partition, and it is on all four slices or none of
// them. OrderPartition says why; this is the write that decides where the document
// lands, so it is the one to get right first.
await dapr.SaveStateAsync(
StateStore.Name,
order.OrderId,
order,
metadata: OrderPartition.For(order.CustomerId),
cancellationToken: cancellationToken);
StateStore.Name is the constant "orderstore", which is the component's metadata.name and the only string this application knows about its own database. The key it hands over is order.OrderId; what lands in Redis locally and in Cosmos DB in Azure is orders-api||ORD-1001, with the app-ID prefix Part 2 covered added by the sidecar rather than by the SDK.
SaveStateAsync has no ETag parameter, in any overload: not an optional one, not a nullable one. Its signature is store, key, value, StateOptions, metadata, cancellation token, and nothing in there can carry a version, which makes an unconditional save last-write-wins by construction. Optimistic concurrency in this SDK is opt-in by choosing a different method rather than by passing an extra argument. For a create that is the behaviour you want: the value being written was computed from the request, not from prior state, so there is no earlier version whose contents you could be silently discarding.
That metadata argument is a decision rather than an API, and the transactions section argues it out. The rule it follows: the partition value is the same on every call that touches an order key, which is why it appears on the create, the read, and the confirm alike.
Reading it back is the same shape in reverse, and the route is the first place the decision shows:
private static async Task<IResult> GetOrderAsync(
string customerId,
string orderId,
DaprClient dapr,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(dapr);
var order = await dapr.GetStateAsync<Order>(
StateStore.Name,
orderId,
metadata: OrderPartition.For(customerId),
cancellationToken: cancellationToken);
// Cosmos DB enforces the second half of this on its own: an order from another customer
// is in another partition and comes back null. Redis ignores the metadata and hands over
// whatever sits under the key, so the check is what keeps both stores answering alike.
return order is null || !string.Equals(order.CustomerId, customerId, StringComparison.Ordinal)
? Results.NotFound()
: Results.Ok(order);
}
The route is /orders/{customerId}/{orderId} rather than /orders/{orderId}, and that is not a REST preference. The partition value lives inside the document you have not read yet, so a caller has to name the partition to address the order at all. Committing to a partition key reaches the URL, not just the storage layer.
A missing key is not an error in Dapr. GetStateAsync returns the default value for TValue, so a key that was never written and a key holding nothing look the same from inside the handler. Turning either into a 404 is your application's decision; the runtime has no opinion. The customer check beside it collapses "no such order" and "not your order" into the same 404 on purpose, since answering them differently would tell an unauthenticated caller which order IDs exist.
The confirm is a loop, not a call
Confirming an order is a read-modify-write against a key other replicas of orders-api are free to write at the same moment. The conditional save is TrySaveStateAsync, which takes an ETag and returns bool: false means the store's current ETag no longer matches the one you presented. No exception, no status code to unpick. Part 2 spent real space on the fact that an ETag mismatch on the raw HTTP API is not a 409 but a 500-class body with ERR_STATE_SAVE and "possible etag mismatch" buried in the text. The .NET client absorbs that and gives you a branch.
private static async Task<IResult> ConfirmOrderAsync(
string customerId,
string orderId,
DaprClient dapr,
ILogger<Order> logger,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(dapr);
var partition = OrderPartition.For(customerId);
for (var attempt = 1; attempt <= MaxAttempts; attempt++)
{
var (order, etag) = await dapr.GetStateAndETagAsync<Order>(
StateStore.Name,
orderId,
ConsistencyMode.Strong,
partition,
cancellationToken);
// A missing key comes back as a default value plus some ETag rather than throwing,
// so the null check has to come before anything reads the order. The customer check
// beside it is what Redis will not do for you: on Cosmos DB an order belonging to
// someone else is simply in another partition and never arrives.
if (order is null || !string.Equals(order.CustomerId, customerId, StringComparison.Ordinal))
{
return Results.NotFound();
}
if (order.Status is OrderStatus.Confirmed)
{
// Already done. Re-confirming is a no-op, which is what makes this endpoint
// safe to retry from the outside as well as the inside.
return Results.Ok(order);
}
var confirmed = order with
{
Status = OrderStatus.Confirmed,
ConfirmedAt = DateTimeOffset.UtcNow,
};
var saved = await dapr.TrySaveStateAsync(
StateStore.Name,
orderId,
confirmed,
etag,
new StateOptions
{
// With an ETag attached the store already behaves first-write-wins; saying
// so explicitly keeps the intent readable next to the ETag itself.
Concurrency = ConcurrencyMode.FirstWrite,
Consistency = ConsistencyMode.Strong,
},
partition,
cancellationToken);
if (saved)
{
logger.LogInformation("Confirmed order {OrderId} on attempt {Attempt}.", orderId, attempt);
return Results.Ok(confirmed);
}
logger.LogInformation(
"Lost the ETag race on order {OrderId}, attempt {Attempt} of {MaxAttempts}.",
orderId, attempt, MaxAttempts);
// A little backoff. A tight loop under contention just restates the race at speed.
await Task.Delay(TimeSpan.FromMilliseconds(25 * attempt), cancellationToken);
}
logger.LogWarning("Gave up confirming order {OrderId} after {MaxAttempts} attempts.", orderId, MaxAttempts);
return Results.Conflict(new ConfirmConflictResponse(orderId, MaxAttempts));
}
MaxAttempts is a private constant set to 5, and the bound is not decoration. An unbounded version of this loop is a livelock with good manners: under sustained contention it never returns and never errors, which is worse for whoever is holding the request open than a conflict would be.
The line that carries the weight is the position of the read. GetStateAndETagAsync sits at the top of the loop body, not above it. Hoist it out to save a round trip and every retry re-presents the ETag the store has already moved past, which is precisely the ETag that cannot match. The save then fails five times for the same reason it failed once, the backoff makes each failure slower than the last, and the endpoint returns a conflict that no amount of retrying would ever have resolved. Re-reading is not an optimisation on top of the retry; re-reading is what a retry consists of.
The early return on an already-confirmed order does something separate and easy to read past: it makes the endpoint idempotent before it makes it concurrency-safe. Inside the loop, a lost race whose winner also confirmed the order terminates on the next pass instead of burning attempts on a write that would be a no-op anyway. Outside the loop, a caller who never saw the response (a timeout, a dropped connection, a queue redelivering) can send the same request again and get the same 200 back. Without that check the second confirm would either overwrite a ConfirmedAt timestamp somebody else already committed or fight for the right to do so.
Then there is ConsistencyMode.Strong, passed twice. The StateOptions on the save draws the eye, but it is the third positional argument on the read that decides whether this loop means what it says. Dapr assumes data stores are eventually consistent by default, and under ConsistencyMode.Eventual a read can come back from a replica that has not caught up. The ETag you were handed is then already behind the store's authoritative copy, and a conditional save that returns true has told you something narrower than "nobody changed this while I was working": it has told you that a comparison against whichever copy answered went through. Strong on the read is what makes the ETag a claim about the current value.
One gap the sample steps around rather than solves. What the ETag in that tuple contains when the key does not exist is not something the code relies on: it inspects the value, returns 404, and never looks at the string. If you are tempted to branch on an empty ETag to mean "this key is new", verify that against the store you actually run, because the SDK's contract does not promise it.
Bulk is not a transaction
Dapr's own overview describes bulk operations as submitting multiple requests individually to the underlying store and returning the results as a single batch. Read that sentence for what it withholds. A batch is a round-trip optimisation: one call from your process to the sidecar instead of N, with the sidecar doing the fan-out. It is not a unit of work. SaveBulkStateAsync with five items can leave you with three items written and two not, and the SDK's return type has no room to tell you which. On the wire it is not even a distinct operation; it is the same POST /v1.0/state/<store> array body Part 2 described for a single save, with more entries in the array.
The atomic one is ExecuteStateTransactionAsync, and only against a store that declares transaction support. Against Blob Storage or Table Storage the call fails outright rather than partially, which is the better failure of the two. Its operations are Upsert or Delete only; there is no read inside a transaction, so every value you commit was read before the transaction was built, which puts you back on the ETag machinery from the previous section if the read matters. The retry shape is not the same one, though. ExecuteStateTransactionAsync returns a bare Task, so there is no false to branch on the way TrySaveStateAsync gives you, and a conflict can only reach you as an exception.
The bulk signatures carry a second surprise, visible only if you go looking for a parameter that is not there.
public abstract Task SaveBulkStateAsync<TValue>(
string storeName, IReadOnlyList<SaveStateItem<TValue>> items,
CancellationToken cancellationToken = default);
public abstract Task DeleteBulkStateAsync(
string storeName, IReadOnlyList<BulkDeleteStateItem> items,
CancellationToken cancellationToken = default);
Every other state method on DaprClient takes StateOptions and a metadata dictionary as top-level parameters. These two take neither, because consistency, concurrency, ETag and metadata all live per item, on SaveStateItem<TValue> and BulkDeleteStateItem. That is a fair reflection of what the call is: N independent writes that happen to travel together, each with its own ETag and its own opinion about consistency, and nothing global left to configure.
Which brings up the part nobody warns you about. Non-actor state in Cosmos DB partitions by the item's own state key, so two orders under two keys land on two partitions, by design and by default. Cosmos DB also requires every item in one transaction to share a partition. Put those together and a multi-key ExecuteStateTransactionAsync against Cosmos DB can fail on partitioning alone, with data that is entirely consistent and code that is entirely correct in its own terms. The fix is the same metadata bag every state method carries, with an explicit partitionKey set to the same value on every operation in the set:
// The serialisation the transactional API does not do for you.
var orderBytes = JsonSerializer.SerializeToUtf8Bytes(cancelled, OrderApiJsonContext.Default.Order);
var auditBytes = JsonSerializer.SerializeToUtf8Bytes(cancellation, OrderApiJsonContext.Default.OrderCancellation);
await dapr.ExecuteStateTransactionAsync(
StateStore.Name,
[
new StateTransactionRequest(order.OrderId, orderBytes, StateOperationType.Upsert, metadata: partition),
new StateTransactionRequest(CancellationKey(order.OrderId), auditBytes, StateOperationType.Upsert, metadata: partition),
],
cancellationToken: cancellationToken);
StateTransactionRequest takes the value as byte[]?, which is why the two SerializeToUtf8Bytes calls are sitting there in the open. Every other state method on the client serialises for you; this one hands the job back.
Nothing in the .NET signature hints at the partition constraint either. metadata is an opaque string dictionary forwarded to the component verbatim, and which keys are legal is entirely store-specific, so the compiler cannot help and neither can IntelliSense.
Then comes the part that makes this a design decision rather than a parameter. The default partition value is the key the component sees, which is the prefixed orders-api||ORD-1001 from Part 2, and application code cannot reproduce that without hardcoding its own app ID. An explicit partitionKey therefore never equals the default: it always moves the key somewhere else. The override is not scoped to the transaction, it is a property of where that document now lives, so every later read and write of those keys has to carry the same override or Cosmos DB looks in the partition the state key implies and finds nothing there. Adding one transaction to an existing key relocates it, permanently, for every code path that touches it. Decide the partitioning before the first write, not on the day you need two keys to commit together.
Redis has no equivalent constraint, running the set through MULTI/EXEC in one keyspace, which is exactly what makes the trap quiet: the transaction that works all week on your laptop is the one that fails on the store you deploy to. Actor state avoids it because Dapr derives the partition key from the app ID, actor type and actor ID rather than the state key, which guarantees one actor's writes share a partition.
Whether any of this is available to you is a property of the component, not of the API, and the four Azure-relevant stores differ more than the common DaprClient surface suggests:
Actor eligibility is not a flag anyone sets. Dapr's rule is that a store can back actors if it supports both transactions and ETag, which is why the last column is derivable from the two before it rather than being independent information. The practical reading of the bottom two rows: Blob Storage and Table Storage will take the SaveStateAsync and TrySaveStateAsync calls from the previous section and reject ExecuteStateTransactionAsync, so a component swap that looks like a one-line change in YAML can remove an operation your code depends on without any C# changing at all. That is the same leak Part 2 flagged, arriving through a different door.
What I would not build on yet
DaprClient has a QueryStateAsync, and it reads like the answer to "give me every order whose status is pending". I would keep it away from anything that has to be on call.
The endpoint behind it is POST /v1.0-alpha1/state/<store>/query, and the how-to page carries an alpha banner to match. That is the polite version of the warning. The impolite version is the release history: the API has been alpha since 1.5, no release note for 1.15, 1.16, 1.17, or 1.18 mentions it moving, and alpha work beside it graduated on schedule, Bulk PubSub in 1.17 and the Jobs API in 1.18. An API that has not moved through four minor releases of a project that demonstrably does promote things is not queued behind the next one. That, rather than the word "alpha", is the reason to stay off it.
The PostgreSQL situation is sharper still. The v2 component stores values as BYTEA where v1 used JSONB, and JSONB was what made SQL-level filtering possible, so v2 shipped with no query support and still has none; Dapr's own reference page says so outright. v1 keeps it and is not deprecated, which leaves the component version you pick deciding whether a .NET method you already wrote does anything at all.
Which stores implement it is the second reason. Query support is not a documented capability flag: Dapr's own state store table carries CRUD, transactions, ETag, TTL, actors, and workflow, and there is no query column in it. The only reliable signal is in components-contrib, where a store supports the alpha API if its Go package ships a *_query.go. Cosmos DB has cosmosdb_query.go, Redis has redis_query.go, and Blob Storage has neither, which fits a store that holds one opaque blob per key. Having to read Go source to find out whether a .NET method does anything against your configured store says plainly where this feature sits in the project.
Searching for it hands you the wrong page as well. Dapr's docs have a Query state store section with a page for Cosmos DB and a page for Redis, and neither one is about the Query API. They show you how to query the underlying store with its own tools, the Cosmos DB SQL API in Data Explorer and redis-cli against the keys, with the sidecar out of the picture entirely. Read at speed that looks like documented per-store support for the thing you went looking for, when it is the docs telling you to go around Dapr.
Which is also the recommendation. When a read path needs "all orders where X", query the store natively behind a repository type that is the one place the vendor SDK appears, or keep an index you own: a key per status holding the order IDs, maintained by the same code that writes the order. Both are more code than one QueryStateAsync call, and both still work after a component upgrade.
Conclusion
Part 2 priced Dapr as a trade: the vendor names leave your code, and a second process joins every replica. Two services later, the first half holds up, and the second half is not where the cost showed up.
inventory-api is the evidence for the first half. Another service reaches it by app ID, through a sidecar, and none of that reached its source code: the project file carries a comment where the package references would be, and that is the whole of it. On the calling side, the component name is the only place a backing store gets named, and moving from Redis to Cosmos DB is a different file under components/, not a different using.
What the component name does not hide is the shape of the store behind it. /orders/{customerId}/{orderId} has a customer in it because Cosmos DB needs a partition before it can find a document, and an explicit partitionKey on one transaction moves those keys for every code path that touches them afterwards. No vendor type appears anywhere in that, and the vendor's constraints still reach the route table.
The rest of the bill is the code between your service and its sidecar. The HttpClient the SDK hands you routes and does nothing else, so telling "the sidecar could not route to that app ID" apart from "the target ran and refused you" is a string constant and a best-effort JSON read you maintain yourself. The inbound token check has the same shape: Dapr defines APP_API_TOKEN and sends it on every call into your app, and whether anything compares it is a filter you write from a docs page one sentence long.
So the second process is not the expensive part. The expense is the code between your service and it, and how much of that code the documentation leaves to you. Part 4 hands the run file and the components folder to .NET Aspire, where the sidecar stops being something you remember to start.















