Chapter 6 of 12

Azure for .NET Developers: Production-Grade Cloud Architecture and Operations

Azure Cosmos DB for .NET: Distributed Data Patterns

Azure Cosmos DB sits at the intersection of planetary-scale distribution and millisecond-latency data access. Unlike relational systems that bolt on replication as an afterthought, Cosmos DB was engineered from the ground up around a globally distributed, multi-model log-structured storage engine where consistency, partitioning, and throughput are first-class design dials. This chapter gives you the mental models, SDK patterns, and operational instincts required to build production .NET systems on Cosmos DB that stay performant as data grows, available as regions fail, and economical as workloads evolve.

1. Foundations: Resource Model and Storage Engine

Account, Database, Container Hierarchy

Every Cosmos DB deployment starts with an account — the unit of geo-distribution, network isolation, and identity. Beneath the account sit databases (logical namespaces for billing and access policy) and containers (independently scalable, partitioned collections of JSON documents). Throughput provisioning, partition key selection, and indexing policy decisions are all made at the container level.

When creating a container choose between provisioned throughput and serverless. Provisioned throughput reserves Request Units per second (RU/s) and supports autoscale; serverless charges per consumed RU with no pre-commitment. For production workloads with predictable traffic, provisioned autoscale is almost always the right choice — it gives latency guarantees and absorbs spikes without cold-start penalty.

The container's storage is divided into logical partitions (all documents sharing a partition key value) and physical partitions (SSD-backed replica sets). Cosmos DB maps logical to physical partitions and splits physical partitions as data grows, transparently to your application. ACID transactions are only guaranteed within a single logical partition.

Request Units: The Universal Currency

A Request Unit (RU) is calibrated so that reading a 1 KB document by its primary key costs exactly 1 RU. Writes cost roughly 5× more than point reads for the same document size. The .NET SDK surfaces RU consumption on every response via the RequestCharge property — log it for every operation and alert when it exceeds your baseline by more than 20%.

Important

Cross-partition queries fan out across every physical partition, multiplying RU cost by the partition count. Design partition keys so the most frequent query patterns are single-partition scans.

Tip

Start with autoscale at a maximum of 4,000 RU/s for new containers. Let Azure Monitor's RU consumption metrics guide your first capacity review after two weeks of real traffic.

Architecture overview diagram showing Azure Cosmos DB for NoSQL distributed data patterns including the .NET SDK v3 CosmosClient with bulk operations, partition strategy and RU budgeting across logical and physical partitions, integrated vector search with DiskANN index, change feed processor driving event consumers, tunable consistency levels from Strong to Eventual, and multi-region active-active replication across East US, West US 2, West Europe, and Southeast Asia with automatic failover and 99.999 percent availability SLA.
Figure 6.1 — Cosmos DB for .NET: partitioning, SDK lifecycle, change feed, multi-region writes, and vector search

2. Partition Key Design and RU Budgeting

Choosing a Partition Key That Scales

Partition key selection is the highest-leverage architectural decision for a Cosmos DB container — and it is immutable. Choose a property with high cardinality, present on every document, that aligns with the natural access unit of your application. For multi-tenant SaaS, tenantId is almost always the right choice. The classic anti-pattern is choosing a low-cardinality property like status or region, which creates hot partitions that absorb the majority of traffic regardless of provisioned RU/s.

Cosmos DB supports hierarchical partition keys (up to three properties, e.g., ["tenantId","userId"]), useful when a single key creates too few logical partitions or when you need transactional atomicity across a two-level hierarchy. For most workloads, a single well-chosen key is sufficient.

Warning

Hot partitions produce 429 TooManyRequests errors on that specific partition while others remain underutilized. Increasing RU/s does not fix the distribution skew — only a data migration to a better-keyed container does.

Synthetic Keys and Bucketing

When no natural high-cardinality field exists, construct a synthetic partition key by combining fields: $"{customerId}:{orderDate:yyyyMM}". This creates monthly buckets that bound the 20 GB logical-partition limit while retaining customer-month co-location. The bucket pattern extends this with a modulus suffix ($"{tenantId}:{documentId.GetHashCode() % bucketCount}") to distribute writes across bucketCount partitions at the cost of bounded read fan-out.

RU Budgeting for Production

RU budgeting is an ongoing capacity planning process: measure actual RU consumption per operation type in a pre-production load test, multiply by expected peak ops/s with a 25% margin, and set that as your autoscale maximum. Revisit monthly during the first year as usage patterns stabilize.

