Why Cosmos DB and ASP.NET Core Make Sense Together
You are building a web service that needs to scale without thinking about sharding. Azure Cosmos DB gives you that. ASP.NET Core gives you the runtime. Putting them together is not magic, but it does require a few deliberate steps. Most developers trip up on connection string handling, container provisioning, or the way asynchronous I/O interacts with the Cosmos SDK. This guide skips the marketing fluff and shows you exactly what to wire up, from the NuGet packages to a working CRUD endpoint.
Prerequisites and Project Setup
Before you write a single line of controller code, make sure your environment is ready. You need an Azure subscription, the .NET 8 SDK installed locally, and a running instance of Visual Studio 2022 or the VS Code equivalent. You also need an existing Cosmos DB account in one of the supported regions. If you do not have one, the Azure portal lets you create a free tier account in minutes. Once that exists, grab the primary connection string from the Keys blade. Treat it like a password; do not commit it to source control.
Create a fresh ASP.NET Core Web API project. Open your terminal and run:
dotnet new webapi -n CosmosApi
cd CosmosApi
dotnet add package Microsoft.Azure.Cosmos
dotnet add package Microsoft.Extensions.Configuration.AzureKeyVault
The first package pulls in the official Cosmos DB SDK. The second is optional but recommended if you store secrets in Azure Key Vault rather than plain text. After installation, open Program.cs. This is where the host builder gets configured. You will register the Cosmos client as a singleton so every request reuses the same underlying TCP connection pool. That saves latency and keeps connection counts low.
Wiring the Cosmos Client in Dependency Injection
Dependency injection is the backbone of ASP.NET Core. Instead of calling CosmosClient directly inside your controllers, you inject it through the constructor. This keeps your business logic testable and your startup sequence clean.
Add the following to your Program.cs file. Notice how we read the connection string from configuration, then instantiate the client. We also set a default consistency level to Session, which is a sensible default for most transactional web APIs.
// Program.cs
using Microsoft.Azure.Cosmos;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
var builder = WebApplication.CreateBuilder(args);
// Bind the Cosmos DB connection string from appsettings.json
var connectionString = builder.Configuration.GetConnectionString("CosmosDb");
// Register CosmosClient as a singleton service
builder.Services.AddSingleton<CosmosClient>(serviceProvider =>
new CosmosClient(connectionString)
);
// Optional: register a container client per database/container pair later
builder.Services.AddSingleton<Container>(serviceProvider =>
{
var client = serviceProvider.GetRequiredService<CosmosClient>();
// Replace with your actual database and container names
return client.GetContainer("RetailStore", "Products");
});
var app = builder.Build();
// ... standard middleware pipeline ...
app.MapControllers();
app.Run();
If you prefer to keep the container name out of code, you can read it from configuration as well. The key point is that CosmosClient is expensive to create, so it stays alive for the lifetime of the application. The Container object, by contrast, is lightweight and can be scoped per request if you ever need to switch databases dynamically.
Designing Your Data Model and Container
Cosmos DB is a document database. Your entities become JSON documents stored in a container. Before you write C# classes, decide on a partition key. This is the most critical design choice. It determines how data is distributed across physical partitions and directly impacts query performance and RU consumption. A poor partition key leads to hot partitions and throttling.
For a typical e-commerce product catalog, the SKU or a tenant identifier works well. Let us assume every product belongs to a specific store, and we use the store ID as the partition key. Here is a minimal POCO that maps to a Cosmos document:
public class Product
{
// The partition key must be a top-level property
[JsonProperty("id")]
public string Id { get; set; } = Guid.NewGuid().ToString();
[JsonProperty("storeId")]
public string StoreId { get; set; } = string.Empty;
[JsonProperty("name")]
public string Name { get; set; } = string.Empty;
[JsonProperty("price")]
public decimal Price { get; set; }
[JsonProperty("createdAt")]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
The [JsonProperty] attributes align the C# property names with the JSON document structure. Cosmos DB uses the id field as the unique document identifier, so we generate a GUID up front. If you are using a different identifier strategy, adjust accordingly, but always ensure the partition key is indexed.
Provisioning the container itself is a one-time Azure CLI or portal task. If you automate deployments, use the Azure Cosmos DB REST API or the .NET SDK to create the container with a throughput setting. A starting point of 400 RU/s is reasonable for a small workload. Do not over-provision; you can scale up later without downtime.
Building the Repository Layer
Controllers should stay thin. Extract all database interaction into a repository class. This isolates Cosmos-specific logic, makes unit testing easier with mocked interfaces, and keeps your HTTP endpoints focused on request/response handling. Below is a repository that supports the basic CRUD operations using the Cosmos SDK's asynchronous methods.
// IProductRepository.cs
using Microsoft.Azure.Cosmos;
public interface IProductRepository
{
Task<IEnumerable<Product>> GetAllAsync(string storeId);
Task<Product> GetByIdAsync(string id);
Task CreateAsync(Product product);
Task UpdateAsync(string id, Product product);
Task DeleteAsync(string id);
}
Now implement the interface. The key method to understand is CreateItemAsync, which inserts a new document and returns the generated resource link. For updates, use ReplaceItemAsync, which overwrites the entire document. Partial updates require a different approach, typically fetching the document, modifying the fields in memory, and writing it back. Cosmos DB does not support atomic field-level patching in the same way relational databases support UPDATE ... SET, so plan your mutation strategy accordingly.
// ProductRepository.cs
using Microsoft.Azure.Cosmos;
using Microsoft.Extensions.Options;
public class ProductRepository : IProductRepository
{
private readonly Container _container;
public ProductRepository(Container container)
{
_container = container;
}
public async Task<IEnumerable<Product>> GetAllAsync(string storeId)
{
// Build a query filter to limit results to a specific store
var query = new QueryDefinition("SELECT * FROM c WHERE c.storeId = @storeId")
.WithParameter("@storeId", storeId);
using var feedIterator = _container.GetItemQueryIterator<Product>(query);
var results = new List<Product>();
while (feedIterator.HasMoreResults)
{
var response = await feedIterator.ReadNextAsync();
results.AddRange(response);
}
return results;
}
public async Task<Product> GetByIdAsync(string id)
{
// Point read by document ID; fastest way to retrieve a single item
var response = await _container.ReadItemAsync<Product>(id, new PartitionKey(id));
return response.Resource;
}
public async Task CreateAsync(Product product)
{
// Insert a new document; Cosmos generates the id if omitted
await _container.CreateItemAsync(product, new PartitionKey(product.StoreId));
}
public async Task UpdateAsync(string id, Product product)
{
// Replace the entire document. Ensure the partition key matches.
await _container.ReplaceItemAsync(product, id, new PartitionKey(product.StoreId));
}
public async Task DeleteAsync(string id)
{
// Remove the document by its ID and partition key
await _container.DeleteItemAsync(id, new PartitionKey(id));
}
}
Notice the use of PartitionKey in every operation. The SDK needs this to route the request to the correct physical partition. For GetAllAsync, we filter by storeId because that is our chosen partition key. If you query without a filter on the partition key, Cosmos performs a cross-partition query, which burns more Request Units and can time out under load.
Creating the Web API Controller
With the repository registered in DI, the controller becomes straightforward. Inject IProductRepository, validate the incoming model state, and delegate to the repository. Return appropriate HTTP status codes: 201 for creation, 404 when a document is missing, and 400 for validation failures.
// ProductsController.cs
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
private readonly IProductRepository _repository;
public ProductsController(IProductRepository repository)
{
_repository = repository;
}
[HttpGet("{storeId}")]
public async Task<ActionResult<IEnumerable<Product>>> GetByStore(string storeId)
{
var products = await _repository.GetAllAsync(storeId);
if (!products.Any())
{
return NotFound();
}
return Ok(products);
}
[HttpGet("{id}")]
public async Task<ActionResult<Product>> GetById(string id)
{
var product = await _repository.GetByIdAsync(id);
if (product == null)
{
return NotFound();
}
return Ok(product);
}
[HttpPost]
public async Task<ActionResult<Product>> Create([FromBody] Product product)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
await _repository.CreateAsync(product);
return CreatedAtAction(nameof(GetById), new { id = product.Id }, product);
}
[HttpPut("{id}")]
public async Task<IActionResult> Update(string id, [FromBody] Product product)
{
if (id != product.Id)
{
return BadRequest();
}
var existing = await _repository.GetByIdAsync(id);
if (existing == null)
{
return NotFound();
}
await _repository.UpdateAsync(id, product);
return NoContent();
}
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(string id)
{
await _repository.DeleteAsync(id);
return NoContent();
}
}
The CreatedAtAction helper returns a 201 response with a Location header pointing to the newly created resource. This is REST-compliant and helps clients discover endpoints without hard-coding URLs. For bulk operations, you would typically expose a stored procedure or use the SDK's bulk executor, but that is beyond the scope of this walkthrough.
Handling Throughput and Throttling
Every operation against Cosmos DB consumes Request Units, or RUs. If your container is provisioned with a fixed throughput, you will eventually hit throttling when traffic spikes. The default consistency level also affects RU cost; strong consistency is the most expensive. Session consistency, which we used in the client setup, strikes a balance between performance and data freshness for most web applications.
To monitor RU consumption, enable diagnostic logging in Azure Monitor. Set up an alert on the Total RU/s metric. If you consistently exceed your provisioned limit, increase the throughput in the Azure portal. Alternatively, switch to autoscale, which adjusts RU/s automatically based on demand. Autoscale is cost-effective for workloads with unpredictable traffic patterns.
Another mitigation strategy is to cache frequently accessed data in Redis or in-memory. A read-through cache reduces the number of round trips to Cosmos DB. Just remember to invalidate the cache on writes, or your users will see stale product prices.
Testing the Integration Locally
You do not need to deploy to Azure to verify your code compiles and your queries behave as expected. The Cosmos DB emulator runs as a Docker container on your development machine. It mimics the service API and stores data in a local directory. Point your connection string to the emulator endpoint, and you can run integration tests against a real database without incurring cloud costs.
Start the emulator with Docker:
docker run -d -p 8081:8081 --name cosmos-emulator \
mcr.microsoft.com/cosmosdb/emulator:latest
Then update your appsettings.json to use the emulator URL and a dummy key. Run your API, hit the endpoints with curl or Postman, and inspect the response times. If a query returns empty unexpectedly, check the partition key filter. A missing filter on a large container can return zero results if the query times out before completing.
Common Pitfalls and How to Avoid Them
One frequent mistake is treating Cosmos DB like a relational database. There are no joins, no foreign keys, and no transactions across multiple containers. If you need relational integrity, either denormalize your data or use a different service. Another pitfall is ignoring the 429 status code. When you receive a 429, back off exponentially and retry with the Retry-After header. Blindly retrying immediately will only worsen the throttling.
Index management is also crucial. Every property you query against should have a corresponding index policy. By default, Cosmos creates a range index on all properties, but custom composite indexes can speed up specific query patterns. Review your query workload, then tune the index settings in the Azure portal or via the SDK. Over-indexing wastes RU and storage; under-indexing causes query failures.
Wrapping Up
You now have a working ASP.NET Core Web API backed by Azure Cosmos DB. The architecture separates concerns cleanly: configuration lives in Program.cs, data access sits in a repository, and HTTP handling stays in the controller. From here, you can extend the API with authentication, pagination, or event-driven updates via Azure Functions. Just remember to keep your partition key strategy consistent, monitor your RU consumption, and test against the emulator before pushing changes to production.