Cosmos DB with the .NET SDK: Practical Patterns

Azure Cosmos DB is a globally distributed, multi-model database. For .NET developers, the NoSQL API with the Microsoft.Azure.Cosmos SDK (v3) is the primary interface. It's fast, scalable, and has some sharp edges you need to understand before going to production.

Client Setup

The CosmosClient is expensive to create and should be a singleton:

Example.cs
builder.Services.AddSingleton(sp =>
{
    var options = new CosmosClientOptions
    {
        SerializerOptions = new CosmosSerializationOptions
        {
            PropertyNamingPolicy = CosmosPropertyNamingPolicy.CamelCase
        },
        ConnectionMode = ConnectionMode.Direct,
        MaxRetryAttemptsOnRateLimitedRequests = 5,
        MaxRetryWaitTimeOnRateLimitedRequests = TimeSpan.FromSeconds(10)
    };

    return new CosmosClient(
        builder.Configuration["Cosmos:Endpoint"],
        new DefaultAzureCredential(),
        options);
});

Use ConnectionMode.Direct for best performance. The CamelCase serialisation option ensures your .NET PascalCase properties are stored as camelCase in Cosmos — consistent with JSON conventions.

Working with Containers

Get a reference to your container (the Cosmos equivalent of a table):

Example.cs
public class OrderRepository
{
    private readonly Container _container;

    public OrderRepository(CosmosClient client)
    {
        _container = client.GetContainer("my-database", "orders");
    }
}

CRUD Operations

Creating and reading items requires both an ID and a partition key:

Example.cs
public async Task<Order> CreateAsync(Order order)
{
    var response = await _container.CreateItemAsync(
        order,
        new PartitionKey(order.CustomerId));

    return response.Resource;
}

public async Task<Order?> GetAsync(string orderId, string customerId)
{
    try
    {
        var response = await _container.ReadItemAsync<Order>(
            orderId,
            new PartitionKey(customerId));

        return response.Resource;
    }
    catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
    {
        return null;
    }
}

Point reads (by ID + partition key) are the cheapest operation in Cosmos DB — typically 1 RU. Always prefer them over queries when you have both values.

Upserts and Conditional Writes

Use UpsertItemAsync when you don't know whether the item exists:

Example.cs
public async Task SaveAsync(Order order)
{
    await _container.UpsertItemAsync(order, new PartitionKey(order.CustomerId));
}

For optimistic concurrency, use the ETag:

Example.cs
public async Task UpdateAsync(Order order, string etag)
{
    var options = new ItemRequestOptions
    {
        IfMatchEtag = etag
    };

    await _container.ReplaceItemAsync(
        order,
        order.Id,
        new PartitionKey(order.CustomerId),
        options);
}

If the item has been modified since you read it, this throws a CosmosException with status 412 (Precondition Failed).

Querying

Use parameterised queries to avoid SQL injection and improve plan caching:

Example.cs
public async Task<IReadOnlyList<Order>> GetByStatusAsync(string customerId, string status)
{
    var query = new QueryDefinition(
        "SELECT * FROM c WHERE c.customerId = @customerId AND c.status = @status")
        .WithParameter("@customerId", customerId)
        .WithParameter("@status", status);

    var iterator = _container.GetItemQueryIterator<Order>(
        query,
        requestOptions: new QueryRequestOptions
        {
            PartitionKey = new PartitionKey(customerId),
            MaxItemCount = 50
        });

    var results = new List<Order>();

    while (iterator.HasMoreResults)
    {
        var response = await iterator.ReadNextAsync();
        results.AddRange(response);
    }

    return results;
}

Always include the partition key in your query options when possible. Cross-partition queries are significantly more expensive and slower.

LINQ Support

The SDK supports LINQ for type-safe queries:

Example.cs
public async Task<IReadOnlyList<OrderSummary>> GetSummariesAsync(string customerId)
{
    var queryable = _container.GetItemLinqQueryable<Order>(
        requestOptions: new QueryRequestOptions
        {
            PartitionKey = new PartitionKey(customerId)
        });

    var query = queryable
        .Where(o => o.CustomerId == customerId && o.Total > 100)
        .Select(o => new OrderSummary
        {
            Id = o.Id,
            Total = o.Total,
            CreatedAt = o.CreatedAt
        });

    using var iterator = query.ToFeedIterator();
    var results = new List<OrderSummary>();

    while (iterator.HasMoreResults)
    {
        var response = await iterator.ReadNextAsync();
        results.AddRange(response);
    }

    return results;
}

LINQ is convenient but be aware that not all expressions translate cleanly to Cosmos SQL. Check the generated query with query.ToQueryDefinition() if performance is unexpected.

Partition Key Design

The partition key is the single most important design decision in Cosmos DB. Get it wrong and you'll hit hot partitions, throttling, and cross-partition queries everywhere.

Good partition keys have:

For the orders example, customerId works well if you primarily query orders by customer. If you also need to query all orders by date across all customers, consider a separate container with a different partition key or use the change feed to maintain a materialised view.

Cost Awareness

Every operation costs request units (RUs). Monitor your RU consumption:

Example.cs
var response = await _container.CreateItemAsync(order, new PartitionKey(order.CustomerId));
Console.WriteLine($"Request charge: {response.RequestCharge} RUs");

Point reads: ~1 RU. Simple queries within a partition: 3-10 RUs. Cross-partition queries: multiply by partition count. Large document writes: proportional to document size.

Cosmos DB is powerful, but it rewards careful modelling. Design your partitions around your access patterns, prefer point reads over queries, and always know what your operations cost.