ADR: Accounts on a document store
- Status
- accepted
- Date
- 2026-09-08
ASP.NET Core Identity, over one document per
account and one document per email address, in a container partitioned on the
account id. Parent: ADR: A second store on Cosmos DB, and what it costs. The
relational side is unchanged and is described in ADR: Accounts and per-user
bids and ADR: Identity and the session token, explained.
In plain words
User accounts on the document database (Azure Cosmos DB) are stored as one document per account, plus a second, tiny document per email address that keeps each address unique. Signing in is two direct lookups by id (point reads), and the page lists what this design costs next to SQL Server.
What that is worth: a developer sees how to keep an address unique on a store with no unique index, and the organization gets sign-in at about two request units with every trade-off written down before a user finds it.
Context
Identity's relational shape is seven tables: a user row plus claims, logins,
tokens, roles and two join tables, related by foreign keys and read with joins.
This application uses one of the seven. It registers an address with a password,
signs in by address, locks an account after five wrong guesses, and asks "who am
I" by id. It has no roles, issues no Identity tokens, holds no external logins
and stores no claims; its session is a JWT it signs itself (ADR: Accounts and
per-user bids).
There are no joins on the document store, and there is no unique index that
spans partitions. Those two facts are the whole design problem.
The three options
Keep accounts on SQL Server. A two-store application, with the hard part
avoided. It is what a lot of real systems do and it would have been defensible.
Steve chose the full stack, and a comparison that kept accounts relational
would not measure sign-in, which is one of the three paths the parity bar is
earned on.
IdentityDbContext on the EF Core Cosmos provider. It compiles and it runs.
Seven entity types land in a document store as seven document shapes with a
discriminator; FindByEmailAsync becomes a query across every partition on a
property no index covers unless one is paid for on every write; and two
registrations of the same address a few milliseconds apart both succeed, because
the unique index that stops them on SQL Server (UserNameIndex, on the
normalized user name, which is the address here) does not exist on this
side. Nothing fails. The store just has two accounts with one
address, and the next sign-in finds whichever it finds.
A custom IUserStore over one document per account. Chosen.
Decision
One container, users, partition key /id. Two document shapes in it.
public sealed class UserDocument
{
public string Id { get; set; } = "";
public string? UserName { get; set; }
public string? NormalizedUserName { get; set; }
public string? Email { get; set; }
public string? NormalizedEmail { get; set; }
public bool EmailConfirmed { get; set; }
public string? PasswordHash { get; set; }
public string? SecurityStamp { get; set; }
public DateTimeOffset? LockoutEnd { get; set; }
public bool LockoutEnabled { get; set; }
public int AccessFailedCount { get; set; }
public long CreatedAtMs { get; set; }
[JsonPropertyName("_etag")]
public string? ETag { get; set; }
}
public sealed class EmailClaimDocument
{
public const string Prefix = "email:";
public string Id { get; set; } = "";
public string UserId { get; set; } = "";
[JsonPropertyName("_ts")]
public long? Timestamp { get; set; }
public static string IdFor(string normalizedEmail) => Prefix + normalizedEmail;
}
Live from api/TheYard.Infrastructure.Cosmos/Shared/Stores/Documents.Users.cs, region documents, read from this build at 8003063.
The account is one document, id and partition key both the account id, so
"who am I" is one point read, about one request unit. It carries exactly the
columns of the user row this application reads: the address and its normalized
form, the password hash, the security stamp, the lockout fields and the count
of failed guesses, and the one field this application added to Identity's
user, when the account was created.
The address is a second, tiny document, id email:<normalized address>,
carrying the account id. It exists to be unique. A store that cannot declare a
unique constraint across partitions can still refuse to create two documents
with one id in one partition, and a document whose id is the address and whose
partition is its own id is exactly that: the second registration of an address
is a 409 from the store, not a race that both sides win.
Registering writes the claim first and the account second.
public async Task<IdentityResult> CreateAsync(YardUser user, CancellationToken cancellationToken)
{
string normalizedEmail = user.NormalizedEmail ?? user.NormalizedUserName
?? throw new InvalidOperationException("an account needs a normalized email address before it is stored");
var claim = new EmailClaimDocument { Id = EmailClaimDocument.IdFor(normalizedEmail), UserId = user.Id };
try
{
await store.CreateAsync(store.Users, claim, claim.Id, ClaimPartition, "CreateItem (email claim)");
}
catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.Conflict)
{
return IdentityResult.Failed(Errors.DuplicateEmail(user.Email ?? ""));
}
try
{
var response = await store.CreateAsync(store.Users, ToDocument(user), user.Id, Partition, "CreateItem (account)");
user.ConcurrencyStamp = response.ETag;
return IdentityResult.Success;
}
catch (CosmosException)
{
await TryDeleteClaimAsync(claim.Id, CancellationToken.None);
throw;
}
}
Live from api/TheYard.Infrastructure.Cosmos/Features/Accounts/CosmosUserStore.cs, region create, read from this build at 8003063.
If the claim is refused the registration fails with Identity's own duplicate
email error, and Accounts.Explain turns that into the same sentence the
relational side shows. If the account write fails after the claim succeeded, the
claim is deleted again, so a failed registration leaves nothing behind.
Sign-in is two point reads. The claim by address, then the account by the id
the claim carries. About two request units and two round trips, in region, and
neither of them a query:
public async Task<YardUser?> FindByIdAsync(string userId, CancellationToken cancellationToken)
{
var document = await store.ReadAsync<UserDocument>(store.Users, userId, userId, Partition);
return document is null ? null : ToUser(document);
}
public async Task<YardUser?> FindByEmailAsync(string normalizedEmail, CancellationToken cancellationToken)
{
string claimId = EmailClaimDocument.IdFor(normalizedEmail);
var claim = await store.ReadAsync<EmailClaimDocument>(store.Users, claimId, claimId, ClaimPartition);
if (claim is null)
{
return null;
}
var user = await FindByIdAsync(claim.UserId, cancellationToken);
if (user is null && IsOrphan(claim))
{
await TryDeleteClaimAsync(claimId, cancellationToken);
}
return user;
}
public TimeSpan OrphanAge { get; } = orphanAge ?? TimeSpan.FromMinutes(1);
private bool IsOrphan(EmailClaimDocument claim) =>
claim.Timestamp is { } written
&& clock.GetUtcNow() - DateTimeOffset.FromUnixTimeSeconds(written) >= OrphanAge;
public Task<YardUser?> FindByNameAsync(string normalizedUserName, CancellationToken cancellationToken) =>
FindByEmailAsync(normalizedUserName, cancellationToken);
Live from api/TheYard.Infrastructure.Cosmos/Features/Accounts/CosmosUserStore.cs, region find, read from this build at 8003063.
This application registers the address as the user name, so Identity's lookup by
normalized user name is the same lookup as the one by normalized email, and the
store treats them as one.
A failed guess is one replace, carrying the etag. Identity keeps a
concurrency stamp on the user and rotates it on every update; on this side the
stamp is the document's own _etag, read with the document and sent back as
If-Match on the replace. A stale one is refused with 412, which the store
reports as Identity's concurrency failure, exactly as a row that moved would be
reported on SQL Server:
public async Task<IdentityResult> UpdateAsync(YardUser user, CancellationToken cancellationToken)
{
try
{
var response = await store.ReplaceAsync(store.Users, ToDocument(user), user.Id, user.Id, user.ConcurrencyStamp, Partition);
user.ConcurrencyStamp = response.ETag;
return IdentityResult.Success;
}
catch (CosmosException ex) when (ex.StatusCode == HttpStatusCode.PreconditionFailed)
{
return IdentityResult.Failed(Errors.ConcurrencyFailure());
}
}
Live from api/TheYard.Infrastructure.Cosmos/Features/Accounts/CosmosUserStore.cs, region update, read from this build at 8003063.
A successful sign-in with no failed guesses on the account writes nothing at
all, because UserManager.ResetAccessFailedCountAsync returns before touching
the store when the count is already zero.
The store implements five interfaces and no more: the base, password, email,
lockout and security stamp. Roles, claims, logins and tokens are not implemented,
which means a call to them fails loudly at registration time rather than
silently at runtime, and which is the honest description of what this
application uses.
Where the document model costs something
Said plainly, because the comparison record keeps this list.
- Uniqueness is a document you write, not a constraint you declare. The
claim document is a line of code doing what one line of DDL does on the other
side, and a reviewer has to know it is there.
- A process that dies between the two writes leaves a claim with no account.
Until it is cleaned up, that address reads as taken. The relational side has
no such window because the row and its unique index are one write.
- There is no cascade. Deleting an account on SQL Server takes its bids with
it, because the foreign key says so. Here a bid is a document in another
container partitioned on the buyer, and deleting the account leaves them. This
application has no delete-account endpoint, so nothing reaches this today; the
record says it rather than a visitor finding it.
- Nothing enforces the shape. A document with a missing field is a document
with a missing field. The conformance tests on the relational side compare
DDL to model; here the round-trip tests are what hold the shape.
What the tests hold
Against the real account, on the runner, as the signed-in principal, through a
real UserManager<YardUser> rather than the store's methods directly, so what
is tested is what the endpoints call:
private static ServiceProvider Identity(CosmosStore store)
{
var services = new ServiceCollection();
services.AddLogging();
services.AddSingleton(store);
services.AddSingleton(TimeProvider.System);
services.AddIdentityCore<YardUser>(options =>
{
options.Password.RequiredLength = 8;
options.Password.RequireNonAlphanumeric = false;
options.Password.RequireUppercase = false;
options.Password.RequireDigit = false;
options.User.RequireUniqueEmail = true;
options.Lockout.MaxFailedAccessAttempts = 5;
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5);
options.Lockout.AllowedForNewUsers = true;
}).AddUserStore<CosmosUserStore>();
return services.BuildServiceProvider();
}
[Fact]
public async Task An_account_is_one_document_and_one_claim_and_the_second_claim_on_an_address_is_refused()
{
var (store, log) = CosmosLive.Open();
using var provider = Identity(store);
using var scope = provider.CreateScope();
var users = scope.ServiceProvider.GetRequiredService<UserManager<YardUser>>();
string email = $"leak-canary-{Guid.NewGuid():N}@example.com";
var created = await users.CreateAsync(new YardUser { UserName = email, Email = email, CreatedAtMs = 1 }, "correct horse");
Assert.True(created.Succeeded, string.Join("; ", created.Errors.Select(e => e.Description)));
var again = await users.CreateAsync(new YardUser { UserName = email, Email = email, CreatedAtMs = 2 }, "correct horse");
Assert.False(again.Succeeded);
Assert.Contains(again.Errors, e => e.Code.Contains("Duplicate", StringComparison.Ordinal));
var found = await users.FindByEmailAsync(email);
Assert.NotNull(found);
Assert.True(await users.CheckPasswordAsync(found, "correct horse"));
Assert.False(await users.CheckPasswordAsync(found, "wrong"));
await users.AccessFailedAsync(found);
var after = await users.FindByIdAsync(found.Id);
Assert.Equal(1, after!.AccessFailedCount);
await users.ResetAccessFailedCountAsync(after);
var operations = log.Snapshot();
Assert.Contains(operations, o => o.Container == "users" && o.Text == "CreateItem (email claim)");
Assert.Contains(operations, o => o.Container == "users" && o.Text == "CreateItem (account)");
Assert.Contains(operations, o => o.Container == "users" && o.Kind == StoreOperationKind.PointWrite && o.Text == "ReplaceItem (If-Match)");
Assert.DoesNotContain(operations, o => o.Container == "users" && o.Kind == StoreOperationKind.Query);
foreach (var operation in operations)
{
Assert.DoesNotContain(email, operation.Text, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain(email, operation.Partition, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("leak-canary", operation.Text, StringComparison.OrdinalIgnoreCase);
}
var deleted = await users.DeleteAsync(after);
Assert.True(deleted.Succeeded);
Assert.Null(await users.FindByEmailAsync(email));
var third = await users.CreateAsync(new YardUser { UserName = email, Email = email, CreatedAtMs = 3 }, "correct horse");
Assert.True(third.Succeeded);
await users.DeleteAsync((await users.FindByEmailAsync(email))!);
}
Live from api/TheYard.Tests/CosmosStoreTests.cs, region accounts, read from this build at 8003063.
Registration creates two documents, the second registration of the address is
refused with a duplicate error, sign-in finds the account and checks the
password, a failed guess moves the count through a replace, the store ran no
query at all, no operation in the store log carries the address, and deleting
the account frees the address. The suite also runs whole with every application
booted on the document store, so the accounts tests written for SQL Server run
against this store unchanged (ADR: A second store on Cosmos DB, and what it
costs).
Consequences
- Sign-in and "who am I" are point reads, which is the cheapest thing the store
does, and the comparison card can put their charge beside their milliseconds.
- Identity's seven tables are four fewer document shapes than the provider
would have made, and the two that exist are readable beside the API's own JSON.
- The address is unique by construction and the construction is visible.
- The costs above are real and are written here rather than found later.
Where it sits
The accounts code sits in Infrastructure.Cosmos, where CosmosUserStore and the user and email-claim documents in Documents.Users.cs live, and it borrows YardUser from Infrastructure, which is the one reason that project references Infrastructure at all; the host's composition root hands it to Identity in place of the Entity Framework store. Interface segregation, small interfaces shaped to what a caller needs, is the principle at work, because Identity splits its store into narrow interfaces and this one implements only the five the application calls, leaving roles, claims, logins and tokens unimplemented. The cost is that email uniqueness is a document the code writes, and a crash between the two writes can leave a claim with no account until a later lookup clears it, with no cascade to take a user's bids when the account goes. An application that needed roles or a delete-account feature would be better served keeping Identity on SQL Server.