Operation TypeApprox. RU (1 KB doc)Notes
Point read (id + partition key)1 RUAlways prefer over queries for known IDs
Upsert / replace5–7 RUVaries with index count; disable unused indexes
Single-partition query (indexed)2–5 RUScales with result set size
Single-partition query (unindexed)10–50+ RUFull scan within partition
Cross-partition query2–5 RU × NN = number of physical partitions
Change feed read (per page)2–5 RUVery efficient; batched per continuation token

Tip

Add a middleware layer that logs response.RequestCharge to Application Insights as a custom metric on every Cosmos DB operation, tagged with operation type and container name. This gives you a real-time RU heat map for both troubleshooting and capacity planning.

3. .NET SDK v3: Client Lifecycle and Bulk Operations

CosmosClient Lifecycle and Configuration

CosmosClient is a heavyweight, thread-safe singleton — creating one per request is a critical antipattern. Register it with AddSingleton in .NET DI. Use ConnectionMode.Direct for all Azure-hosted workloads (reduces latency by 1–3 ms per op), and authenticate with DefaultAzureCredential instead of account keys.

csharp
// Program.cs — Cosmos DB singleton with Direct mode + Managed Identity
builder.Services.AddSingleton<CosmosClient>(sp => {
    var cfg = sp.GetRequiredService<IConfiguration>();
    var opts = new CosmosClientOptions {
        ConnectionMode = ConnectionMode.Direct,
        MaxRetryAttemptsOnRateLimitedRequests = 9,
        MaxRetryWaitTimeOnRateLimitedRequests = TimeSpan.FromSeconds(30),
        ApplicationPreferredRegions = [Regions.EastUS2, Regions.WestUS2]
    };
    return new CosmosClient(cfg["CosmosDb:AccountEndpoint"], new DefaultAzureCredential(), opts);
});

Important

Always use Managed Identity rather than account keys. Account keys are 512-bit symmetric secrets that grant full data-plane access to your entire Cosmos DB account with no scope limit. Grant the minimum required role: Cosmos DB Built-in Data Contributor.

Optimized Read and Write Patterns

Point reads (by id + partition key) cost 1 RU and have P99 latency under 5 ms in the same region. Use GetItemQueryIterator only when the document ID is unknown. For atomic multi-document operations within a partition, use TransactionalBatch (up to 100 ops, single round trip) instead of stored procedures.

csharp
// Point read — 1 RU, O(1)
var response = await _container.ReadItemAsync<Order>(
    id: orderId, partitionKey: new PartitionKey(tenantId), cancellationToken: ct);
ActivitySource.AddTag("cosmos.ru", response.RequestCharge);

// Transactional batch — atomic within one partition key
var batch = _container.CreateTransactionalBatch(new PartitionKey(tenantId));
batch.CreateItem(newOrder);
batch.UpsertItem(updatedInventory);
using var res = await batch.ExecuteAsync(ct);
if (!res.IsSuccessStatusCode) throw new CosmosException(res.ErrorMessage, res.StatusCode, ...);

Bulk Operations and High-Throughput Ingestion

For data migrations and initial loads, enable AllowBulkExecution = true in CosmosClientOptions. The SDK switches from single-item path to a micro-batching pipeline that groups operations by partition and issues server-side batches automatically. Use a SemaphoreSlim to bound concurrency and avoid overwhelming the client-side queue.

csharp
var bulkClient = new CosmosClient(endpoint, credential,
    new CosmosClientOptions { AllowBulkExecution = true });
var sem = new SemaphoreSlim(500, 500);
var tasks = new List<Task>();
await foreach (var order in orderStream.WithCancellation(ct)) {
    await sem.WaitAsync(ct);
    tasks.Add(Task.Run(async () => {
        try { await container.UpsertItemAsync(order, new PartitionKey(order.TenantId), cancellationToken: ct); }
        finally { sem.Release(); }
    }, ct));
}
await Task.WhenAll(tasks);

Tip

For bulk jobs, set MaxRetryAttemptsOnRateLimitedRequests to at least 15 and MaxRetryWaitTimeOnRateLimitedRequests to 60 seconds. The bulk engine naturally spikes above provisioned RU/s during ramp-up; the retry policy prevents transient 429s from aborting the entire batch.

