Skip to content
Aryeh Citron edited this page Apr 20, 2026 · 18 revisions

Unified InMemoryCosmos API for 3.0.0 — Final Design

Problem

The current FakeCosmosHandler has two patterns for creating a CosmosClient:

Option A — handler.CreateClient() (simple but sealed):

var container = new InMemoryContainer("orders", "/customerId");
var handler = new FakeCosmosHandler(container);
var client = handler.CreateClient();
var cosmosContainer = client.GetContainer("db", "orders");

Three objects. Zero extension points. The entry point is FakeCosmosHandler — an HTTP message handler — which is a confusing, non-intuitive starting point for someone who just wants an in-memory Cosmos container to test against.

Option B — Manual wiring (extensible but verbose):

var container = new InMemoryContainer("orders", "/customerId");
var handler = new FakeCosmosHandler(container);
var innerClient = new CosmosClient(
    "AccountEndpoint=https://localhost:9999/;AccountKey=dGVzdGtleQ==;",
    new CosmosClientOptions
    {
        ConnectionMode = ConnectionMode.Gateway,
        LimitToEndpoint = true,
        HttpClientFactory = () => new HttpClient(
            new CosmosTrackingMessageHandler(trackingOptions, handler))
    });
var client = handler.WrapClient(innerClient);
var cosmosContainer = client.GetContainer("db", "orders");

~15 lines. Adding TestTrackingDiagrams forces a complete rewrite from A to B.


Design: Hybrid Builder + Static Sugar

Entry point: InMemoryCosmos

public static class InMemoryCosmos
{
    // Sugar for single-container (delegates to builder internally)
    public static InMemoryCosmosResult Create(
        string containerName,
        string partitionKeyPath = "/id",
        Func<HttpMessageHandler, HttpMessageHandler>? wrapHandler = null,
        Action<CosmosClientOptions>? configureOptions = null,
        Action<InMemoryContainer>? configureContainer = null);

    public static InMemoryCosmosResult Create(
        string containerName,
        string[] partitionKeyPaths,
        Func<HttpMessageHandler, HttpMessageHandler>? wrapHandler = null,
        Action<CosmosClientOptions>? configureOptions = null,
        Action<InMemoryContainer>? configureContainer = null);

    // Builder for multi-container or when you want fluent config
    public static InMemoryCosmosBuilder Builder();
}

Builder: InMemoryCosmosBuilder

public sealed class InMemoryCosmosBuilder
{
    public InMemoryCosmosBuilder AddContainer(string name, string partitionKeyPath,
        Action<InMemoryContainer>? configure = null);
    public InMemoryCosmosBuilder AddContainer(string name, string[] partitionKeyPaths,
        Action<InMemoryContainer>? configure = null);

    public InMemoryCosmosBuilder WrapHandler(
        Func<HttpMessageHandler, HttpMessageHandler> wrapper);
    public InMemoryCosmosBuilder ConfigureOptions(
        Action<CosmosClientOptions> configure);

    public InMemoryCosmosResult Build();
}

Result type: InMemoryCosmosResult

Three tiers of access:

public sealed class InMemoryCosmosResult : IDisposable
{
    // Tier 1 — Production-like (what code-under-test uses)
    public CosmosClient Client { get; }
    public Container Container { get; }                     // single-container; throws if multi
    public IReadOnlyDictionary<string, Container> Containers { get; }

    // Tier 2 — Test setup (sprocs, UDFs, triggers, state, reset)
    public IContainerTestSetup SetupContainer(string? containerName = null);

    // Tier 3 — Fault injection & diagnostics
    public FakeCosmosHandler Handler { get; }               // single-container; throws if multi
    public IReadOnlyDictionary<string, FakeCosmosHandler> Handlers { get; }
    public FakeCosmosHandler GetHandler(string name);
    public void SetFaultInjector(Func<HttpRequestMessage, HttpResponseMessage?>? injector);   // sets on ALL handlers
}

Interface: IContainerTestSetup

public interface IContainerTestSetup
{
    // C# delegate registrations (always available)
    void RegisterStoredProcedure(string id, StoredProcedureHandler handler);
    void RegisterUdf(string name, Func<object?[], object?> handler);
    void RegisterTrigger(string id, TriggerType type, TriggerOperation operation,
        Func<JObject, JObject> handler);

