Benday.CosmosDb — The Foundation

April 09, 2026
Benday.CosmosDb — The Foundation

This is Chapter 5 of Azure Cosmos DB for .NET Developers. Previous: Chapter 4: Running Your Code — The Emulator, System Properties, and SDK Rough Edges.

At the end of the last chapter, we took stock. We'd written working code against the Cosmos DB SDK, and it was... fine. It worked. But we'd also built up a friction inventory — a list of things that felt harder than they should be.

Here's what was on that list:

  1. Manual partition key construction. Every read, every write, every query — you're hand-building PartitionKey objects. Forget one and you get a cross-partition query instead of a point read. The SDK doesn't warn you.
  2. Opt-in concurrency. ETags exist, but you have to remember to capture them, remember to pass them back, and remember to catch the specific exception. Forget any step and you silently lose data.
  3. Query ceremony. Getting a simple list of documents requires GetItemLinqQueryable, ToFeedIterator, a while loop over ReadNextAsync, and manual result aggregation. It's not hard. It's just a lot of plumbing for what should be a one-liner.
  4. Silent cross-partition queries. The same query with and without a partition key returns the same results. One costs 3 RUs. The other costs 30. The SDK says nothing.
  5. No aggregate root awareness. The SDK doesn't know what your documents represent. It doesn't know that a Note and a Person are different species of tree in the same container. It doesn't know that TenantId is your partition key. Every bit of domain knowledge lives in your head, not in the code.

The raw SDK isn't bad. It's just — well — raw. It gives you mechanisms without guardrails.

This chapter is about adding the guardrails and making these problems fade into the background.

Install a NuGet Package: Benday.CosmosDb

When I first started writing apps that use Cosmos DB, I bumped into all those issues above and started trying to find ways to make them less painful. I wanted nice, standardized things that developers could use that would simply do the right thing. Over time that turned into my Benday.CosmosDb NuGet package. Before we get into the details, let's get the library installed.

dotnet add package Benday.CosmosDb

The library is open source, MIT licensed, and available on NuGet. The source code is on GitHub at benday-inc/Benday.CosmosDb. (Share and enjoy.)

There's a complete sample application in that repository, too.

The Tree That Stores Itself

Let's start with the biggest item on the friction inventory: no aggregate root awareness.

In Chapter 1, we talked about how domain models are trees — hierarchical, branching structures with identity and behavior. In Chapter 2, we talked about how Cosmos DB stores JSON documents, and JSON documents are trees. The whole promise of a document database is that the tree goes straight into storage without being chopped into boxes first.

But when we wrote code with the raw SDK in Chapter 4, that promise didn't quite land. We had a Note class, sure. But it was just a POCO (Plain Old C# Object) with some properties. It didn't know what its partition key was. It didn't carry its own concurrency token. It didn't know what kind of document it was in a shared container. All of that knowledge was spread across our application code — in the PartitionKey constructors we built by hand, in the ItemRequestOptions we remembered (or forgot) to set, in the query filters we wrote to separate notes from other document types.

The Benday.CosmosDb.DomainModels.TenantItemBase class fixes this. It's the base class for your domain models in the Benday.CosmosDb library, and it carries everything a document needs to store itself correctly.

Here's what a Note class looks like when it inherits from TenantItemBase:

using Benday.CosmosDb.DomainModels;

public class Note : TenantItemBase
{
    public string Title { get; set; } = string.Empty;
    public string Body { get; set; } = string.Empty;
    public DateTime CreatedDate { get; set; } = DateTime.UtcNow;
    public List<string> Tags { get; set; } = new();
}

That's it. That's your domain model. Just your business properties. Everything else — identity, partition key, concurrency, type discrimination — comes from the base class.

Let's look at what TenantItemBase gives you.

Id

Every document needs a unique identifier. TenantItemBase provides an Id property that auto-generates a GUID string. You never have to think about it. If you create a new Note and save it, it already has an Id. If you want to set your own, you can — but you don't have to.

TenantId and Why Multi-Tenancy Matters

This is the first level of your partition key, and it's worth pausing to talk about why it's called TenantId and not something more generic.

Most real-world applications are multi-tenant. That just means more than one customer, organization, or user's data lives in the same system. A SaaS product where each company has their own data? Multi-tenant. A note-taking app where each user has their own notes? Multi-tenant. An internal enterprise app where different departments have separate data? Multi-tenant.