Architecture diagram showing Azure Cosmos DB distributed data patterns for .NET developers, including SDK v3 singleton client lifecycle with bulk operations, partition key strategy decision guide covering high cardinality and hierarchical keys, change feed processor driving event hub and downstream systems, multi-region active-active write topology with five tunable consistency levels from Strong to Eventual, and integrated vector search using HNSW or DiskANN index with VectorDistance SQL query syntax for RAG and semantic search scenarios.
Figure 6.2 — Cosmos DB distributed patterns: SDK lifecycle, partition strategy, change feed, multi-region writes, and vector search

4. Change Feed Processor: CDC Workflows

Change Feed Architecture and Delivery Guarantees

The Cosmos DB change feed is a persistent, ordered, append-only log of every document create and update, retained for the full life of the account. The change feed processor library provides distributed, load-balanced delivery via a leases container that tracks read position (continuation token) per physical partition. Multiple worker instances compete for leases, and adding workers increases processing throughput linearly up to the physical partition count.

Note

"Exactly-once" means exactly-once lease ownership. If your handler throws an unhandled exception, the processor retries from the last checkpoint, so handlers must be idempotent. Use the document's _etag or a versioned state store to achieve idempotency.

Implementing Change Feed Processor in .NET

Build the processor using the fluent builder API, supplying the monitored container, a dedicated leases container (partition key /id, 400 RU/s minimum), and an async handler delegate. The leases container tracks per-partition progress; checkpointing is automatic after the handler returns without exception.

csharp
var processor = monitoredContainer
    .GetChangeFeedProcessorBuilder<Order>(
        processorName: "order-search-indexer",
        onChangesDelegate: async (context, changes, innerCt) => {
            foreach (var order in changes)
                await indexer.UpsertOrderAsync(order, innerCt);
            // Checkpoint is automatic after handler returns without exception
        })
    .WithInstanceName(Environment.MachineName)
    .WithLeaseContainer(leasesContainer)
    .WithPollInterval(TimeSpan.FromSeconds(1))
    .Build();
await processor.StartAsync();

This pattern underpins CDC projections (maintaining a read-optimized Azure AI Search index), the event sourcing pattern (append-only event documents + change-feed aggregate hydration), and the outbox pattern (atomic batch write of domain doc + outbox event, then reliable change-feed forwarding to a message bus).

Soft Deletes and TTL for Delete Tracking

The change feed does not surface physical document deletions. Implement the soft delete pattern: set deleted: true and a ttl property instead of deleting. The change feed delivers the tombstone to your processor; Cosmos DB TTL physically removes the document after the specified seconds.

csharp
var patch = new[] {
    PatchOperation.Set("/deleted", true),
    PatchOperation.Set("/deletedAt", DateTimeOffset.UtcNow),
    PatchOperation.Set("/ttl", 86400) // Physical delete after 24 h
};
await _container.PatchItemAsync<Order>(orderId, new PartitionKey(tenantId), patch, ct);

Warning

TTL expiry without the soft delete pattern leaves downstream read models (search indexes, caches, materialized views) serving stale data for deleted records indefinitely. Always pair TTL with the soft delete flag when any change feed consumer exists.

A four-section production diagram covering Azure Cosmos DB distributed data patterns: top row shows logical versus physical partition strategy with RU budgeting rules, SDK v3 CosmosClient singleton with bulk operation flow, and multi-region write topology with vector search index. A five-row consistency level comparison matrix in the middle compares Strong, Bounded Staleness, Session, Consistent Prefix, and Eventual across guarantee, latency, availability, use case, and relative RU cost. Bottom row shows the change feed processor workflow from source container through the append-only change feed engine and lease container checkpoint to parallel processor instances and downstream event handlers. A legend and SDK quick-reference panel appear at lower right.
Figure 6.3 — Cosmos DB partition strategy, consistency matrix, change feed workflow, and multi-region write topology

5. Multi-Region Writes and Consistency Levels

The Five Consistency Levels

Cosmos DB offers five consistency levels from Strong (linearizable, highest latency) to Eventual (lowest latency, weakest guarantees). You set a default at the account level and may override per-request downward. Session consistency is the most widely appropriate choice for .NET web APIs: it guarantees a client always reads its own writes (tracked via a SDK-managed session token) with negligible overhead.

Consistency LevelRead Your WritesMulti-Region Write LatencyRU Premium
StrongYes2×–3× (blocked on all replicas)~2× reads
Bounded StalenessConfigurable lag1.5×–2×~1.5× reads
SessionYes (within session)Negligible
Consistent PrefixNoNegligible
EventualNo1× (lowest)None