    // JS body registrations (requires CosmosDB.InMemoryEmulator.JsTriggers package)
    // Throws NotImplementedException with helpful message if JsTriggers not installed
    void RegisterStoredProcedure(string id, string jsBody);
    void RegisterUdf(string name, string jsBody);
    void RegisterTrigger(string id, TriggerType type, TriggerOperation operation,
        string jsBody);

    // State management
    string ExportState();
    void ExportStateToFile(string path);
    void ImportState(string json);
    void ImportStateFromFile(string path);
    void RestoreToPointInTime(DateTimeOffset timestamp);

    // Reset
    void ClearItems();
}

InMemoryContainer implements IContainerTestSetup. JS overloads check for registered IJsTriggerEngine/ISprocEngine/IJsUdfEngine; if absent, throw:

NotImplementedException: "JavaScript stored procedure execution requires the
CosmosDB.InMemoryEmulator.JsTriggers package. Install it and call
container.UseJsTriggers(), or use the C# delegate overload instead."

Usage Examples

Simplest case

var cosmos = InMemoryCosmos.Create("orders");
await cosmos.Container.CreateItemAsync(
    new Order { Id = "1", Name = "Widget" }, new PartitionKey("1"));
var response = await cosmos.Container.ReadItemAsync<Order>("1", new PartitionKey("1"));

Simple case

var cosmos = InMemoryCosmos.Create("orders", "/customerId");
await cosmos.Container.CreateItemAsync(
    new Order { Id = "1", CustomerId = "c1" }, new PartitionKey("c1"));
var response = await cosmos.Container.ReadItemAsync<Order>("1", new PartitionKey("c1"));

With TestTrackingDiagrams

var cosmos = InMemoryCosmos.Create("orders", "/customerId",
    wrapHandler: h => new CosmosTrackingMessageHandler(trackingOptions, h));
await cosmos.Container.CreateItemAsync(...);

With custom CosmosClientOptions

var cosmos = InMemoryCosmos.Create("orders", "/customerId",
    wrapHandler: h => new CosmosTrackingMessageHandler(trackingOptions, h),
    configureOptions: o => o.Serializer = new MyCamelCaseSerializer());
await cosmos.Container.CreateItemAsync(...);

Hierarchical partition key

var cosmos = InMemoryCosmos.Create("events", new[] { "/tenantId", "/category", "/date" });
var prefixPk = new PartitionKeyBuilder().Add("tenant-1").Build();
var iterator = cosmos.Container.GetItemQueryIterator<Event>(
    "SELECT * FROM c",
    requestOptions: new QueryRequestOptions { PartitionKey = prefixPk });

Multi-container (builder)

var cosmos = InMemoryCosmos.Builder()
    .AddContainer("orders", "/customerId")
    .AddContainer("customers", "/id")
    .Build();
var ordersContainer = cosmos.Containers["orders"];
var customersContainer = cosmos.Containers["customers"];

await ordersContainer.CreateItemAsync(
    new Order { Id = "1", CustomerId = "c1" }, new PartitionKey("c1"));
await customersContainer.CreateItemAsync(
    new Customer { Id = "c1", Name = "Alice" }, new PartitionKey("c1"));

Multi-container with TestTrackingDiagrams + hierarchical PKs

var cosmos = InMemoryCosmos.Builder()
    .AddContainer("orders", "/customerId")
    .AddContainer("customers", "/id")
    .AddContainer("events", new[] { "/tenantId", "/category", "/date" })
    .WrapHandler(h => new CosmosTrackingMessageHandler(trackingOptions, h))
    .ConfigureOptions(o => o.Serializer = new MyCamelCaseSerializer())
    .Build();
var ordersContainer = cosmos.Containers["orders"];
var eventsContainer = cosmos.Containers["events"];

Fault injection

// Single-container
var cosmos = InMemoryCosmos.Create("orders", "/customerId");
cosmos.Handler.FaultInjector = req =>
{
    if (req.Method == HttpMethod.Post)
        return new HttpResponseMessage((HttpStatusCode)429);
    return null;
};
cosmos.Handler.FaultInjectorIncludesMetadata = false;

// Multi-container — per-container
cosmos.GetHandler("orders").FaultInjector = req => ...;

// Multi-container — global outage across all containers
cosmos.SetFaultInjector(req => new HttpResponseMessage((HttpStatusCode)503));

Query logging

foreach (var query in cosmos.Handler.QueryLog)
    Console.WriteLine(query);