The "tenant" is whoever owns the data. Sometimes it's a company. Sometimes it's a user. Sometimes it's a team or a department. Sometimes it's data that's owned by the application itself. The specific meaning depends on your application — but the pattern is the same: you need a boundary that keeps one tenant's data separate from another's.

Cosmos DB is unusually good at this. Here's why.

The partition key is Cosmos DB's fundamental unit of performance isolation. All documents with the same partition key live together, get queried together, and scale together. When you make TenantId your partition key, you're telling Cosmos: "all of Tenant A's data lives here, all of Tenant B's data lives there." Each tenant gets their own partition, which means their queries don't compete with each other, their data doesn't intermingle, and their performance doesn't degrade as other tenants grow.

In a relational database, you'd typically implement multi-tenancy with a WHERE TenantId = @tenantId clause on every query, and you'd rely on discipline (or a query filter) to make sure nobody forgets it. In Cosmos, the partition key enforces it at the storage layer. If your code asks for Tenant A's partition, it physically cannot see Tenant B's documents. The isolation isn't a filter — it's structural.

This is why the library's recommended partition key path is /tenantId,/entityType — a hierarchical partition key with two levels. The first level, TenantId, gives you tenant isolation. The second level, EntityType, gives you type filtering within the tenant. Together, they mean that most of your queries — "give me all Notes for this tenant," "find this Person in this tenant" — hit exactly one partition. No fan-out. No cross-partition costs. Just a direct read from the right place.

And because TenantId lives on the domain model, it's not something you construct separately when you call the SDK. The document knows where it belongs.

EntityType

This is the second level of your partition key, and it's one of my favorite design decisions in the library. EntityType is a read-only property that returns the name of your concrete class. For our Note class, EntityType automatically returns "Note".

Why does this matter? Because in Cosmos DB, you'll often store multiple types of documents in the same container. Notes, Persons, Comments — they all live together. EntityType is how you tell them apart. It's what I call the "species of tree" identifier — your container is a forest, and EntityType tells you which species you're looking at.

And because it's computed from the class name via GetEntityTypeName(), you can't forget to set it. You can't accidentally set it to the wrong value. It's just correct, every time.

Etag

The ETag property carries the concurrency token from Cosmos DB. When you read a document, the ETag comes back with it. When you save, the library automatically passes it back to Cosmos as an IfMatchEtag condition. If someone else modified the document since you read it, the save throws an OptimisticConcurrencyException.

Compare that to the raw SDK, where you had to capture the ETag from the response, store it somewhere, and remember to pass it in ItemRequestOptions. Here, it's just a property on the object. It round-trips automatically.

The Partition Key Builds Itself

In the raw SDK, every operation required you to construct a PartitionKey:

// Raw SDK — you build this every time
var partitionKey = new PartitionKey(note.TenantId);

// Or with hierarchical keys:
var partitionKey = new PartitionKeyBuilder()
    .Add(note.TenantId)
    .Add("Note")
    .Build();

With the library, the repository constructs the partition key from the item's TenantId and EntityType properties. You never touch PartitionKeyBuilder. The document knows its own partition key because that information is part of its identity.

The default partition key path is /tenantId,/entityType — a hierarchical partition key with two levels. The library builds the correct PartitionKey object automatically for every read, write, and query.

This is worth pausing on. In the raw SDK, the partition key was something you built for each operation. With the library, the partition key is something the document knows about itself. That's not just fewer lines of code. It's a fundamentally different relationship between your domain model and your storage — and it means an entire category of mistakes (wrong partition key, forgotten partition key, mismatched partition key) simply can't happen.

The Repository: Where the Friction Disappears

The domain model carries the data. The repository handles the operations. Let's look at what changes when you use the library's built-in repository instead of raw Container calls.

You Don't Have to Write a Repository Class

With the library's DI registration helper (which we'll cover in detail shortly), you can get a fully functional repository without writing a single repository class:

helper.RegisterRepository<Note>();

That one line registers CosmosTenantItemRepository<Note> as the implementation for ITenantItemRepository<Note>. You inject ITenantItemRepository<Note> into your code, and you've got save, get by id, get all, delete, paged queries, and bulk operations — all partition-key-aware, all with ETag concurrency, all with RU logging. No custom class. No boilerplate coding required.

In my HonestCheetah application, several entity types use exactly this pattern:

CosmosHelper.RegisterRepository<IssueData>();
CosmosHelper.RegisterRepository<GitHubAppInstallationData>();
CosmosHelper.RegisterRepository<GitHubAppTokenData>();

