Skip to content

ADR: Cosmos DB, explained for someone who knows SQL Server

ADR: Cosmos DB, explained for someone who knows SQL Server

Status: written 2026-09-08, for 1.0.0.90. The newcomer's record for the second store, in the shape of ADR: Two providers and a SQL project, explained: written for a reader who knows SQL Server cold and this not at all, and organised around the one question the store makes you answer on every design decision, which is what it costs. The decision and the arithmetic are in ADR: A second store on Cosmos DB, and what it costs; the numbers below come from ADR: Measuring both stores and ADR: The partition key, and say so where they do.

In plain words

This page explains the document database behind the second store (Azure Cosmos DB) to a reader who already knows SQL Server, one term at a time, organised around what each operation costs (request units). It ends with which store fits this workload: SQL Server's guarantees are the ones this application actually uses.

What that is worth: a developer who knows SQL Server can read the document store's code and its costs after one page, and the organization can explain its choice of store with measured numbers behind it.

What is actually running

Two containers, one image, and since 1.0.0.94 each opens both stores and serves one by default (the addendum at the end and ADR: One container, both stores). As first built: the first, the site at theyard.stevenstout.biz, keeps its catalogue, its bids and its accounts in Azure SQL Database. The second, now at theyard-cosmos.stevenstout.biz, keeps the same three things in Azure Cosmos DB, in a database called theyard with four containers in it. Both authenticate as the same managed identity; neither has a password or a key anywhere.

The application above the store is the same code. The three ports in Ports.cs are what each store implements, and nothing above them knows which one answered. That is the whole reason the comparison is fair.

The vocabulary, in the order you will meet it

Account. The thing you create in Azure, like a logical server. It has an endpoint and a default consistency level, and its own list of regions. Ours has one region (West US 2) and no keys.

Database. A namespace with a throughput budget. Ours holds 1000 RU/s, shared by every container in it, which is the whole of the free tier's allowance.

Container. The nearest thing to a table, and the comparison stops there. A container has no columns and no schema. It has a partition key path, an indexing policy, and optionally a time to live and unique keys, and that is everything you can say about it. Every document in it can be a different shape.

Document. A row, if a row were a JSON object with no fixed columns. Ours are in snake case, like the API, and you can read one beside the dataset it was seeded from and see the same fields. Every document has an id, and the pair of id and partition key value is its primary key. The system adds _etag, _ts and a few others.

Request unit (RU). The unit everything is priced in, and the one idea in this record worth taking to an interview. A request unit is a normalised measure of the CPU, memory and I/O a request consumed. A point read of a 1 KB document costs 1 RU. Everything else is measured against that: a write of the same document about 5 RU with a minimal index and more with the default one, a query the cost of its index lookup plus the documents it loaded, an aggregate the cost of everything it touched. The service tells you the charge on every response, and this application writes it down beside every operation on the Admin tab (ADR: What the store is actually doing). Throughput is bought in RU per second: 1000 RU/s means the database will do a thousand request units of work a second and answer 429 to the thousand-and-first, and the SDK backs off and retries.

Partition key. The path in every document whose value decides where the document lives. All documents sharing a value are a logical partition; logical partitions are packed onto physical partitions, each of which serves up to 10,000 RU/s and holds 50 GB. A read that names the key and the id is a point read, the cheapest thing there is. A query that names the key runs inside one partition. A query that does not fans out to every physical partition. The key cannot be changed after the container exists, and choosing it is the design (ADR: The partition key).

Indexing policy. By default the service indexes every path of every document, which makes every property queryable and makes every write pay for every index entry. The policy is where you say otherwise. Ours excludes everything on the four parity containers, because nothing queries them by a property, and includes exactly the filter and sort paths on the experiment container. This is the single biggest cost lever on this workload.

Consistency level. Five of them, from strong to eventual, and the account picks a default. Ours is session: a client reads its own writes, in order, which is what a bidder needs, and reads cost 1 RU per KB. Strong and bounded staleness read from a quorum of replicas and cost double. Consistent prefix and eventual cost the same as session and promise less. The application asks for session explicitly in code so the choice is not only in the portal.

Etag. The concurrency token, kept by the store, changed on every write. A replace that sends the etag it read is refused with 412 if the document moved, which is what rowversion does on SQL Server with a different spelling (ADR: The SQL Server backend, addendum).

Point read against query

The distinction the whole store is built around, and the one a SQL Server reader has least reason to have met.