Stored procedures / UDFs / triggers (C# delegates)

cosmos.SetupContainer().RegisterStoredProcedure("mysproc", (args, context) =>
{
    context.Response = new { result = "ok" };
    return Task.CompletedTask;
});

cosmos.SetupContainer().RegisterUdf("toUpper", args => ((string)args[0]!).ToUpper());

cosmos.SetupContainer().RegisterTrigger("audit", TriggerType.Post, TriggerOperation.All,
    doc => { doc["audited"] = true; return doc; });

Stored procedures / UDFs / triggers (JS bodies — requires JsTriggers)

cosmos.SetupContainer().RegisterStoredProcedure("mysproc",
    "function(prefix) { var context = getContext(); ... }");

cosmos.SetupContainer().RegisterUdf("toUpper",
    "function(s) { return s.toUpperCase(); }");

cosmos.SetupContainer().RegisterTrigger("validate", TriggerType.Pre, TriggerOperation.Create,
    "function() { var context = getContext(); ... }");

State persistence

// Export
var json = cosmos.ExportState();
cosmos.ExportStateToFile("seed.json");

// Import
cosmos.ImportState(json);
cosmos.ImportStateFromFile("seed.json");

// Point-in-time restore
cosmos.RestoreToPointInTime(DateTimeOffset.UtcNow.AddHours(-1));

Reset between tests

cosmos.ClearItems();
// Multi-container:
cosmos.ClearItems("orders");
cosmos.ClearItems("customers");

Test fixture pattern

public class OrderTests : IAsyncLifetime, IAsyncDisposable
{
    private readonly InMemoryCosmosResult _cosmos =
        InMemoryCosmos.Create("orders", "/customerId");

    public Task InitializeAsync() => Task.CompletedTask;
    public Task DisposeAsync() => _cosmos.DisposeAsync().AsTask();

    [Fact]
    public async Task Can_create_and_read_order()
    {
        await _cosmos.Container.CreateItemAsync(
            new Order { Id = "1", CustomerId = "c1" },
            new PartitionKey("c1"));

        var response = await _cosmos.Container.ReadItemAsync<Order>(
            "1", new PartitionKey("c1"));
        response.Resource.Id.Should().Be("1");
    }
}

API Surface Summary

New public types

  • InMemoryCosmos — static entry point
  • InMemoryCosmosBuilder — fluent builder
  • InMemoryCosmosResult — result with Container/Setup/Handler access
  • IContainerTestSetup — interface for test-only operations (implemented by InMemoryContainer)
  • InMemoryDatabaseBuilder — Phase 3: database-scoped builder callback
  • InMemoryDatabaseResult — Phase 3: database-scoped result with Containers/Handlers

Mental model (three tiers)

Property What it's for When you need it
cosmos.Container CRUD, queries, LINQ Every test
cosmos.SetupContainer() Sprocs, UDFs, triggers Test setup
cosmos.ExportState() / ImportState() / ClearItems() State management Test setup/teardown
cosmos.Handler Fault injection, query log Chaos testing, diagnostics

Design decisions

  • Builder + Create sugar: Create() for single-container (80% case); Builder() for multi-container or advanced config
  • IContainerTestSetup interface: Filters InMemoryContainer surface to test-only methods; avoids exposing 100+ Container methods via IntelliSense
  • JS overloads with helpful errors: JS body string overloads on IContainerTestSetup throw NotImplementedException with package install guidance when JsTriggers not installed
  • No BackingContainer exposure: Users access test setup through IContainerTestSetup; handler through FakeCosmosHandler
  • Direct Container property: cosmos.Container eliminates need for cosmos.Client.GetContainer("db", "orders") with meaningless "db" string
  • wrapHandler param: Same Func<HttpMessageHandler, HttpMessageHandler> shape as existing DI WithHttpMessageHandlerWrapper() — consistent across DI and non-DI paths
  • CosmosClientOptions guardrails: The user's configureOptions callback runs first, then:
    • ConnectionMode is forcibly set to ConnectionMode.Gateway (required by FakeCosmosHandler)
    • LimitToEndpoint is forcibly set to true
    • If the user set HttpClientFactory, throw InvalidOperationException: "Do not set HttpClientFactory directly — use the wrapHandler parameter to customize the HTTP pipeline. HttpClientFactory is managed internally by InMemoryCosmos."
    • This lets users safely configure serializers, timeouts, etc. without accidentally breaking the fake handler pipeline
  • Builder validation:
    • Build() with no containers (across both AddContainer() and AddDatabase()) throws InvalidOperationException: "At least one container must be added before calling Build()."
    • AddContainer() with a duplicate name throws InvalidOperationException: "Container 'orders' has already been added."
  • Multiple WrapHandler/ConfigureOptions calls compose: Both chain in order. .WrapHandler(a).WrapHandler(b) produces b(a(handler)) (middleware convention). .ConfigureOptions(a).ConfigureOptions(b) runs both callbacks sequentially.
  • Container name casing: Containers dictionary uses StringComparer.Ordinal — case-sensitive, matching real Cosmos DB behavior.
  • Dispose: InMemoryCosmosResult implements both IDisposable and IAsyncDisposable (matching CosmosClient). Supports both using var cosmos = ... and await using var cosmos = .... Disposes the CosmosClient. Does not clear backing container data (consistent with real Cosmos — disposing client doesn't delete data).
  • Invalid name lookups: SetupContainer("nonexistent") and GetHandler("nonexistent") throw InvalidOperationException listing available container names: "Container 'nonexistent' not found. Available containers: orders, customers." Containers["nonexistent"] gives standard KeyNotFoundException.
  • Input validation:
    • Null/empty container name throws ArgumentException
    • Empty partition key paths array throws ArgumentException
    • Partition key path not starting with / throws ArgumentException: "Partition key path must start with '/'. Got: 'customerId'."
  • Single-container populates Containers: InMemoryCosmos.Create("orders") populates both cosmos.Container and cosmos.Containers["orders"] — they return the same instance.
  • Handlers are per-container: Each container gets its own FakeCosmosHandler with independent FaultInjector, QueryLog, etc. Handlers dictionary mirrors Containers. SetFaultInjector() convenience method sets the injector on all handlers at once for simulating global outages.
  • Builder reuse: Build() can be called multiple times on the same builder. Each call creates a fresh, independent InMemoryCosmosResult with new containers and handlers. The builder acts as a reusable template. Mutations to the builder between Build() calls are reflected in subsequent results (mutable builder, immutable results).
  • Container configuration: AddContainer supports an optional config callback for TTL, unique keys, computed properties, and feed range count:
    .AddContainer("orders", "/customerId", container => {
        container.DefaultTimeToLive = 3600;
        container.UniqueKeyPolicy = new UniqueKeyPolicy { ... };
        container.ComputedProperties = new[] { ... };
        container.FeedRangeCount = 4;
    })
    The Create() sugar also gets a config callback overload:
    InMemoryCosmos.Create("orders", "/customerId",
        configureContainer: c => c.DefaultTimeToLive = 3600);
  • SetFaultInjector overwrites per-handler injectors: Calling cosmos.SetFaultInjector(...) overwrites any previously set per-handler injectors on every handler. SetFaultInjector(null) clears them all back to no injection. Type is Func<HttpRequestMessage, HttpResponseMessage?>? — same as the existing FakeCosmosHandler.FaultInjector property. No named delegate type introduced.
  • FakeCosmosHandlerOptions (CacheTtl, CacheMaxEntries, PartitionKeyRangeCount): Not exposed on builder — these are niche tuning knobs. Configure post-build via the handler: cosmos.Handler.CacheTtl = TimeSpan.FromMinutes(5);
  • Database name: InMemoryCosmos.DefaultDatabaseName is a const string with value "default". Used for the rare case where users need cosmos.Client.GetContainer(db, name). Primary path is cosmos.Container / cosmos.Containers["name"] which avoids the question entirely. The router ignores the database name — any value works. Phase 3 introduces explicit multi-database support; DefaultDatabaseName is the database used by AddContainer() and Create().
  • Breaking change (3.0.0): Remove CreateClient(), WrapClient(), and CreateRouter() from FakeCosmosHandler. InMemoryCosmos replaces all three patterns. This is a test-code-only breaking change — production code is unaffected. No known external users.
  • DI coexistence: Document that InMemoryCosmos is for non-DI usage. For DI-based integration tests, continue using UseInMemoryCosmosDB() / UseInMemoryCosmosContainers().
  • Auto-persist with Dispose: When StatePersistenceDirectory is configured via configureContainer, the container auto-saves on dispose. InMemoryCosmosResult.Dispose() disposes the client, which triggers container cleanup — auto-persist fires as expected.
  • ProductionExtensions not needed: Since InMemoryCosmos always uses FakeCosmosHandler, .ToFeedIterator() works natively via the SDK pipeline. The ProductionExtensions package is unnecessary — document this to avoid confusion.
  • configureContainer callback timing: The configureContainer / configure callback runs after the InMemoryContainer is fully constructed and wired to its FakeCosmosHandler. Safe to call ImportState(), RegisterStoredProcedure(), etc. inside the callback.
  • Multiple Create() calls are independent: Each InMemoryCosmos.Create() call produces a fully independent result — separate InMemoryContainer, FakeCosmosHandler, and CosmosClient. No shared state between results.
  • Phase 3 Containers ambiguity detection (implementation note): The flat Containers dictionary requires a custom IReadOnlyDictionary<string, Container> wrapper to throw ambiguity messages on indexer access — standard Dictionary throws generic KeyNotFoundException without custom guidance.
  • Builder AddContainer requires PK path (no default): Unlike Create() which defaults to /id, the builder's AddContainer(name, partitionKeyPath) has no default. Multi-container setups are more deliberate — omitting the PK path is more likely a mistake than an intentional /id choice.

Relevant files for implementation

  • src/CosmosDB.InMemoryEmulator/InMemoryCosmos.cs — new file: static class + builder + result
  • src/CosmosDB.InMemoryEmulator/IContainerTestSetup.cs — new file: interface
  • src/CosmosDB.InMemoryEmulator/InMemoryContainer.cs — implement IContainerTestSetup, add JS overloads with NotImplementedException
  • src/CosmosDB.InMemoryEmulator/FakeCosmosHandler.cs — existing CreateClient/WrapClient/CreateRouter used internally by builder
  • Both wikis need updating after implementation

Phase 2: Dynamic Container Management

After the core InMemoryCosmos API ships, add support for runtime container creation and deletion via the SDK — enabling production startup code that calls CreateContainerAsync / DeleteContainerAsync to work without pre-registration.

Behavior

  • CreateContainerAsync(containerProperties): Router intercepts the request, creates a new InMemoryContainer + FakeCosmosHandler from the ContainerProperties (PK path, unique keys, TTL, etc.), adds them to the Containers and Handlers dictionaries, applies the captured wrapHandler from build time, and returns 201.
  • DeleteContainerAsync: Removes the container and handler from the dictionaries. Subsequent operations on that container return 404.
  • Containers and Handlers are backed by ConcurrentDictionary behind the IReadOnlyDictionary interface — read-only access, but contents may grow/shrink.
  • SetupContainer() and GetHandler() work for dynamically created containers.
  • Dynamically created containers get default FakeCosmosHandlerOptions.
  • Container (singular property) behavior: if created via Create() (single-container), the shortcut still points to the original container — dynamic additions don't change it.

Scope

  • POST /dbs/{db}/colls — CreateContainerAsync
  • DELETE /dbs/{db}/colls/{coll} — DeleteContainerAsync
  • GET /dbs/{db}/colls/{coll} — ReadContainerAsync (already partially supported)
  • Thread-safe via ConcurrentDictionary

Additional scope

  • PUT /dbs/{db}/colls/{coll} — ReplaceContainerAsync: Replaces mutable container configuration (TTL, unique keys, indexing policy, etc.) from the updated ContainerProperties. Existing items are preserved. Partition key path changes are rejected with 400 BadRequest — matching real Cosmos DB, where PK path is immutable after creation. Returns 200.
  • POST /dbs/{db}/colls with IfNotExists — CreateContainerIfNotExistsAsync: If the container already exists, returns 200 with existing properties. Otherwise creates and returns 201. This is the most common variant in production startup code.
  • POST /dbs — CreateDatabaseAsync: No-op success (201). The database name is noted internally so ReadDatabaseAsync returns it, but no state is created until containers are added.
  • POST /dbs with IfNotExists — CreateDatabaseIfNotExistsAsync: Returns 200 if exists, 201 if created. Same no-op semantics as CreateDatabaseAsync.
  • GET /dbs/{db} — ReadDatabaseAsync: Returns 200 if the database was created or has containers; 404 otherwise.
  • DELETE /dbs/{db} — DeleteDatabaseAsync: Removes the database and all its containers and handlers. Subsequent operations return 404.
  • GET /dbs — ReadAllDatabases / iterating databases: Returns all known database names.
  • GET /dbs/{db}/colls — GetContainerQueryIterator / listing containers: Returns all container names within the specified database.
  • Dynamic deletion + singular Container: If Create("orders") then production code calls DeleteContainerAsync("orders"), the cosmos.Container property still references the original SDK Container object, but the router will 404 all subsequent requests through it. This matches real Cosmos behavior (holding a Container reference after deletion doesn't throw — operations on it do).

Phase 3: Multi-Database Support

For codebases where a single CosmosClient talks to multiple databases that may have overlapping container names (e.g. users-db/events and orders-db/events).

Constraint

Phase 1 and Phase 2 code must remain identical — zero additional ceremony for the 99% single-database case.

Builder addition: AddDatabase()

public sealed class InMemoryCosmosBuilder
{
    // Existing — adds to DefaultDatabaseName
    public InMemoryCosmosBuilder AddContainer(string name, string partitionKeyPath,
        Action<InMemoryContainer>? configure = null);

    // New — explicit database scoping
    public InMemoryCosmosBuilder AddDatabase(string databaseName,
        Action<InMemoryDatabaseBuilder> configure);
}

public sealed class InMemoryDatabaseBuilder
{
    public InMemoryDatabaseBuilder AddContainer(string name, string partitionKeyPath,
        Action<InMemoryContainer>? configure = null);
    public InMemoryDatabaseBuilder AddContainer(string name, string[] partitionKeyPaths,
        Action<InMemoryContainer>? configure = null);
}

Result addition: Database()

public sealed class InMemoryCosmosResult : IDisposable, IAsyncDisposable
{
    // Existing — work when container names are globally unique
    public Container Container { get; }
    public IReadOnlyDictionary<string, Container> Containers { get; }
    public IContainerTestSetup SetupContainer(string? containerName = null);

    // State management convenience methods
    public string ExportState(string? containerName = null);
    public void ExportStateToFile(string path, string? containerName = null);
    public void ImportState(string json, string? containerName = null);
    public void ImportStateFromFile(string filePath, string? containerName = null);
    public void RestoreToPointInTime(DateTimeOffset timestamp, string? containerName = null);
    public void ClearItems(string? containerName = null);

    public FakeCosmosHandler Handler { get; }
    public IReadOnlyDictionary<string, FakeCosmosHandler> Handlers { get; }

    // New — database-scoped access
    public InMemoryDatabaseResult Database(string databaseName);
    public IReadOnlyDictionary<string, InMemoryDatabaseResult> Databases { get; }
}

public sealed class InMemoryDatabaseResult
{
    public string DatabaseName { get; }
    public IReadOnlyDictionary<string, Container> Containers { get; }
    public IContainerTestSetup SetupContainer(string containerName);

    // State management convenience methods
    public string ExportState(string containerName);
    public void ExportStateToFile(string containerName, string path);
    public void ImportState(string containerName, string json);
    public void ImportStateFromFile(string containerName, string filePath);
    public void RestoreToPointInTime(string containerName, DateTimeOffset timestamp);
    public void ClearItems(string containerName);

    public FakeCosmosHandler GetHandler(string containerName);
    public IReadOnlyDictionary<string, FakeCosmosHandler> Handlers { get; }
    public void SetFaultInjector(Func<HttpRequestMessage, HttpResponseMessage?>? injector);
}

Usage — multi-database

var cosmos = InMemoryCosmos.Builder()
    .AddDatabase("users-db", db => {
        db.AddContainer("events", "/userId");
        db.AddContainer("profiles", "/userId");
    })
    .AddDatabase("orders-db", db => {
        db.AddContainer("events", "/orderId");
        db.AddContainer("products", "/categoryId");
    })
    .WrapHandler(h => new CosmosTrackingMessageHandler(trackingOptions, h))
    .Build();

// Database-scoped access
var userEvents = cosmos.Database("users-db").Containers["events"];
var orderEvents = cosmos.Database("orders-db").Containers["events"];

// Setup & fault injection scoped to a database's container
cosmos.Database("users-db").SetupContainer("events").RegisterUdf(...);
cosmos.Database("orders-db").GetHandler("events").FaultInjector = ...;

// Global outage — one database
cosmos.Database("orders-db").SetFaultInjector(req =>
    new HttpResponseMessage((HttpStatusCode)503));

// Global outage — all databases, all containers
cosmos.SetFaultInjector(req => new HttpResponseMessage((HttpStatusCode)503));

Mixing AddContainer() and AddDatabase()

var cosmos = InMemoryCosmos.Builder()
    .AddContainer("orders", "/customerId")           // goes to DefaultDatabaseName
    .AddDatabase("audit-db", db => {
        db.AddContainer("events", "/eventId");
    })
    .Build();

// Flat access works — names are globally unique
var orders = cosmos.Containers["orders"];
var events = cosmos.Containers["events"];

// Database-scoped also works
var eventsToo = cosmos.Database("audit-db").Containers["events"];

Ambiguity handling

The flat convenience properties (Containers, SetupContainer(), Handlers, GetHandler()) work when container names are globally unique across all databases. When names collide, they throw with guidance:

  • cosmos.Containers["events"] → KeyNotFoundException: "Container 'events' exists in multiple databases: users-db, orders-db. Use cosmos.Database("users-db").Containers["events"] instead."
  • cosmos.SetupContainer("events") → InvalidOperationException: "Container 'events' exists in multiple databases: users-db, orders-db. Use cosmos.Database("users-db").SetupContainer("events") instead."
  • cosmos.GetHandler("events") → same pattern.
  • cosmos.Container (singular): throws if multi-container, regardless of databases. Unaffected by Phase 3.
  • cosmos.Handler (singular): same — single-container only.

Router changes

The HTTP router already receives database name in the URL path (/dbs/{db}/colls/{coll}/...). Phase 1/2 ignore it. Phase 3 matches on both {db} and {coll} segments to route to the correct handler. Requests for unregistered database/container combinations return 404.

Validation

  • AddDatabase() with a duplicate database name throws InvalidOperationException: "Database 'orders-db' has already been added."
  • AddDatabase() with an empty callback (no containers) throws InvalidOperationException: "Database 'orders-db' must have at least one container."
  • Duplicate container names within a database throw (same as Phase 1).
  • Duplicate container names across databases are allowed — that's the whole point.
  • Database("nonexistent") throws InvalidOperationException: "Database 'nonexistent' not found. Available databases: users-db, orders-db."

Create() sugar

The Create() static method is unchanged — it always uses DefaultDatabaseName and produces a single-database, single-container result. Multi-database requires the builder.

Phase 2 interaction

Dynamic CreateContainerAsync extracts the database name from the URL path. If the database was registered via AddDatabase(), the new container joins that database. If not, a new InMemoryDatabaseResult is created on the fly (same ConcurrentDictionary thread-safety). Dynamic containers in ambiguous situations follow the same ambiguity-throws rules.

Out of scope for Phase 3

  • Cross-database transactions

Phase 4: Documentation

After all three phases are implemented and tested, update all documentation:

  • README.md — Update quick-start examples to use InMemoryCosmos.Create() and builder. Remove references to FakeCosmosHandler.CreateClient() / WrapClient() / CreateRouter().
  • Wiki — Integration Approaches — Rewrite to show unified API as the primary approach. Document the three tiers (Container, SetupContainer, Handler) and the progression from Create() to Builder() to AddDatabase().
  • Wiki — DI Patterns — Add note that InMemoryCosmos is for non-DI usage; DI continues using UseInMemoryCosmosDB() / UseInMemoryCosmosContainers().
  • Wiki — FakeCosmosHandler — Document removal of CreateClient(), WrapClient(), CreateRouter() in 3.0.0. Update fault injection examples to use cosmos.Handler.
  • Wiki — Feed Iterator — Note that ProductionExtensions are unnecessary when using InMemoryCosmos (FakeCosmosHandler pipeline handles it).
  • Wiki — Seeding Data — Update examples to use cosmos.ImportState() and configureContainer callback.
  • Wiki — Container Management — Add Phase 2 dynamic container/database CRUD examples.
  • Wiki — Multi-Database — New page for Phase 3: AddDatabase(), Database(), ambiguity handling, mixing AddContainer() and AddDatabase().
  • Wiki — Troubleshooting — Update for new API error messages and common migration issues.
  • Wiki — Migration Guide — New page: 2.x → 3.0.0 migration guide covering the CreateClient() / WrapClient() / CreateRouter() removal and replacement patterns.
  • NuGet package description / release notes — Update for 3.0.0 breaking changes.

Clone this wiki locally