Three entity types, three lines, done. Each gets a fully functional repository through ITenantItemRepository<T>. They don't have any need for custom queries right now but if they do, I've got a clean spot in my code where I can drop it in.

When You Need Custom Queries

Eventually you'll need queries that go beyond GetAllAsync and GetByIdAsync. Maybe you need to find notes by tag, or get the most recent notes across all tenants for an admin dashboard. That's when you create a custom repository class:

using Benday.CosmosDb.Repositories;
using Microsoft.Azure.Cosmos;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;

public class NoteRepository : CosmosTenantItemRepository<Note>
{
    public NoteRepository(
        IOptions<CosmosRepositoryOptions<Note>> options,
        CosmosClient client,
        ILoggerFactory loggerFactory) : base(options, client, loggerFactory) { }

    public async Task<List<Note>> GetByTagAsync(string tenantId, string tag)
    {
        var context = await GetQueryContextAsync(tenantId);
        var query = context.Queryable.Where(n => n.Tags.Contains(tag));
        return await GetResultsAsync(query, "GetByTagAsync", context.PartitionKey);
    }
}

You still get all the base class operations for free. You're just adding the queries that are specific to your domain. And here's what's nice about the custom query: GetQueryContextAsync(tenantId) gives you a LINQ queryable that's already scoped to the right partition. GetResultsAsync handles the feed iterator loop, logs the RU cost, and checks for cross-partition queries. You write the Where clause. The library handles the plumbing.

The DI registration for a custom repository uses the three-type-parameter overload:

// Note = domain model class 
// INoteRepository = the repository interface
// NoteRepository = the repository implementation
helper.RegisterRepository<Note, INoteRepository, NoteRepository>();

In HonestCheetah, you can see both patterns side by side — simple registrations for entities that only need basic CRUD, and custom repositories for entities that need specialized queries:

// Simple — no custom class needed
CosmosHelper.RegisterRepository<IssueData>();
CosmosHelper.RegisterRepository<LinkedGitHubAccount>();

// Custom — entity needs specialized queries
CosmosHelper.RegisterRepository<Project, IProjectRepository, CosmosDbProjectRepository>();
CosmosHelper.RegisterRepository<UserData, IUserRepository, CosmosDbUserRepository>();

Start simple. Add a custom repository when you need one. The library doesn't make you decide up front.

What You Get: The Repository Operations

Whether you're using the built-in ITenantItemRepository<Note> or a custom repository class, the operations are the same. In the examples below, noteRepository could be either one — the interface gives you everything shown here.

Save

In the raw SDK:

// Raw SDK save with concurrency
var response = await container.UpsertItemAsync(
    note,
    new PartitionKey(note.TenantId),
    new ItemRequestOptions { IfMatchEtag = etag }
);
// Don't forget to capture the new ETag
etag = response.ETag;

With the library:

await noteRepository.SaveAsync(note);

One line. The repository builds the partition key from note.TenantId and note.EntityType. It passes note.Etag as the IfMatchEtag. After the save, it updates note.Etag with the new value from the response. It also syncs the TimestampUnixStyle property and logs the RU cost.

If another user modified the document since you read it, you get an OptimisticConcurrencyException. Not a raw CosmosException with a 412 status code that you have to know to catch — a named exception that tells you exactly what happened.

Get by Id

In the raw SDK, you'd typically do a point read:

// Raw SDK point read
var response = await container.ReadItemAsync<Note>(
    noteId,
    new PartitionKey(tenantId)
);
var note = response.Resource;

With the library:

var note = await noteRepository.GetByIdAsync(tenantId, noteId);

The repository does a point read with the correct partition key. One RU, same as the raw SDK. But you didn't have to construct the PartitionKey yourself.

Get All (for a Tenant)

var notes = await noteRepository.GetAllAsync(tenantId);

This queries within the partition — efficient, partition-scoped, ordered by timestamp descending. The query is automatically filtered to documents matching the Note entity type.

Delete

await noteRepository.DeleteAsync(note);

Note that the tenant-scoped delete takes the item, not just the id. The repository extracts the partition key from the item itself, so it can do a point delete. Efficient, direct, no cross-partition scan.

Paged Queries

For large result sets:

var page = await noteRepository.GetPagedAsync(
    tenantId, 
    pageSize: 25
);

// page.Items — the results
// page.ContinuationToken — pass this back for the next page
// page.HasMoreResults — are there more pages?
// page.TotalRequestCharge — how much did this cost?

