Skip to main content
Version: 0.2.x (0.2.1)

Public identifiers

An identifier that clients see — in a URL, a webhook, a support ticket — has needs of its own. It should say what it identifies, survive being read over the phone, refuse to be mistaken for another kind of identifier, and still make a good primary key. AdCodicem.ValueObjects.Identifiers generates identifiers in the shape made familiar by Stripe: acc_1kcv3ahrz6dmv29gqy5cv.

dotnet add package AdCodicem.ValueObjects.Identifiers
dotnet add package AdCodicem.ValueObjects.Identifiers.EntityFrameworkCore

Declare one​

[EntityId("acc")]
public readonly partial struct AccountId;

That is the whole declaration. AccountId is a value object like any other — the same parsing, JSON, model binding, OpenAPI schema and contract kit — with a few members of its own:

var id = AccountId.New(); // acc_1kcv3ahrz6dmv29gqy5cv, from the ambient clock and a CSPRNG
AccountId.Prefix // "acc"
AccountId.Length // the total width, prefix and separator included

AccountId.TryParse("cus_1kcv3ahrz6dmv29gqy5cv", out _) // false: the prefix belongs to another type

What is in the value​

After the prefix and the separator comes a body in Crockford Base32:

  • a time bucket, so that new identifiers sort after old ones and inserts land at the end of the index rather than all over it. It reveals the creation time at the granularity you choose — Hour by default — and nothing finer;
  • 80 random bits, which is what makes the identifier impossible to guess, whatever the granularity;
  • a check character, which catches any single mistyped character before a query is sent, and covers the prefix too.

Upper case and the look-alikes i, l and o are folded on the way in, so the stored value is canonical and compares ordinally.

Choose the granularity from the insert rate of the table, not from taste:

[EntityId("cus", Granularity = IdGranularity.Minute, Example = "cus_ke1kcv3ahrz6dmv29gqy5cv")]
public readonly partial struct CustomerId;

Store it​

Map identifiers with their own convention, alongside the value object one:

protected override void ConfigureConventions(ModelConfigurationBuilder builder)
{
builder.ConfigureValueObjects(typeof(AccountId).Assembly);
builder.ConfigureEntityIds(typeof(AccountId).Assembly);
}

Because the width is fixed and the alphabet is ASCII, the column is char(n) rather than varchar(n), and never nchar. The prefix is stored with the body on purpose: a raw SQL join between two tables of bare bodies would succeed silently, and one between prefixed values cannot.

A binary collation makes the database compare the way the application does. It is a performance choice, since normalization already made the values canonical:

builder.ConfigureEntityIds(IdCollations.PostgreSql, typeof(AccountId).Assembly); // or IdCollations.SqlServer

Test with them​

New() reads an ambient clock and entropy source, so a test can pin both without injecting a factory into every aggregate:

using (ValueObjectIds.Use(fakeClock, deterministicBytes))
{
var id = AccountId.New(); // the same value on every run
}

The scope follows the execution flow, so tests running in parallel do not see each other's settings.

Accepting any identifier​

A webhook or an audit trail may receive an identifier of any registered kind. AnyEntityId parses whichever prefix arrives, and converts to the concrete type once you know which one it is:

if (AnyEntityId.TryParse(text, provider: null, out var any) && any.TryConvertTo<AccountId>(out var account))
{
}

Entity identifiers explains the format in depth: the widths, the check character, and the alternatives that were turned down.