A point read is ReadItem(id, partitionKey). The service goes straight to the partition and the document, and charges about 1 RU per kilobyte. A query, even SELECT * FROM c WHERE c.id = @id, is a query: it is parsed, planned, sent to each physical partition it could touch, answered by the index, and charged for the index lookup and the documents loaded. On this application the difference is a sign-in that costs about 2 RU as two point reads against one that would cost about 3 as one query, and the design of the accounts container exists to make every account operation a point read (ADR: Accounts on a document store).

What a hot partition looks like

A key whose most common value holds half the data. body_style here has five values and SUV is 53 per cent of them, so a container partitioned on it would put 53,000 of the 100,000 experiment documents in one logical partition. Every write during the seed and every read that names SUV lands on the one physical partition that holds it, while the rest sit idle, and no amount of provisioned throughput helps because a logical partition can never use more than the physical partition it sits on. make has fifteen values and the largest is nine per cent, which is why it was chosen (ADR: The partition key).

What it costs to leave the indexing policy at the default

The seed is where it shows. A document is written once and its index entries are written with it, so the default policy, which indexes every path, charges for thirty-odd index entries on every one of the 100,000 experiment documents where the tuned policy charges for nine: 16.07 request units a document against 8.84, 21 minutes against 13, and 8,407 documents refused by throttling that the tuned seed never saw, measured by seeding the same 100,000 documents into two containers that differ only in the policy (ADR: The partition key). The same seven queries then cost within half a request unit of each other on the two containers, which is the whole lesson in one line: an index is paid for on every write and earns only on the queries that use it. On the four parity containers there is no index at all, which is the same decision ADR: The relational store made about SQL Server: they are read whole or by key, so an index would cost every write and earn nothing.

Where the document model won, and where it lost

Won: the bid write and the sign-in are point operations in the same region as the container, two request units for a sign-in and six and a half for a bid, and the numbers in ADR: Measuring both stores are the numbers. The store's cost is visible on every response, which no relational engine offers, and the Admin tab shows it. The account has no keys and needed none. The whole month is $0.00 on the free tier, and would be a cent on serverless.

Lost: uniqueness is a document you write rather than a constraint you declare. There is no cascade, so deleting an account would leave its bids. There is no ORDER BY without an index on the sorted path, and no join at all, so the document has to carry what it needs. A synchronous port cannot be implemented against it, which reached the Application layer (ADR: The ports learn to wait). And the default view of this site, sorted by ending soonest, cannot be pushed down at all, because the schedule is derived from the clock and the id rather than stored, which is a fact about this application's design that the store made visible.

Which one to choose for this workload

This application reads its catalogue once at startup and serves everything from memory, writes one document per accepted bid, and signs people in. Measured from Missouri on 8 September, before the move to the App Service plan, a bid took 135 ms on the document store and 219 on the relational one, a sign-in 221 against 249, and the pages a visitor spends most of their time on are equal to within a few milliseconds because neither store is on their path (ADR: Measuring both stores). The document store's lead is geography: its account is in the container's own region and the SQL server is one region away, because that region refused to create one. Both cost $0.00. On this workload the honest answer is that the store is not the performance question; the in-memory index is, and it was answered in ADR: The search index. Where the two differ is what you are buying. Azure SQL Database buys constraints, joins, a schema in source control as DDL and a query planner that will sort by anything. Cosmos DB buys a cost model you can see, point reads at single-digit milliseconds anywhere in the world if you add regions, and the freedom to change a document's shape without a migration. For a used-vehicle auction whose catalogue fits in memory and whose writes are bids, the relational store is the one whose guarantees this application actually uses, and the document store is the one whose performance it would need at a scale it does not have. That is the sentence for the interview, and the numbers behind it are measured.

Addendum, 2026-09-09: the proof took the networks out

With both stores in one container (ADR: One container, both stores), the container measured itself (ADR: Same performance, proven): the same request, the same process, the same request ring, only the store different, and the round trip to each store measured on its own. On the pages that never touch a store the two are the same. On every path that does, the whole difference is the round trip, 39 ms per statement to the relational server one region away and 2 ms per operation to the document account in the container's own region; take one round trip per operation off each side and the two stores are the same on every row. So the sentence above stands and gets sharper: on this workload the performance question is where the store is. A relational server in the container's region would close the gap the document store currently enjoys, and nothing in the rows says the document store would keep it.

Where it sits

No ring: this is a teaching document that explains Cosmos DB to a reader who knows SQL Server and decides nothing of its own, so the onion and SOLID do not apply to it.

Files