Continuation-token-based pagination, partition-scoped, with RU tracking built in.

Concurrency You Can't Forget

I want to come back to the concurrency story because it's a good example of how the library's design philosophy works.

With the raw SDK, concurrency protection is opt-in. You can use ETags, but nothing forces you to. The default behavior — upsert without an ETag check — silently overwrites whatever's there. In Chapter 4, I showed you the code to do it right: capture the ETag, pass it in ItemRequestOptions, catch the CosmosException with status code 412.

The library inverts this. Concurrency protection is the default because in a production application you're going to need it. Every SaveAsync call passes the ETag. If this is a new document (empty ETag), the upsert works as expected. If it's an existing document, the ETag check is automatic. You'd have to go out of your way to disable it.

This matters because the most dangerous bugs are the ones that don't throw exceptions. Silently overwriting another user's changes doesn't throw. The save succeeds. The data is wrong. Nobody knows until someone notices the problem, and by then you've lost the information you need to diagnose it.

The library makes the safe thing the easy thing.

The Library Yells When You Do Something Expensive

Remember the silent cross-partition problem from Chapter 4? Same query, same results, wildly different cost — and the SDK says nothing?

The library fixes this in two ways.

Cross-Partition Detection

Every time the library executes a query, it inspects the Cosmos DB diagnostics for signs of a cross-partition operation. If it detects one, it logs a warning to ILogger:

*** WARNING ***: Cross-partition query detected

It doesn't throw an exception — sometimes cross-partition queries are intentional. But it makes sure you know. The raw SDK gives you silence. The library gives you a heads-up.

RU Logging

Every operation — point reads, upserts, queries — logs its Request Unit cost. You'll see entries like:

Request Charge (SaveAsync): 6.29
Request Charge (GetByIdAsync): 1.0
Total request charge (GetAllAsync): 12.47

This isn't just nice-to-have. It's how you catch performance problems before they become billing problems. When you see a query that should cost 3 RUs costing 30, you know something's wrong. When you see costs creeping up over time, you can investigate before your Azure bill surprises you.

The repository also exposes RU information in PagedResults<T>.TotalRequestCharge, so you can surface cost data to your own monitoring.

The [Obsolete] Markers: Guardrails Against the Wrong Pattern

There's one more thing the library does that I want to call out, and it came from a real production incident (that I introduced myself).

The library has two layers of methods. The base CosmosRepository<T> class has methods like GetByIdAsync(string id) and DeleteAsync(string id) that take just an id — no tenant, no partition key. These work, but they do cross-partition queries to find the item first. They're expensive.

The CosmosTenantItemRepository<T> class adds tenant-scoped versions: GetByIdAsync(string tenantId, string id) and DeleteAsync(T item). These use partition keys directly. They're efficient.

A few months ago, I found some AI-generated code that was calling the non-partition-key version of GetByIdAsync. The code worked. The tests passed. But performance in production was bizarre — reads that should have been 1 RU were costing 15-20 RUs. The AI had picked the simpler method signature because it looked right, and nothing in the code told it otherwise.

So I added [Obsolete] attributes to the non-partition-key methods. Now the compiler itself tells you — and your AI coding assistant — to use the partition-aware versions. The cross-partition methods still exist for cases where you genuinely need them, but you have to consciously acknowledge the warning by adding a #pragma warning disable directive.

Guardrails aren't just for humans anymore.

Wiring It Up

So far we've looked at the domain model and the repository. Let's talk about how you actually configure all of this in an application.

Configuration using the Emulator

If you're running locally against the Cosmos DB emulator, all you need in your appsettings.json is this:

{
  "CosmosConfiguration": {
    "UseEmulator": true
  }
}

This sets a whole bunch of defaults for you for the database name, container name, partition key, etc. The idea is to get you up and running quickly against the emulator. If you want to start customizing some of the values like database name or container name, just add those values into the config along with UseEmulator.

{
  "CosmosConfiguration": {
    "UseEmulator": true,
    "DatabaseName": "NotesDb",
    "ContainerName": "NotesContainer"
  }
}

Loading the config is one line:

var cosmosConfig = builder.Configuration.GetCosmosConfig();

Configuration in Detail

So that's the simple, emulator-focused config stuff. What about moving towards production? What else can you configure?