Important

The account-level default consistency is a ceiling, not a floor. Lowering from Strong to Session often reduces read RU cost by 30–50% for read-heavy applications. Measure the impact across your workload after migrating.

Configuring Multi-Region Writes

Multi-region (multi-master) writes allow every geographic replica to accept writes simultaneously, providing sub-10 ms write acknowledgement for globally distributed users. The tradeoff is conflict resolution: the default Last-Write-Wins (LWW) policy uses _ts timestamp and works for most applications. Custom merge stored procedures are available for collaborative editing or distributed counter scenarios.

csharp
var options = new CosmosClientOptions {
    ApplicationPreferredRegions = [Regions.EastUS2, Regions.WestEurope],
    ConsistencyLevel = ConsistencyLevel.Session,
    // Omit response body on writes — reduces bandwidth for write-heavy workloads
    EnableContentResponseOnWrite = false
};

The SDK handles regional failover automatically via ApplicationPreferredRegions. Circuit breaker logic detects unavailability within 1–2 seconds and fails over in under 5 seconds, with no application-level intervention required. Setting EnableContentResponseOnWrite = false reduces latency and egress cost for write-heavy paths where the response body is not needed.

Vector Search Architecture

Cosmos DB integrated vector search (GA late 2024) stores embeddings directly alongside documents and executes approximate nearest neighbor (ANN) searches within the same query engine, eliminating dual-write complexity. The feature is especially powerful for queries that combine vector similarity with standard filters: "find the 10 most semantically similar orders to this query, but only for tenantId = 'acme' and status = 'active'" — the filter and vector search execute together on the same physical data.

Define a vector policy on the container specifying the path, data type (float32), and dimensions, and an indexing policy with a vector index — flat (exact, suitable for <100K docs) or DiskANN (approximate, graph-based, sub-millisecond at millions of documents with ~95% recall). Always exclude vector paths from the standard inverted index to avoid enormous index overhead.

Important

The vector property path (e.g., /descriptionVector) must appear in excludedPaths of the standard index. Including it causes Cosmos DB to index each of the 1,536 float values individually, dramatically increasing write RU cost and potentially exceeding maximum index entry size.

The application flow: generate an embedding for the search query using Azure OpenAI, execute a VectorDistance SQL query with partition key filter predicates, and return ranked results. Pre-filtering on a partition key (e.g., categoryId) constrains the ANN candidate set to a single logical partition, improving both recall and query cost.

csharp
// Generate embedding, then query with VectorDistance + equality filter
var qv = (await _embeddingClient.GenerateEmbeddingAsync(userQuery, ct)).Value.ToFloats().ToArray();
var sql = new QueryDefinition(@"
    SELECT TOP @topK c.id, c.name,
        (1.0 - VectorDistance(c.descriptionVector, @qv) / 2.0) AS similarity
    FROM c WHERE c.categoryId = @cat
    ORDER BY VectorDistance(c.descriptionVector, @qv)")
    .WithParameter("@topK", topK).WithParameter("@qv", qv).WithParameter("@cat", categoryId);

Managing Vector Freshness

Embeddings must be stored at document write time. For frequently changing documents, use a SHA-256 content hash to detect semantic changes and regenerate embeddings only when the hash changes. The change feed processor is the natural mechanism: the handler computes a new embedding and patches descriptionVector only when the content hash differs.

csharp
var hash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes($"{p.Name}|{p.Description}")));
if (p.DescriptionVectorHash == hash) return; // Skip — embedding is current
var emb = (await _client.GenerateEmbeddingAsync($"{p.Name}. {p.Description}", ct)).Value.ToFloats().ToArray();
await _container.PatchItemAsync<Product>(p.Id, new PartitionKey(p.CategoryId), new[] {
    PatchOperation.Set("/descriptionVector", emb),
    PatchOperation.Set("/descriptionVectorHash", hash)
}, ct);

7. Lab Exercises

1

CE-11: Provision Production Cosmos DB with Multi-Region Writes

Create a multi-region Cosmos DB account with private endpoint, Session consistency, and Managed Identity RBAC for the application identity. The script provisions the account across East US 2 and West Europe, creates the sales database with orders and orders-leases containers, and assigns Cosmos DB Built-in Data Contributor to the application identity.