Addendum, 2026-09-09: the comparison, laid out beside this

This record teaches the vocabulary and gives the choice; the evidence for the choice is now laid out in one place, row by row, with Azure SQL Database on the left and Azure Cosmos DB on the right: the same bid at rest in both, the same write in both adapters as live code, the same guarantee given by one engine and built on the other, the measured numbers and the cost, each row naming the record that decided it, and the reading behind each row: the page SQL Server and Cosmos DB, side by side, in its own section at the top of the sidebar (docs/sql-vs-cosmos/SQL-VS-COSMOS.md). It is a page rather than a record because it decides nothing; it shows what the records decided. Read this record first and that page second; its drawing, on a page of its own, is the one to keep open in an interview.

Store

The Yard, home

Welcome to The Yard

Steven Stout’s showcase: a used-vehicle auction on .NET and React, built and run by one engineer, live on Azure, with its decisions written down.

Answering in

unread

0255075100

server time at p95, the usual five minutes of the last day.

Azure this month

unread

the bill, by resource, read from Azure. The Admin tab shows every line.

Since the last roll

unread

 

rolled blue and green, with no downtime.

People who have visited

unread

30 days agotoday

Scanners, my own tools' and my own reads left out.

  •    
  •    
  •    

Tech stack

Read from the build, never typed by hand

What TheYard runs on

What we use, where it runs, the version as built, and the file that proves it.

  • .NET 1014 projects
  • React 19.3TypeScript 7.0
  • Azure6 Bicep files
  • 9GitHub Actions workflows
LayerWhat we useWhere it runsVersionProof
Backendthe API
Backendthe API.NET and ASP.NET CoreApp Service, the API container10.0TheYard.Api.csproj
Entity Framework Core (SQL Server)App Service, talking to Azure SQL10.0.12TheYard.Infrastructure.csproj
Identity with JWT bearer authApp Service, the API container10.0.12TheYard.Api.csproj
OpenAPI with Scalar API docsApp Service, /api docs2.17.13TheYard.Api.csproj
Frontendthe page
Frontendthe pageReactYour browser19.3package.json
TypeScriptYour browser7.0package.json
ViteBuild step, in the container image8.3package.json
Server-side rendering on NodeApp Service, the renderer container24render/Dockerfile
Datatwo stores, one port
Datatwo stores, one portAzure SQL DatabaseAzure, managed SQLEF Core 10.0.12TheYard.Infrastructure.csproj
Azure Cosmos DBAzure, NoSQLSDK 3.63.2TheYard.Infrastructure.Cosmos.csproj
Azureinfrastructure as code
Azureinfrastructure as codeApp Service and Container Registry (Linux container)Azure App Service and ACRlive resourceappservice.bicep
Front Door (written, switched off)Off: the subscription refuses Front Door, so the Netlify edge stands in front todayoffmain.bicep
Application Insights with OpenTelemetryAzure Monitor1.6.0TheYard.Api.csproj
Communication Services emailAzure Communication Services1.1.0TheYard.Api.csproj
Azure Functions, Flex Consumption (clock, snapshot, tracker merger, Sheet mirror, ATS sweep, daily card)func-jobhunt-ss, Flex Consumption.NET 10 isolatedJobHunt.Functions.csproj
Bicep for every resource it deploysDeployed from the repository6 Bicep filesinfra/
Edge
EdgeNetlify edge in front of the siteNetlify edge networklive resourcenetlify.toml
Quality gatesevery push
Quality gatesevery pushxUnitGitHub Actions runners2.9.3TheYard.Tests.csproj
NetArchTest architecture rulesGitHub Actions runners1.4.5TheYard.Tests.csproj
VitestGitHub Actions runners5.0package.json
Playwright with axe accessibility checksGitHub Actions runners, against the built site1.63package.json
oxlint and PrettierGitHub Actions runners1.86 and 3.9package.json
Delivery and AIhow it ships
Delivery and AIhow it shipsGitHub Actions (azure-read, blog-post, ci, deploy-function, deploy-render, deploy, edge-branch, gate, nightly)GitHub9 workflowsworkflows/
Built with Claude Code under the test gateDeveloper machine, then the gateevery pushgate.yml
Claude Code on GitHub Actions runs the job hunt's nightly lanesGitHub Actions runners, on the Azure share4 AM and every 4 hnightly.yml

Generated, not typed. A build step reads the project files, package.json, the Dockerfiles, the Bicep and the workflows; a test fails CI when this table drifts from the repository.

How it is built

How it is run

Who and why