{
  "CosmosConfiguration": {
    "Endpoint": "https://localhost:8081",
    "AccountKey": "your-key-here",
    "DatabaseName": "NotesDb",
    "ContainerName": "NotesContainer",
    "PartitionKey": "/tenantId,/entityType",
    "CreateStructures": true,
    "DatabaseThroughput": 400,
    "UseGatewayMode": true,
    "UseHierarchicalPartitionKey": true,
    "AllowBulkExecution": true,
    "UseDefaultAzureCredential": false
  }
}

A few things to notice here. CreateStructures: true tells the library to create the database and container if they don't exist — handy for development against the emulator. UseGatewayMode: true is required as of April 2026 for the vNext emulator (you'll typically set this to false in production). And UseHierarchicalPartitionKey: true enables the two-level partition key path we've been talking about.

Let's say our application is an ASP.NET app running in an Azure App Service. The best practice for security reasons is to use Azure managed identity for authentication. You'd set the App Service to have managed identity turned on and then you'll grant permissions to that identity in Cosmos DB. (I just yada yada'd past a ton of detail. More on permissions later.)

For a production application, the cosmos config in my appsettings.json typically looks something like this:

{
  "CosmosConfiguration": 
  {
      "Endpoint": "https://mycosmosdb.documents.azure.com:443/",
      "DatabaseName": "MyDatabase",
      "ContainerName": "MyContainer",
      "CreateStructures": false,
      "UseDefaultAzureCredential": true
  }
}

What's nice about this is that it doesn't have any secrets stored in it (like AccountKey) so I can stuff this into Git if I want to.

DI Registration with CosmosRegistrationHelper

You've already seen RegisterRepository<Note>() in the examples above. Here's the full picture of how it fits into application startup:

var builder = WebApplication.CreateBuilder(args);

// GetCosmosConfig() is an extension method that reads the config
var cosmosConfig = builder.Configuration.GetCosmosConfig();

// pass the DI services collection and the cosmos config to the helper
var helper = new CosmosRegistrationHelper(builder.Services, cosmosConfig);

// Simple entities — no custom repository class needed
helper.RegisterRepository<Note>();
helper.RegisterRepository<Tag>();

// Entity with a custom repository
helper.RegisterRepository<Project, IProjectRepository, ProjectRepository>();

// Entity that needs a service layer, too
helper.RegisterRepositoryAndService<Note>();

When you create the CosmosRegistrationHelper, it configures a singleton CosmosClient from your CosmosConfig. Each RegisterRepository call configures the CosmosRepositoryOptions<T> for that entity type and registers the repository as transient.

RegisterRepositoryAndService<Note>() does everything RegisterRepository does plus registers TenantItemService<Note> as the implementation for ITenantItemService<Note>. The service layer is intentionally thin — it passes through to the repository. We'll talk about why you'd want that layer in a later chapter when we get into application architecture.

Per-Entity Container Overrides

Here's something that comes up in real applications: you might want different entity types in different containers. Maybe your main entities live in the default container but your webhook log entries need their own container with a completely different partition key.

The three-type-parameter registration method accepts optional overrides:

helper.RegisterRepository<
    WebhookLogEntry,
    IWebhookLogRepository, WebhookLogRepository>(
        containerName: "raw-webhooks", 
        partitionKey: "/partitionKey"
    );

Any parameter you don't specify falls back to the defaults from your CosmosConfig. This means you can have one configuration section for the common case and override only what's different per entity type.

Non-ASP.NET Scenarios: CosmosConfigBuilder

Not building a web app? The CosmosConfigBuilder provides a fluent API for building configuration in console apps, Azure Functions, or test projects:

var config = new CosmosConfigBuilder()
    .ForEmulator()                              // preset for local emulator
    .WithDatabase("NotesDb")
    .WithContainer("NotesContainer")
    .UseHierarchicalPartitionKeys()
    .WithCreateStructures()
    .Build();

ForEmulator() sets the endpoint, account key, and gateway mode for the Cosmos Linux emulator. Build() validates that all required fields are set and returns a CosmosConfig.

Side by Side: Before and After

Let's put it all together. Here's what the same operations look like in the raw SDK versus the library. This is the code from Chapter 4, reimagined.

Defining a Domain Model

Raw SDK:

public class Note
{
    public string id { get; set; } = Guid.NewGuid().ToString();
    
    public string TenantId { get; set; } = string.Empty;
    
    public string Title { get; set; } = string.Empty;
    public string Body { get; set; } = string.Empty;
    public DateTime CreatedDate { get; set; } = DateTime.UtcNow;
    public List<string> Tags { get; set; } = new();
}