bash
# CE-11 — Multi-region Cosmos DB account, private endpoint, Managed Identity RBAC
RG="rg-cosmos-db-dotnet-patterns-prod-001"; ACCOUNT="cosmos-db-prod-eastus2-001"
az group create --name "$RG" --location eastus2 --tags Environment=Production Project=DotNetPatterns
az cosmosdb create --name "$ACCOUNT" --resource-group "$RG" \
  --default-consistency-level Session \
  --locations regionName=eastus2 failoverPriority=0 isZoneRedundant=true \
  --locations regionName=westeurope failoverPriority=1 isZoneRedundant=true \
  --enable-multiple-write-locations true --enable-automatic-failover true \
  --enable-public-network-access false
az cosmosdb sql database create --account-name "$ACCOUNT" --resource-group "$RG" --name sales
az cosmosdb sql container create --account-name "$ACCOUNT" --resource-group "$RG" \
  --database-name sales --name orders --partition-key-path /tenantId --max-throughput 10000
az cosmosdb sql container create --account-name "$ACCOUNT" --resource-group "$RG" \
  --database-name sales --name orders-leases --partition-key-path /id --throughput 400
# Assign Cosmos DB Built-in Data Contributor to application managed identity
PRINCIPAL=$(az identity show --name id-order-api-prod-eastus2-001 --resource-group "$RG" --query principalId -o tsv)
ACCOUNT_ID=$(az cosmosdb show --name "$ACCOUNT" --resource-group "$RG" --query id -o tsv)
az cosmosdb sql role assignment create --account-name "$ACCOUNT" --resource-group "$RG" \
  --role-definition-id 00000000-0000-0000-0000-000000000002 \
  --principal-id "$PRINCIPAL" --scope "$ACCOUNT_ID"
2

CE-12: Deploy Change Feed Processor as Azure Container App

Build a .NET 8 change feed processor image via ACR Tasks, deploy it to Azure Container Apps with Managed Identity, and configure a KEDA scale rule based on Cosmos DB change feed lag so replicas scale between 2 and 10 based on unprocessed document count.

bash
# CE-12 — Build, push, and deploy change feed processor as Container App
ACR="acrordersprocessorprod001"; CA_APP="ca-orders-cfp-prod-eastus2-001"
az acr create --name "$ACR" --resource-group "$RG" --sku Premium --admin-enabled false
az acr build --registry "$ACR" --image "orders-cfp:latest" .
IDENTITY_ID=$(az identity show --name id-order-api-prod-eastus2-001 --resource-group "$RG" --query id -o tsv)
COSMOS_EP=$(az cosmosdb show --name "$ACCOUNT" --resource-group "$RG" --query documentEndpoint -o tsv)
az containerapp create --name "$CA_APP" --resource-group "$RG" \
  --environment cae-orders-prod-eastus2-001 \
  --image "${ACR}.azurecr.io/orders-cfp:latest" \
  --user-assigned "$IDENTITY_ID" --min-replicas 2 --max-replicas 10 \
  --env-vars "CosmosDb__AccountEndpoint=${COSMOS_EP}" "CosmosDb__DatabaseName=sales"
az containerapp update --name "$CA_APP" --resource-group "$RG" \
  --scale-rule-name cosmos-cfp-lag --scale-rule-type azure-cosmosdb \
  --scale-rule-metadata databaseName=sales containerName=orders \
    leaseContainerName=orders-leases targetMetric=ResidualDocumentCount targetValue=500

8. Summary

ConceptKey Point
Partition Key DesignHigh-cardinality, immutable; prefer tenantId/deviceId; use synthetic keys or bucket pattern when natural keys are hot or absent.
RU BudgetingPoint reads = 1 RU; writes ≈ 5×; cross-partition = N×. Log RequestCharge on every op; set autoscale max at 1.25× observed peak.
SDK Client LifecycleDI singleton; ConnectionMode.Direct; Managed Identity; AllowBulkExecution only for batch — not OLTP paths.
Change Feed ProcessorExactly-once per lease; idempotent handlers mandatory; outbox pattern eliminates dual-write; soft delete + TTL surfaces deletes.
Consistency LevelsSession is the right default for most .NET APIs; Strong adds 2–3× write latency in multi-region; Bounded Staleness suits reporting dashboards.
Multi-Region WritesSub-10 ms write latency globally; LWW conflict resolution covers most scenarios; SDK handles failover via ApplicationPreferredRegions.
Integrated Vector SearchExclude vector path from standard index; use DiskANN at scale; pre-filter by partition key for pre-filtered ANN to improve recall and reduce cost.

Chapter: 6 of 12  |  Status: v0.1 Draft  |