This is Chapter 10 of Azure Cosmos DB for .NET Developers. Previous: Chapter 9: Security & Permissions.
Nine chapters. That's a lot of theory.
We've talked about trees and boxes, aggregate roots, hierarchical partition keys, the repository pattern, request units, change feeds, managed identity, and the whole Azure RBAC mess. I've tried to pay all of that off with working code along the way — the Note class in Chapter 3, the Person/Note/Comment sample app in Chapters 5 and 6, snippets from Azure Functions in Chapter 8. But those are deliberately small examples. They're the training-wheels versions. Each one is designed to illustrate exactly one thing and nothing else.
That's useful for learning. It's less useful for real, actual dev.
My goal for the next handful of chapters is to talk you through building an actual app called drinkymcdrinkface.com. Seriously. It's a real site. I registered a domain and everything. It's a cocktail recipe search engine. It's simple enough that it doesn't take a ton of explaining but complex enough that we can work through some actual design choices.
Think of it as a case study: ~5,800 real cocktail recipes, a curated ingredient taxonomy, multi-tenancy, a working auth layer, and a domain model. This is a chapter about design decisions.
Quick Overview of the App
Here's what drinkymcdrinkface.com does. You can search cocktails by name ("manhattan"), by a single ingredient ("rye"), by multiple ingredients with AND semantics ("rye AND vermouth"), or by describing what you're in the mood for in natural language ("something dark and smoky with mezcal") and letting AI take over. We'll get to the AI stuff in the Vector Database chapter.
There's a classification taxonomy about ingredients and that means that the app knows that "Old Overholt Rye" and "Rittenhouse 100" are both rye whiskey. Put another way, searching by ingredient type works even if you don't know the specific brand. That ingredient taxonomy is browsable so you can find what you're in the mood for. Also, if you're looking at a recipe and see an ingredient that you don't recognize, you can click it to view what kind of 'thing' it is.
And if you've created an account, logged-in users can favorite recipes and see their favorites on a dedicated page.
Under the hood: it's 5,800 recipe documents, 2,144 ingredient classification documents, whatever user favorites people have created, and ASP.NET Core Identity users and roles — all sharing one Cosmos container, partitioned intelligently so that we use Cosmos DB efficiently.
Here's the whole domain model in one picture before we start picking it apart:
classDiagram
class TenantItemBase {
+string Id
+string TenantId
+string EntityType
+string Etag
+DateTime Timestamp
}
class CocktailRecipe {
+string Name
+string Description
+string Source
+DateTime EntryDate
+List~Ingredient~ Ingredients
+float[] Embedding
}
class Ingredient {
+double Amount
+string Unit
+string Name
+string StandardizedName
+string Primary
+string Secondary
+string Tertiary
}
class IngredientClassification {
+string StandardizedName
+string Primary
+string Secondary
+string Tertiary
+List~string~ Aliases
+int FrequencyCount
}
class UserFavorite {
+string UserId
+string RecipeId
+string RecipeName
+DateTime FavoritedAt
}
TenantItemBase <|-- CocktailRecipe
TenantItemBase <|-- IngredientClassification
TenantItemBase <|-- UserFavorite
CocktailRecipe "1" *-- "many" Ingredient : contains
Three classes inherit from TenantItemBase — CocktailRecipe, IngredientClassification, UserFavorite. Those are the three aggregate roots, and they're what we'll spend most of this chapter talking about. Ingredient is the lone non-aggregate-root in this picture — it lives inside CocktailRecipe as a nested value object. We'll get to why in a minute.
One Database, One Container
The first design choice is to have one Cosmos DB database and inside it, only one Cosmos DB container.
This is for cost reasons. Remember that you pay for Cosmos typically on a per-container basis. Each container you provision gets its own throughput allocation (or shares an allocation at the database level), and that allocation is what shows up on your bill. More containers = more throughput pools to pay for.
Also, a Cosmos container can happily store all kinds of documents side-by-side in the same container. Documents in the same container don't have to share a schema. The only thing they share is the partition key path, which we'll get to next.
So: one container, multiple kinds of documents in it. That's the starting point.
The Partition Key
At the risk of — well, more like certainty of — repeating myself, with Cosmos DB you've got to get your partition key structure right and you've got to get it right almost from the very beginning. You can change it later...but you'll also have to recreate the container and all its data at the same time. Changing the partition key is a pain.
That said, we're using the /tenantId,/entityType hierarchical partition key for everything. This gives us a clear way to create a multi-tenant system.
You might be thinking — why does a cocktail recipe database need to be multi-tenant? What could possibly be multi-tenant? Who are the "tenants"?
There are two types of tenant in this app: the system tenant and the user tenants.
The system tenant is where shared data lives — the cocktail recipes, the ingredient taxonomy, anything that the whole app reads from. Every user gets to access data under the system tenant. In our case, the system tenant ID is the literal string "COCKTAILS". In other apps, I often use "SYSTEM" but whatever value you choose, I'd suggest making it obvious and create a constant in your application called something like DefaultTenantId.
User tenants are per-user. Every authenticated user gets their own tenant, keyed by their Identity user ID. Per-user data — favorites, eventually anything else a user "owns" — gets stored with TenantId = userId. Users can't see each other's data because their data lives in different logical partitions. Using TenantId as the top of the partition key hierarchy is a key element of NOT accidentally serving up some other user's data. You'd have to mess up and have a cross-partition query in order to even retrieve that data. It's not that it makes it impossible for your app to access that data but it does add that little extra layer to help ensure that you don't do something...uhhh..."sub-optimal".
Anyway. Same container. Different tenant IDs at the first level of the partition key. That's how multi-tenancy works in this app and it's a real-world pattern that works great in Cosmos.
That second level of the partition key (entityType) is the discriminator for types of data. This is what allows us to stuff all kinds of documents into a single Cosmos container without running into partitioning problems. Since we're using hierarchical partition keys, we aim for the tenantId first and then the entityType second.
So a recipe document goes into the partition ["COCKTAILS", "CocktailRecipe"]. A classification document goes into ["COCKTAILS", "IngredientClassification"]. A user favorite goes into ["f8d9a1b2-...", "UserFavorite"] — under the user's own tenant ID. Identity user records go into their own partition, separately again.
Every query in the app specifies both levels of the partition key. That means every query is scoped to one logical partition. No cross-partition scans. No "let me check every partition for matching documents." One query, one partition.
CocktailRecipe and Ingredient
I've said CocktailRecipe is one of the aggregate roots. Why isn't Ingredient one too?
classDiagram
class CocktailRecipe {
+string Name
+string Description
+string Source
+DateTime EntryDate
+List~Ingredient~ Ingredients
+float[] Embedding
}
class Ingredient {
+double Amount
+string Unit
+string Name
+string StandardizedName
+string Primary
+string Secondary
+string Tertiary
}
CocktailRecipe "1" *-- "many" Ingredient : contains
Apply the delete test. If I delete a recipe, should this Ingredient disappear?
The "1.5 oz of Old Overholt Rye" that goes into the "Manhattan" has no meaning outside of that recipe. Therefore, delete it.
Two different recipes that both use 1.5 oz of Old Overholt Rye don't share an Ingredient instance — each recipe gets its own copy.
Let's bounce back into relational database thinking for a second. If we were going to model this in a relational database, you could probably create a "1.5 oz of Old Overholt Rye" record in an ingredient table and then share that to multiple recipes via foreign key...but why would you? It might save some data storage space but then suddenly, you've got an awkward data integrity nightmare where two separate recipes share the same ingredient record. You could normalize that relationship but it would end up adding so much complexity that you'd regret the design decision almost immediately.
Back to Cosmos DB, there's no "look up an Ingredient by ID" use case because there's no Ingredient ID to look up. An Ingredient doesn't have its own identity. It exists only in the context of a recipe.
That makes Ingredient a value object, not an aggregate root. It lives inside the CocktailRecipe document as a nested array entry. When you save the recipe, the ingredients go with it. When you delete the recipe, the ingredients go with it.
Here's what Ingredient actually looks like in code:
public class Ingredient
{
public double Amount { get; set; }
public string Unit { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
// Denormalized snapshot from IngredientClassification
public string StandardizedName { get; set; } = string.Empty;
public string Primary { get; set; } = string.Empty;
public string Secondary { get; set; } = string.Empty;
public string Tertiary { get; set; } = string.Empty;
}
The top three properties are the raw ingredient as it appeared in the source recipe — "1.5 oz Old Overholt Rye." The bottom four are denormalized snapshot data: StandardizedName = "Old Overholt Rye Whiskey", Primary = "Spirit", Secondary = "Whiskey", Tertiary = "Bourbon".
Hold that thought about the snapshot fields — we'll come back to them.
IngredientClassification
OK, so now that I've said all that about CocktailRecipe and Ingredient, why is IngredientClassification a separate aggregate root?
It's doing two jobs. One is a definition job — it's the controlled vocabulary that says "this is what a Bourbon is" and "this is what a Liqueur is." The other is a cleanup job — it's where I reconcile the messy ingredient names in the source data against those canonical definitions. Both of those jobs together are what push the classification out into its own document.
The definition job. Classifications are metadata about ingredients, not instances of ingredients in the context of a recipe. The fact that "Old Overholt Rye is a Bourbon" exists independently of any recipe. Delete a Manhattan, and Old Overholt Rye is still a Bourbon. Delete every cocktail that uses it, and it's still a Bourbon. Classifications have their own lifecycle, edited on a different cadence from recipes. And when they change — say, I decide to split Bourbon into Straight Bourbon and Bonded Bourbon — I want that change to propagate to every recipe that uses Rittenhouse without having to track which recipes reference it.
Apply the delete test: if I delete a recipe, should the "Old Overholt Rye is a Bourbon" fact disappear? No. That's a separate aggregate root. The classification is the source of truth. What the recipe holds is just a snapshot.
The cleanup job. The recipes got scraped out of various sources on the internet, and the raw ingredient names are all over the place. "Old Overholt Rye", "Overholt Rye", "Old Overholt", or just "Overholt" — they're all the same thing. That's where the alias list comes in. Each IngredientClassification document carries a List<string> of aliases, and anything that matches any of those aliases resolves to that classification.
public class IngredientClassification : TenantItemBase
{
public string StandardizedName { get; set; } = string.Empty;
public string Primary { get; set; } = string.Empty;
public string Secondary { get; set; } = string.Empty;
public string Tertiary { get; set; } = string.Empty;
public List<string> Aliases { get; set; } = new();
public int FrequencyCount { get; set; }
}
Aliases are doing two things at once. They're the data cleanup mechanism — every known variant of an ingredient name resolves to the same canonical entry. And they're also the loose link between Recipe.Ingredients (which carries the messy original name from the source recipe) and the canonical IngredientClassification. There's no foreign key between the recipe's ingredient and the classification. There's an alias match. At import time, the classifier looks up the raw ingredient name against every alias in every classification, and when it finds a hit, it copies the classification's metadata into the ingredient's denormalized snapshot fields. (We'll come back to those snapshot fields in a minute.)
Why are aliases a List<string> on the classification document instead of their own entity? Apply the delete test one more time. If I delete the classification for "Old Overholt Rye Whiskey," should the alias "Old Overholt 100" disappear? Yeah, of course. An alias has no meaning without the classification it points to. It's not data that survives on its own. It's not data you'd query for independently. A separate "Alias" aggregate root would be a classic example of over-decomposition. Aliases are a nested value object inside the classification document. Keep it simple.
The classifier logic at runtime just walks the alias list across every classification, builds a case-insensitive dictionary from alias → classification, and uses that dictionary at import and reclassification time. Single-pass, in-memory, rebuilt when the taxonomy changes. Nothing fancy.
Denormalization, and the Guess Behind It
Chapter 6 talked about denormalization as the Cosmos pattern that often makes people coming from relational a little itchy. "Wait, you're storing the ingredient's classification on the ingredient, even though the classification is its own document?" Yes. I know. It violates the RDBMS Prime Directive: "thou shalt not duplicate data, for lo, it invites the discord and debauchery of referential integrity."
But there's a real conversation to have here that I glossed over in Chapter 6, and it's worth having now against a concrete app. There are definitely trade-offs.
The duplication is real. Those four snapshot fields on Ingredient — StandardizedName, Primary, Secondary, Tertiary — appear on every ingredient on every recipe. Roughly 5,800 recipes, each with 3–8 ingredients, comes out to 20,000+ copies of taxonomy data sitting across the container. Every time I classify "Old Overholt Rye" as Spirit → Whiskey → Bourbon, that classification gets copy-pasted into however many recipes use Old Overholt Rye. Multiply across the whole ingredient list and it's a lot of duplicated data.
But then again, at worst, we're probably looking at a handful of megabytes of duplicated data. The current charge per gigabyte in Cosmos is $0.25 USD per month. The cost of that duplicated data is probably well under a penny per month.
There's an obvious alternative. Skip the duplication snapshot data entirely. Load every classification into an in-memory hashtable at app startup, keyed by alias. At recipe render time, look up each ingredient's name against the hashtable and get the classification back. No duplication. No stale snapshots. No reclassify command.
But then that alternative has its own costs and complexity. Now I have a cache. The cache has to be warmed at startup. The cache has to be invalidated when classifications change. If the app runs across multiple processes, every process has its own copy of the cache, and keeping them in sync is its own problem. Cache management probably (once again) doesn't move the needle on our bill but the complexity added to the app isn't free.
So I made a guess. It's one of those things that software architects never like to admit. In the absence of a clear decision, I made a guess. And my guess is that classifications change rarely — probably once every few months as I tune the taxonomy — but recipe rendering happens constantly, and every recipe render needs to display canonical ingredient names. Rare expensive write operations versus frequent cheap reads. That shape says "denormalize." The cost of updating a lot of documents when the taxonomy changes is paid rarely. The cost of reading the canonical name at render time is practically zero, because the document already contains it.
If classifications changed every hour — if users were editing the taxonomy through a UI all the time — the math would flip. I'd probably be on the cache side. But they don't.
I could be wrong. And with all of these types of decisions, I could be totally wrong. But if I'm wrong, the likely fix (ironically) is probably even more duplication — just add the IngredientClassification's Id value to the recipe Ingredient. That'll formalize the connection and give a nice tight way to bulk reclassify all those recipes. Or I move to the cache idea. Or I quit software entirely and go raise goats or something. I dunno. Everything is a trade-off.
Here's the part I'm even less sure about. The denormalized snapshot makes recipe display easy — every recipe document has everything it needs. It does not make taxonomy browsing easy. "Show me all recipes that use Bourbon" is a query that scans the ingredients across many recipe documents. It works. It's not a point read against some indexed structure. If the app eventually gets enough traffic that taxonomy browsing becomes slow, my next move is probably (like I said a moment ago) to add a denormalized copy of IngredientClassification.Id onto each ingredient. That would let the taxonomy pages query by ID instead of by string match, which is more surgical.
But I haven't done that yet because I don't know if it'll actually matter. It's hard to predict performance problems before they happen. For now, the denormalized StandardizedName/Primary/Secondary/Tertiary fields get me the payoff I care about (easy recipe rendering) without the complexity of yet another denormalized field or caching structure. If taxonomy browsing becomes a problem later, I've got options.
That's honestly how most of these decisions go. You guess about the rate-of-change. You pick the design that matches your guess. You build the escape hatch (the reclassify command, or "I could add this field later") so that if you guessed wrong, you can recover...or just quit and go raise goats.
UserFavorite and the User-Tenant Payoff
I wanted to have a feature that allowed users to create a list of favorite cocktail recipes. Maybe they're actual favorites. Maybe they're more of a 'to do' list. But I needed a way to start storing user specific data. Which brings us to the UserFavorite class.
UserFavorite is another of the aggregate roots in the domain model. It's an aggregate root because a favorite has its own lifecycle, isn't owned by a recipe or a taxonomy document, and gets created and deleted independently.
This UserFavorite data is the first obvious element of multi-tenancy in our app. So where in the hierarchical partition key does a favorite go?
Remember the "two-tenant" setup from earlier. Recipes and classifications live under the system tenant — tenantId = "COCKTAILS". Favorites are different. Favorites are user data. Each user's favorites belong to that user. So UserFavorite documents set TenantId = userId. Not "COCKTAILS". The Identity user's ID goes directly into the hierarchical partition key.
The hierarchical partition key for a favorite is ["f8d9a1b2-...", "UserFavorite"], not ["COCKTAILS", "UserFavorite"].
Why does that matter? Because the question "what does this user need to see?" is a per-user question, not a global question. The "My Favorites" page loads my favorites only — not everyone's favorites simply filtered to mine. That filter-to-mine pattern is an expensive one. It cross-partitions, scans all favorites, and discards 99.99% of them just to show a user his or her data. The per-user partition pattern is a much more efficient approach. It reads the partition for exactly one user, reads the data, and returns it. One partition, one query.
Same container as the recipes and classifications. Different tenant. Different scope of query. This is the user-tenant half of the multi-tenancy pattern showing up in code.
A Little Added Safety
This multi-tenant partition key approach also helps out in another way that has nothing to do with application performance. It gives you a layer of defense against accidentally serving up the wrong data to a user. If User A saw User B's cocktail recipe favorites, it's probably not the end of world. Not great. Definitely a bug. But not a crisis. On the other hand, if this were something more sensitive like a medical record management system or a banking app, there'd be Big Problems™ to deal with.
There's a denormalization beat here too:
public class UserFavorite : TenantItemBase
{
public string UserId { get; set; } = string.Empty;
public string RecipeId { get; set; } = string.Empty;
public string RecipeName { get; set; } = string.Empty; // denormalized
public DateTime FavoritedAt { get; set; }
}
RecipeName is a snapshot of the recipe's name at the time the favorite was created. The My Favorites page renders entirely from UserFavorite documents. It never goes back to the CocktailRecipe entity type for lookups. Single-partition query, zero cross-entity reads, full page rendered.
If a recipe gets renamed later, the favorite keeps showing the old name until the user re-favorites it. That's fine. The trade is "slightly stale favorite names" in exchange for "My Favorites loads in one partition read."
Another opportunity for denormalization for UserFavorites would be to take a snapshot of the ingredients. This would allow us to fetch all the user's favorites and display them along with the ingredients. That might be a good feature for v2.
Auth: Identity in the Same Container
If we're going to store favorites on a per-user basis, we're going to need to know who the user is. That means adding authentication and authorization to the app. Since I'm deploying drinkymcdrinkface.com to an Azure App Service, I've got about a half-zillion options for how to get authentication up and running. Once again, there are trade-offs but the easiest option in this case is using ASP.NET Core Identity.
By the way, if you'd like an overview of ASP.NET Core Identity, I've got an overview video up on YouTube.
The cocktail app has user authentication — registration, login, passkey support, an admin dashboard. That's all handled by ASP.NET Core Identity, backed by Benday.Identity.CosmosDb.UI, a NuGet package I wrote that plugs Identity's stores into Cosmos DB and ships a prebuilt set of Razor Pages for the UI.
Registration looks like this in Program.cs:
builder.Services.AddCosmosIdentityWithUI(cosmosConfig);
One line. That's the whole auth wiring. Under the hood, AddCosmosIdentityWithUI does all the Identity plumbing — stores, schemes, password hashers, the login and registration pages, the passkey pages, the admin dashboard. All of it configured against the same CosmosConfig the rest of the app uses.
"The same CosmosConfig." Read that again. ASP.NET Core Identity has a bunch of data that it needs to store in whatever persistence option you use — SQL Server, Cosmos DB, etc. That means that the Identity user documents, role documents, and claim documents are all (by default) going into the same Cosmos container as the recipes, classifications, and favorites. One container. Different entity types. Same hierarchical partition key scheme.
My identity library is built on top of the Benday.CosmosDb library that we've been talking about in this book. It happily uses the same /tenantId,/entityType partition key config that we've been using so far. The EntityType discriminator is what makes this work. Cosmos doesn't care that an IdentityUser and a CocktailRecipe are different shapes. The partition routing is what matters, and the partition routing is happy to have different shapes in different partitions.
First reaction from most people: that's insane. Authentication data and application data in one container? Aren't those supposed to live in different databases?
In a relational world, yah...maybe. It could be a separate database in SQL Server or maybe a separate schema namespace to keep it separated. But in Cosmos, that argument mostly evaporates. There's no schema at the container level. Documents are JSON. Partition routing is per-document. A well-designed hierarchical partition key keeps an Identity query from ever touching cocktail data and vice versa.
The practical upside is significant. One container to provision. One RBAC surface to configure. One throughput pool to manage. No "users database" and "app database" separation that would require two sets of Azure resources, two connection strings, two sets of permissions, and two places where things could go wrong.
This is exactly the kind of architectural simplification the aggregate-root-and-hierarchical-partition-key pattern is supposed to enable. It's counterintuitive the first time you see it. Then you stop worrying about it and it's just how you build apps.
Five Entity Types, One Container, One Running System
Let's recap what's now sitting in the container:
| Entity Type | Tenant ID | Count | Partition shape |
|---|---|---|---|
CocktailRecipe |
"COCKTAILS" |
~5,800 | Single logical partition |
IngredientClassification |
"COCKTAILS" |
~2,144 | Single logical partition |
UserFavorite |
user's Identity ID | N per user | One partition per user |
IdentityUser |
(library-managed) | N users | (library-managed) |
IdentityRole |
(library-managed) | Handful | (library-managed) |
Five entity types. One container. Zero cross-partition queries at runtime when the app is used normally. The recipe-search page reads from one partition. The My Favorites page reads from one partition. The taxonomy browser reads from one partition. Login checks read from one partition. Every page, one partition.
This isn't theoretical. I'll show you the RU charges from a real session against the deployed database in Chapter 11, where we get into the query layer. The short version: the most expensive query in the app is about 250 RUs, and that's the natural-language vector search that has to scan a lot of document content. The cheapest queries — point reads like "is this recipe favorited?" — are 1 RU. An entire typical user session, clicking around the app for a few minutes, runs on less RU than a single inefficient query would burn in a relational app doing the same work.
The container is partitioned well because the domain was modeled well. The aggregate roots are in the right places. The denormalization is in the right places. The multi-tenancy lives at the right level of the partition key. The queries that fall out of that design stay inside a single partition because the design was built so they could.
None of this is magic. It's the direct, mechanical consequence of applying aggregate-root thinking to document design, and then laying the documents into a hierarchical partition key that matches the access patterns of the app.
If you finish this chapter thinking "okay, I could do this for my domain" — that's the win. Pick up a pen. Write down your domain model. Ask "if I delete this, should that disappear?" Draw the aggregate-root boundaries. Decide what goes in a tenant and what goes in a per-user partition. Denormalize the snapshots that your queries need. Put it all in one container unless you have a specific reason not to.
That's the pattern. There might be trade-offs. Try to think them through and plan your architectural escape route. But also, don't over build and over plan. Try some stuff, run some profiling on your app, and see what actually works.
Next up: Chapter 11, where we finally dig into the query layer. LINQ vs. raw Cosmos SQL, when to reach for which, how Benday.CosmosDb instruments the ones that go through it, and the category of bug that happens when LINQ doesn't go through it. I'll also explain why I built ICosmosQueryLogSink into the library — because once you have a real production app with a hundred different queries, pulling query diagnostics out of the general log stream stops being a viable strategy. Chapter 11 is where we solve that.
The cocktail app is the running example from here to Chapter 13. Same container. Same partition layout. Same denormalized snapshots. Different capabilities layered on top.