Benday.CosmosDb:

public class Note : TenantItemBase
{
    public string Title { get; set; } = string.Empty;
    public string Body { get; set; } = string.Empty;
    public DateTime CreatedDate { get; set; } = DateTime.UtcNow;
    public List<string> Tags { get; set; } = new();
}

The id property with its awkward lowercase name is gone. The TenantId that you had to declare and manage yourself is handled by the base class. What's left is just your business data. (The tree, not the plumbing.)

Saving a Document

Raw SDK:

var partitionKey = new PartitionKeyBuilder()
    .Add(note.TenantId)
    .Add("Note")
    .Build();

try
{
    var response = await container.UpsertItemAsync(
        note, partitionKey,
        new ItemRequestOptions { IfMatchEtag = etag }
    );
    etag = response.ETag;
    Console.WriteLine($"Saved. Cost: {response.RequestCharge} RUs");
}
catch (CosmosException ex) 
    when (ex.StatusCode == System.Net.HttpStatusCode.PreconditionFailed)
{
    Console.WriteLine("Conflict detected.");
}

Benday.CosmosDb:

try
{
    await noteRepository.SaveAsync(note);
}
catch (OptimisticConcurrencyException)
{
    Console.WriteLine("Conflict detected.");
}

Partition key construction: gone. ETag management: gone. RU logging: automatic. The save is one line. The exception is named.

Reading a Document

Raw SDK:

var response = await container.ReadItemAsync<Note>(
    noteId,
    new PartitionKey(tenantId)
);
var note = response.Resource;
Console.WriteLine($"Read. Cost: {response.RequestCharge} RUs");

Benday.CosmosDb:

var note = await noteRepository.GetByIdAsync(tenantId, noteId);

Querying Documents

Raw SDK:

var queryable = container.GetItemLinqQueryable<Note>(
    requestOptions: new QueryRequestOptions
    {
        PartitionKey = new PartitionKey(tenantId)
    }
);

var query = queryable
    .Where(n => n.TenantId == tenantId)
    .OrderByDescending(n => n.CreatedDate)
    .ToFeedIterator();

var results = new List<Note>();
while (query.HasMoreResults)
{
    var response = await query.ReadNextAsync();
    results.AddRange(response);
}

Benday.CosmosDb:

var notes = await noteRepository.GetAllAsync(tenantId);

Twelve lines to one. And the one line automatically scopes to the partition, filters by entity type, orders by timestamp, logs the RU cost, and checks for cross-partition queries.

What the Library Actually Did

Let's go back to that friction inventory from Chapter 4 and check things off.

Manual partition key construction. ✅ Gone. The document carries its own TenantId and EntityType. The repository builds the partition key automatically.

Opt-in concurrency. ✅ Inverted. Concurrency is now the default. ETags round-trip on the domain model. The library throws OptimisticConcurrencyException on conflicts.

Query ceremony. ✅ Collapsed. GetAllAsync(tenantId) replaces a dozen lines of LINQ-to-feed-iterator plumbing.

Silent cross-partition queries. ✅ The library detects them and logs warnings. The [Obsolete] markers nudge you (and your AI coding assistant) toward partition-aware methods.

No aggregate root awareness.TenantItemBase is an aggregate root implementation. It carries identity, partition key, concurrency token, and type discrimination. The repository manages one entity type per instance. The type system encodes what used to live only in your head.

But here's the thing I want you to take away from this chapter. The library didn't just reduce the amount of code you write — although it did that. It changed where the knowledge lives.

In the raw SDK, the knowledge of how to correctly save a document was spread across your application: the right partition key constructor here, the right ETag handling there, the right query filter somewhere else. Every time you wrote a new operation, you had to remember all of it. And the SDK was perfectly happy to let you forget.

With the library, that knowledge is encoded in the base classes and the repository. TenantItemBase knows what it is. The repository knows how to store it correctly. You can focus on what the document means to your application instead of how to convince the SDK to handle it properly.

In Chapter 1, I said that the promise of a document database is that the tree goes straight into storage. With the raw SDK, that promise came with a lot of fine print. With the library, the fine print is handled for you.

The tree stores itself.


Next up: Chapter 6, where we get into the really important stuff — partition key strategy and data modeling. How do you decide what goes in a document? When is something its own aggregate root versus a nested object? What happens when entities need to reference each other across document boundaries? That's where the "species of tree" concept from this chapter becomes a design framework.