
Azure Cosmos DB has a habit of exposing architectural weaknesses very early. Classic data-access patterns that work perfectly fine with relational databases often become expensive, noisy and operationally awkward once request units (RUs), partitioning and cross-partition query fan-out come into play.
The biggest optimization gains rarely come from cosmetic refactoring. The real leverage lies in how data boundaries, query paths and repository responsibilities are shaped before the first line of application code is written around the database.
The following recommendations are ordered by their architectural and operational impact. For local development workflows with the emulator, the operational setup is covered separately in Run Azure Cosmos DB locally with .NET Aspire and make emulator endpoints visible in the dashboard .
1. Design containers and partition keys together
The container design is usually the single most critical decision—even more important than any repository abstraction that sits in front of it. In Cosmos DB, container boundaries dictate throughput sharing, indexing policies, time-to-live (TTL) configurations, transactional scope and basic query costs. The partition key then determines whether this structure scale efficiently as data grows.
A common mistake is modeling containers 1:1 after entity types. This approach misses the physical reality of the database. A container is an operational boundary, not database table or a class name.
A single container is often the best choice when:
- Multiple document types belong to the same aggregate root or share a lifecycle.
- General query patterns are similar and access primarily stays within the same partition key.
- The same indexing policy and retention behavior apply across those types.
- Transactional batch operations within one logical partition key are required.
Multiple containers are typically justified when:
- Throughput (RUs) must be isolated between competing workloads to prevent starvation.
- Document lifecycles differ substantially (e.g., permanent master data versus 30-day telemetry logs).
- Indexing requirements are in direct conflict (one needs heavy indexing, the other is write-heavy and needs next to none).
- Workloads are highly write-heavy and read-heavy in ways that shouldn’t share the same resource envelope.
- Data boundaries are completely independent from an operational and domain perspective.
The question is rarely “one container or many” as a matter of purity, but rather whether workloads share enough physical and operational characteristics to run efficiently in the same space. When they do not, splitting them is often cheaper and simpler than forcing unrelated access patterns together.
2. Model documents for queries, not for normalized purity
Cosmos DB rewards documents that are already shaped to match how they are read. It does not reward highly normalized structures that require multiple application-side lookups, memory joins, or cross-partition fan-out queries to build a single UI response.
In practice, this means:
- Duplicating relatively stable lookup or master data inside documents to avoid extra roundtrips.
- Embedding child lists and values that logically belong to the parent aggregate lifecycle.
- Storing pre-computed, read-optimized document formats directly matching high-frequency API endpoints.
- Accepting calculated redundancy as a deliberate performance strategy, not as bad schema design.
The primary measure of a Cosmos DB document model is not how elegant it looks in a class diagram, but how many high-frequency reads can be satisfied as cheap point reads or highly targeted partition-local queries.
3. Keep point reads separate from queries in the repository API
The cheapest, fastest and most predictable operation in Cosmos DB is retrieving a single document by its id and PartitionKey. Under the hood, this bypasses the query translation engine entirely. In the .NET SDK, this is represented by ReadItemAsync<T>() and it should not be hidden behind a generic query wrapper or LINQ expression.
This distinction is crucial. Modern repository abstractions often collapse all reads into a generic “find by predicate” method, turning clear point reads into SQL queries. This is a massive waste of RUs and engine resources.
In the application architecture:
- Use explicit point reads via
ReadItemAsync<T>()wheneveridand partition key are known. - Reserve query execution (
GetItemQueryIterator<T>()) only for fields, filters and patterns that are not point-lookup compatible. - Expose these two pathways distinctly in data-access contracts so developers do not accidentally query for single items.
Once this physical difference is masked by a generic repository interface, high RU consumption ceases to be an exception and becomes a systemic architecture problem.
4. Treat request units (RUs) as a continuous budget
Performance in Cosmos DB cannot be treated as a task for post-go-live optimization. Request charges, document sizes, index footprints, logical partition sizing and query fan-out are integral parts of the application design.
A few architectural rules of thumb:
- Selectively project properties (
SELECT c.id, c.name) instead of loading complete, large documents when only a few fields are needed. - Avoid open-ended queries without paging limits; they scale poorly as datasets grow.
- Treat cross-partition queries as high-cost actions that require explicit justification.
- Avoid large, frequently updated documents, as write operations charge RUs based on the full document size and indexing complexity.
- Choose continuation-token based pagination over typical skipped-offset architectures for predictable performance.
A development team that only measures RU consumption in production is designing blind. Keeping RU costs visible in diagnostics or logs during local development is the best way to prevent later scalability issues.
5. Keep schema objects explicit and avoid heavy inheritance hierarchies
Deep inheritance hierarchies for Cosmos DB persistence models tend to create fragile, hard-to-maintain code. A lightweight Base Type for core persistence invariants makes sense, but beyond that, flattening models is almost always the cleaner approach.
A robust base document structure should only enforce the actual platform invariants:
id(the unique identifier within the partition)- The partition key value (e.g.,
tenantId) - A discriminator property (e.g.,
typeorschemaVersionfor polymorphism) - An optional optimistic concurrency token (
_etag)
A clean, explicit model structure without deep abstraction layers looks like this:
1using System.Text.Json.Serialization;
2
3public abstract class CosmosDocument
4{
5 [JsonPropertyName("id")]
6 public required string Id { get; init; }
7
8 [JsonPropertyName("tenantId")]
9 public required string TenantId { get; init; }
10
11 [JsonPropertyName("type")]
12 public required string Type { get; init; }
13
14 [JsonPropertyName("_etag")]
15 public string? ETag { get; init; }
16}
17
18public abstract class OrderDocumentBase : CosmosDocument
19{
20 [JsonPropertyName("createdUtc")]
21 public required DateTime CreatedUtc { get; init; }
22}
23
24public sealed class SalesOrderDocument : OrderDocumentBase
25{
26 [JsonPropertyName("status")]
27 public required string Status { get; init; }
28
29 [JsonPropertyName("totalGross")]
30 public decimal TotalGross { get; init; }
31
32 [JsonPropertyName("lines")]
33 public required IReadOnlyList<SalesOrderLineDocument> Lines { get; init; }
34}
35
36public sealed class SalesOrderLineDocument
37{
38 [JsonPropertyName("sku")]
39 public required string Sku { get; init; }
40
41 [JsonPropertyName("quantity")]
42 public int Quantity { get; init; }
43}
The goal is to keep properties transparent and matches simple. Polymorphism inside a single container is absolutely valid, but it should be managed via a distinct, queryable discriminator property rather than a complex class hierarchy that requires advanced, hard-to-debug deserialization setups.
6. Build repositories around explicit business aggregates, not generic entity CRUD
A generic IRepository<T> where T : class is a poor fit for Cosmos DB. It hides point reads, forces developers toward ad-hoc LINQ filters and masks the true cost of database operations.
Instead, shape repositories around specific Domain Aggregates or highly optimized, explicit access patterns:
GetAsync(tenantId, id, cancellationToken)FindOpenAsync(tenantId, cancellationToken)ReplaceAsync(document, etag, cancellationToken)
This keeps the physical reality of partitioning, point actions and query pagination visible straight in the application model.
1using Microsoft.Azure.Cosmos;
2using Microsoft.Azure.Cosmos.Linq;
3using System.Net;
4
5public sealed record SalesOrderListItem(
6 string Id,
7 string Status,
8 DateTime CreatedUtc);
9
10public sealed class SalesOrderRepository
11{
12 private readonly Container _container;
13
14 public SalesOrderRepository(Container container)
15 {
16 _container = container;
17 }
18
19 public async Task<SalesOrderDocument?> GetAsync(
20 string tenantId,
21 string id,
22 CancellationToken cancellationToken)
23 {
24 try
25 {
26 ItemResponse<SalesOrderDocument> response = await _container.ReadItemAsync<SalesOrderDocument>(
27 id,
28 new PartitionKey(tenantId),
29 cancellationToken: cancellationToken);
30
31 return response.Resource;
32 }
33 catch (CosmosException exception) when (exception.StatusCode == HttpStatusCode.NotFound)
34 {
35 return null;
36 }
37 }
38
39 public async Task<IReadOnlyList<SalesOrderListItem>> FindOpenAsync(
40 string tenantId,
41 CancellationToken cancellationToken)
42 {
43 QueryRequestOptions options = new QueryRequestOptions
44 {
45 PartitionKey = new PartitionKey(tenantId),
46 MaxItemCount = 100
47 };
48
49 IQueryable<SalesOrderListItem> query = _container
50 .GetItemLinqQueryable<SalesOrderDocument>(requestOptions: options)
51 .Where(document => document.Type == "sales-order" && document.Status != "Completed")
52 .OrderByDescending(document => document.CreatedUtc)
53 .Select(document => new SalesOrderListItem(
54 document.Id,
55 document.Status,
56 document.CreatedUtc));
57
58 FeedIterator<SalesOrderListItem> iterator = query.ToFeedIterator();
59 List<SalesOrderListItem> items = new List<SalesOrderListItem>();
60
61 while (iterator.HasMoreResults)
62 {
63 FeedResponse<SalesOrderListItem> page = await iterator.ReadNextAsync(cancellationToken);
64 items.AddRange(page);
65 }
66
67 return items;
68 }
69}
This ensures that the difference between an ultra-cheap point lookup and a multi-page cursor search is obvious to any developer reading the consumer code.
7. Use LINQ where simple, but switch to explicit QueryDefinitions for complex searches
The LINQ provider within the .NET SDK is excellent for basic, partition-local filters, ordering and mapping directly to return DTOs. It maintains compiler-checked, safe schema queries.
However, complex LINQ queries can hide translation issues. The generated SQL may have performance traps, or the expression tree may fail to translate, forcing unneeded client-side evaluation.
LINQ is a good default when:
- Filtering is straightforward and the SQL translation is clear.
- Projections map directly to small, clean return records.
- Queries always target a single partition key.
- Code readability is preferred over ultra-fine-grained SQL optimization.
An explicit QueryDefinition is better when:
- Queries must be reviewed block-by-block by database or operations teams.
- Raw SQL features are needed (like specific join patterns or mathematical functions).
- The query performance is highly critical and requires indexing overrides.
- The SQL query needs to be shared across diagnostic tools and application code.
1using Microsoft.Azure.Cosmos;
2
3public sealed record SalesOrderRevenueItem(
4 string Id,
5 DateTime CreatedUtc,
6 decimal TotalGross);
7
8public async Task<IReadOnlyList<SalesOrderRevenueItem>> FindCompletedSinceAsync(
9 Container container,
10 string tenantId,
11 DateTime fromUtc,
12 CancellationToken cancellationToken)
13{
14 QueryDefinition queryDefinition = new QueryDefinition(
15 """
16 SELECT c.id, c.createdUtc, c.totalGross
17 FROM c
18 WHERE c.type = @type
19 AND c.tenantId = @tenantId
20 AND c.status = @status
21 AND c.createdUtc >= @fromUtc
22 ORDER BY c.createdUtc DESC
23 """)
24 .WithParameter("@type", "sales-order")
25 .WithParameter("@tenantId", tenantId)
26 .WithParameter("@status", "Completed")
27 .WithParameter("@fromUtc", fromUtc);
28
29 QueryRequestOptions options = new QueryRequestOptions
30 {
31 PartitionKey = new PartitionKey(tenantId),
32 MaxItemCount = 200
33 };
34
35 FeedIterator<SalesOrderRevenueItem> iterator = container.GetItemQueryIterator<SalesOrderRevenueItem>(
36 queryDefinition,
37 requestOptions: options);
38
39 List<SalesOrderRevenueItem> results = new List<SalesOrderRevenueItem>();
40
41 while (iterator.HasMoreResults)
42 {
43 FeedResponse<SalesOrderRevenueItem> page = await iterator.ReadNextAsync(cancellationToken);
44 results.AddRange(page);
45 }
46
47 return results;
48}
Balanced designs utilize LINQ for simple, repetitive filters and drop down to raw parameterized SQL whenever precise index utilization or clean query execution plans are necessary.
8. Fine-tune indexing policies early instead of relying on default indexing
The default indexing policy indexing every field is useful for onboarding and prototyping, but it causes excessive resource consumption on write-heavy workloads. Indexing unused properties increases the cost of writes and document updates.
Key index optimization strategies:
- Exclude large text fields, JSON arrays and payloads that are never used in query filters.
- Build composite indexes specifically matching queries that filter on multiple fields and order the results.
- Review index metrics regularly to find unused paths that waste IO resources.
- Keep index complexity minimal on high-volume insert queues.
When indexing requirements for two datasets in the same container become highly contradictory, splitting the data into separate containers is almost always the more cost-effective choice.
9. Model optimistic concurrency and transactional boundaries explicitly
Distributed databases require robust conflict handling. The _etag property exists for a reason and ignoring it in multi-client systems leads to accidental data overwrites and dirty states.
Key concurrency rules:
- Include
_etagchecks on document updates to prevent lost-update scenarios. - Limit
TransactionalBatchoperations to fields that share the same physical partition key. - Design idempotent APIs instead of relying on distributed transactions (which partition boundaries do not support).
- Implement compensating actions or event-driven sagas for orchestrating changes across multiple logical boundaries.
Because transactions in Cosmos DB are strictly scoped to a single logical partition key, this boundary dictates how the code handles aggregates, consistency and conflict resolution.
10. Validate performance with realistic data size and logical distribution
Testing code using a hand-crafted dataset with five documents in a single partition key masks performance and scalability bottlenecks. The worst-performing queries and hot partitions are difficult to find when the database size is negligible.
A rigorous, realistic validation loop should include:
- Populating development or staging containers with data sizes that resemble actual production scope.
- Simulating key skew (e.g., matching the distribution of small, medium and heavy customers).
- Reviewing diagnostic headers (
x-ms-request-charge) inside query integration runs. - Testing pagination logic specifically with datasets that span multiple physical partitions.
- Identifying and eliminating cross-partition queries from low-latency application pathways.
While local emulators represent a fantastic asset for checking functional correctness on a local machine, verifying actual operational limits and database cost curves requires testing on cloud-based environments with production-like datasets.
Summary
Azure Cosmos DB is incredibly efficient when the application architecture respects its native engine design: partition-aware queries, cheap point reads, explicit document schemas and precise index profiles.
In C# and .NET applications, the highest engineering ROI comes from a few critical choices: designing containers around operational work bounds, using explicit point reads via ReadItemAsync<T>(), building aggregate-focused repositories and managing indices deliberately. These structural foundation blocks guarantee that the database performance scales cleanly and remains affordable over years of operation.
Related articles

Jul 05, 2026 - 7 min read
How to migrate .NET versioning from x.x.x.x to SemVer
Many long-lived .NET repositories did not start with semantic versioning. They grew up with major.minor.build.revision, often set in …

Jul 03, 2026 - 7 min read
Modern versioning for ASP.NET Core and web apps and how to produce idempotent artifacts
ASP.NET Core applications are usually released often enough that versioning stops being a documentation concern and turns into delivery …

Jun 30, 2026 - 8 min read
Modern versioning for .NET desktop apps and how to produce idempotent artifacts
Desktop software makes versioning visible in a way that web systems often do not. A backend can hide a surprising amount of release …
Let's Work Together
Looking for an experienced Platform Architect or Engineer for your next project? Whether it's cloud migration, platform modernization or building new solutions from scratch - I'm here to help you succeed.
