Getting Started with Azure Cosmos DB in .NET
The Problem
Relational databases are the default choice for most applications, and for good reason — they are well understood, widely supported, and work well for structured data with stable schemas. But they come with trade-offs that become painful at scale:
- Schema changes require migrations that block deployments
- Horizontal scaling requires significant infrastructure effort
- Global replication with low write latency is complex to configure correctly
- Document-shaped data (nested objects, variable fields) requires awkward joins or JSON columns
As soon as an application needs to handle unpredictable traffic spikes, serve users across multiple regions, or store flexible document structures, a relational database forces you to work around its constraints rather than with its strengths.
The Solution
Azure Cosmos DB is a fully managed, globally distributed NoSQL database from Microsoft. It is built from the ground up for elastic scale, low latency, and high availability.
Key characteristics:
- Elastic throughput and storage — scale independently without downtime
- Global distribution — replicate data across any Azure region with a few clicks
- Single-digit millisecond latency — guaranteed at the 99th percentile for reads and writes
- Multiple consistency levels — from strong to eventual, configurable per request
- Schema-free — store JSON documents without a fixed schema
For .NET developers, the Microsoft.Azure.Cosmos SDK provides a first-class async API that integrates cleanly with IHttpClientFactory, dependency injection, and the rest of the ASP.NET Core infrastructure.
Setup
Install the NuGet package:
dotnet add package Microsoft.Azure.Cosmos
Add the connection details to appsettings.json:
{
"CosmosDb": {
"AccountEndpoint": "https://your-account.documents.azure.com:443/",
"DatabaseId": "ShopDb",
"ContainerId": "Orders"
}
}
For local development, use the Azure Cosmos DB Emulator running in Docker:
docker pull mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator
docker run -p 8081:8081 mcr.microsoft.com/cosmosdb/linux/azure-cosmos-emulator
Then update the endpoint to https://localhost:8081 with the well-known emulator key. This gives you full fidelity locally without any cloud costs.
Registering CosmosClient
CosmosClient manages the underlying connection pool and is expensive to create. Register it as a singleton — creating a new instance per request is a common mistake that leads to socket exhaustion and degraded performance:
public sealed class CosmosOptions
{
public const string SectionName = "CosmosDb";
public string AccountEndpoint { get; set; } = string.Empty;
public string DatabaseId { get; set; } = string.Empty;
public string ContainerId { get; set; } = string.Empty;
}
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddOptions<CosmosOptions>()
.Bind(builder.Configuration.GetSection(CosmosOptions.SectionName));
builder.Services.AddSingleton(provider =>
{
var options = provider
.GetRequiredService<IOptions<CosmosOptions>>()
.Value;
return new CosmosClient(options.AccountEndpoint, new DefaultAzureCredential());
});
Using DefaultAzureCredential (from Azure.Identity) means the same code works with a managed identity in Azure and with your personal account locally — no connection strings in source control.
Data Modelling
Cosmos DB stores items as JSON documents inside containers. Every item must have an id field (unique within its partition) and a partition key. Getting the partition key right is the most important decision you will make.
A good partition key:
- Has high cardinality (many unique values) —
userId,orderId,tenantId - Aligns with your most common query patterns (queries that include the partition key avoid cross-partition fan-out)
- Distributes writes evenly — avoid low-cardinality keys like
statusorcountry
Here is a simple order document model:
public sealed class OrderDocument
{
[JsonPropertyName("id")]
public string Id { get; set; } = Guid.NewGuid().ToString();
// This is the partition key — all orders for a customer
// live in the same logical partition
[JsonPropertyName("customerId")]
public string CustomerId { get; set; } = string.Empty;
[JsonPropertyName("status")]
public string Status { get; set; } = "Pending";
[JsonPropertyName("items")]
public List<OrderItem> Items { get; set; } = [];
[JsonPropertyName("totalAmount")]
public decimal TotalAmount { get; set; }
[JsonPropertyName("createdAt")]
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}
public sealed class OrderItem
{
[JsonPropertyName("productId")]
public string ProductId { get; set; } = string.Empty;
[JsonPropertyName("name")]
public string Name { get; set; } = string.Empty;
[JsonPropertyName("quantity")]
public int Quantity { get; set; }
[JsonPropertyName("unitPrice")]
public decimal UnitPrice { get; set; }
}
Notice that OrderItem is embedded directly in the order document. Because order items are always accessed alongside the order itself, embedding avoids a second read and keeps the operation within a single partition. Only extract sub-documents into their own items when they are accessed independently, or when embedding would push an item beyond the 2 MB size limit.
CRUD Operations
Wrap the SDK calls in a repository to keep Cosmos DB concerns out of your business logic:
public sealed class OrderRepository
{
private readonly Container _container;
public OrderRepository(CosmosClient client, IOptions<CosmosOptions> options)
{
var opt = options.Value;
_container = client.GetContainer(opt.DatabaseId, opt.ContainerId);
}
public async Task<OrderDocument> CreateAsync(
OrderDocument order,
CancellationToken cancellationToken = default)
{
var response = await _container.CreateItemAsync(
order,
new PartitionKey(order.CustomerId),
cancellationToken: cancellationToken);
return response.Resource;
}
public async Task<OrderDocument?> GetAsync(
string orderId,
string customerId,
CancellationToken cancellationToken = default)
{
try
{
var response = await _container.ReadItemAsync<OrderDocument>(
orderId,
new PartitionKey(customerId),
cancellationToken: cancellationToken);
return response.Resource;
}
catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
return null;
}
}
public async Task<OrderDocument> UpdateAsync(
OrderDocument order,
CancellationToken cancellationToken = default)
{
var response = await _container.ReplaceItemAsync(
order,
order.Id,
new PartitionKey(order.CustomerId),
cancellationToken: cancellationToken);
return response.Resource;
}
public async Task DeleteAsync(
string orderId,
string customerId,
CancellationToken cancellationToken = default)
{
await _container.DeleteItemAsync<OrderDocument>(
orderId,
new PartitionKey(customerId),
cancellationToken: cancellationToken);
}
}
Always provide the partition key alongside the item id. Point reads (ReadItemAsync) that include the partition key are the fastest and cheapest operation in Cosmos DB — a single RU and a single-digit millisecond response.
Querying Items
For queries across multiple items in the same partition, use GetItemQueryIterator with a parameterized query. Parameterized queries prevent injection and allow the SDK to cache the query plan:
public async Task<List<OrderDocument>> GetByCustomerAsync(
string customerId,
string? status = null,
CancellationToken cancellationToken = default)
{
var queryText = "SELECT * FROM c WHERE c.customerId = @customerId";
var parameters = new List<(string Name, object Value)>
{
("@customerId", customerId)
};
if (status is not null)
{
queryText += " AND c.status = @status";
parameters.Add(("@status", status));
}
var queryDefinition = parameters
.Aggregate(
new QueryDefinition(queryText),
(qd, p) => qd.WithParameter(p.Name, p.Value));
var results = new List<OrderDocument>();
using var iterator = _container.GetItemQueryIterator<OrderDocument>(
queryDefinition,
requestOptions: new QueryRequestOptions
{
// Scope the query to a single partition — avoids cross-partition fan-out
PartitionKey = new PartitionKey(customerId)
});
while (iterator.HasMoreResults)
{
var page = await iterator.ReadNextAsync(cancellationToken);
results.AddRange(page);
}
return results;
}
Always include the partition key in QueryRequestOptions when you know it. Without it, Cosmos DB fans the query out across all partitions, consuming more RUs and increasing latency.
Handling Throttling
When traffic exceeds your provisioned throughput, Cosmos DB returns 429 Too Many Requests. The SDK retries automatically by default, but you should be aware of it and handle it gracefully in latency-sensitive paths:
public async Task<OrderDocument?> GetWithDiagnosticsAsync(
string orderId,
string customerId,
ILogger logger,
CancellationToken cancellationToken = default)
{
try
{
var response = await _container.ReadItemAsync<OrderDocument>(
orderId,
new PartitionKey(customerId),
cancellationToken: cancellationToken);
// Log diagnostics if latency is unexpectedly high
if (response.Diagnostics.GetClientElapsedTime() > TimeSpan.FromSeconds(1))
{
logger.LogWarning(
"Slow Cosmos DB read. Diagnostics: {Diagnostics}",
response.Diagnostics);
}
return response.Resource;
}
catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.TooManyRequests)
{
logger.LogWarning(
"Cosmos DB throttled. Retry after: {RetryAfter}ms",
ex.RetryAfter?.TotalMilliseconds);
// The SDK will retry, but you can surface this to observability tooling
throw;
}
catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
return null;
}
}
The Diagnostics object on every response is your first port of call when investigating latency issues. Log it whenever a response exceeds your SLO threshold.
Registering the Repository
Wire everything up in Program.cs:
builder.Services.AddSingleton(provider =>
{
var options = provider
.GetRequiredService<IOptions<CosmosOptions>>()
.Value;
return new CosmosClient(
options.AccountEndpoint,
new DefaultAzureCredential(),
new CosmosClientOptions
{
SerializerOptions = new CosmosSerializationOptions
{
PropertyNamingPolicy = CosmosPropertyNamingPolicy.CamelCase
}
});
});
builder.Services.AddSingleton<OrderRepository>();
Inject OrderRepository into your services and controllers as usual:
public sealed class OrderService
{
private readonly OrderRepository _repository;
public OrderService(OrderRepository repository)
{
_repository = repository;
}
public async Task<OrderDocument> PlaceOrderAsync(
string customerId,
List<OrderItem> items,
CancellationToken cancellationToken = default)
{
var order = new OrderDocument
{
CustomerId = customerId,
Items = items,
TotalAmount = items.Sum(i => i.Quantity * i.UnitPrice)
};
return await _repository.CreateAsync(order, cancellationToken);
}
}
Summary
Azure Cosmos DB removes the operational burden of scaling, replication, and availability management so you can focus on your application. For .NET developers, the key practices to get right from the start are:
- Reuse
CosmosClientas a singleton — never create it per request - Choose a high-cardinality partition key aligned with your dominant query pattern
- Embed related data that is always read together to minimize round trips
- Always supply the partition key for point reads and scoped queries
- Use parameterized queries to prevent injection and improve plan caching
- Log diagnostics on slow or unexpected responses to guide performance tuning
Getting these fundamentals right early avoids expensive data migrations and performance issues later.