ข้ามไปยังเนื้อหา

Identifiers

เนื้อหานี้ยังไม่ได้แปลเป็นภาษาไทย แสดงเป็นภาษาอังกฤษแทน

CephalonEngine ships an opinionated default for database identifiers (Sfid) but doesn’t lock you in. This page covers all four supported strategies, the trade-offs, and how to plug a custom one in.

PackageMaturityWhat it ships
Cephalon.Ids.SfidM3Low-ceremony database ids backed by Sfid.Net. EF Core value converter, JSON converter, model-builder extension.

The Sfid type itself comes from Sfid.Net (a small Sfid spec library). Cephalon.Ids.Sfid is the engine integration: EF Core converter, optional IdStrategy.Sfid registration, and the IIdGenerator<Sfid> service for non-EF cases.

CephalonEngine recognises four Engine:Data:IdStrategy values:

StrategySortable?SizeURL-safeWhen to pick
Sfid (default)✅ k-sortable16 bytes (binary) / 26 chars (text)✅ Crockford base32Most new tables. Good index locality + global uniqueness + URL-safe.
Guid (v7)✅ k-sortable16 bytes❌ (use hex / base64)When you need standard GUID format for interop with external systems or you’re migrating from a Guid-keyed schema.
Long✅ insert-order8 bytes✅ digitsAppend-mostly tables where the DB-issued sequence is fine. Smaller footprint than 16-byte ids.
CustomdependsdependsdependsWhen you have an existing id format (Twitter-snowflake, Hilo, K-Sortable Unique IDentifier, NanoID) you want to preserve.

Sfid (the Sfid.Net implementation of the Sfid spec) gives you:

  • k-sortable — ids generated within the same millisecond cluster together. Excellent for B-tree indexes (avoids page-split storms common with random Guids).
  • Globally unique — 48-bit timestamp + 80-bit random suffix. Collisions astronomically unlikely.
  • Compact text form — 26 characters using Crockford base32 (0-9, A-Z minus I L O U). Case-insensitive, URL-safe, unambiguous when read aloud.
  • Type-safeSfid is its own value type — never confused with an arbitrary string. The compiler catches “passing a UserId where OrderId is expected” if you use strong-typed id wrappers.
01HQ8RVKSXM4Y8RBVPDC0E9YHZ
└┬─────────┘└┬───────────┘
│ │
│ └─ 80-bit randomness
└─ 48-bit unix-ms timestamp (k-sortable)
appsettings.json
{
"Engine": {
"Data": {
"IdStrategy": "Sfid" // Sfid | Guid | Long | Custom
}
}
}

Sometimes a single app uses different strategies for different tables (e.g. Sfid for new entities, Long for an existing legacy table). Override at the entity level:

ProductsDbContext.cs
public sealed class ProductsDbContext : DbContext
{
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// Sfid for new entities (engine default)
modelBuilder.Entity<Product>().UseSfidPrimaryKey();
modelBuilder.Entity<Catalog>().UseSfidPrimaryKey();
// Long for the legacy invoice table
modelBuilder.Entity<LegacyInvoice>().HasKey(i => i.InvoiceNumber);
}
}
Product.cs
public sealed class Product
{
public Sfid Id { get; init; }
public string Sku { get; init; } = string.Empty;
public decimal Price { get; init; }
}

The Sfid value type is a readonly struct (16 bytes on the stack). It behaves like a primitive in every way except the API — equality, comparison, and hash code are based on the underlying bytes.

Value converter (auto-wired by UseSfidPrimaryKey)

Section titled “Value converter (auto-wired by UseSfidPrimaryKey)”

Cephalon.Ids.Sfid registers a converter that stores Sfids as BINARY(16) (Postgres bytea, SQL Server BINARY(16), MySQL BINARY(16), MongoDB binary subtype 0x04). This is the most compact + index-friendly storage.

If you’d rather store the text form:

ProductsDbContext.cs
modelBuilder.Entity<Product>()
.Property(p => p.Id)
.HasConversion(new SfidToStringConverter()); // 26-char base32

By default, the engine generates Sfids client-side at SaveChanges. To control this:

// Inject the generator anywhere
public sealed class CreateProductHandler(IIdGenerator<Sfid> ids)
{
public Sfid Handle(string sku, decimal price)
{
var product = new Product { Id = ids.NewId(), Sku = sku, Price = price };
// ... persist
return product.Id;
}
}

IIdGenerator<Sfid> is registered as a singleton — safe to inject anywhere.

The default System.Text.Json converter renders Sfids as base32 strings:

{ "id": "01HQ8RVKSXM4Y8RBVPDC0E9YHZ", "sku": "WIDGET-1", "price": 12.50 }

For Newtonsoft.Json interop, add Cephalon.Ids.Sfid.Newtonsoft (separate package) and call JsonConvert.DefaultSettings += s => s.Converters.Add(new SfidJsonConverter()).

The default. Nothing to configure beyond IdStrategy: "Sfid" (which is implicit).

ProductsDbContext.cs
modelBuilder.Entity<Product>().UseSfidPrimaryKey();

Generated ids look like 01HQ8RVKSXM4Y8RBVPDC0E9YHZ in URLs, JSON, logs, and DB queries.

You’re talking to an external service that issues UUIDv7s. Pick Guid:

appsettings.json
{ "Engine": { "Data": { "IdStrategy": "Guid" } } }

EF Core’s default Guid handling kicks in. CephalonEngine doesn’t replace EF’s Guid generation — it just steps out of the way.

Product.cs
public sealed class Product
{
public Guid Id { get; init; }
public string Sku { get; init; } = string.Empty;
}
Use UUIDv7, not v4. Random Guids (v4) destroy B-tree index locality, causing page-split storms in Postgres / SQL Server. EF Core 9+ supports Guid.CreateVersion7() natively. Set builder.Property(p => p.Id).HasDefaultValueSql(“uuidv7()”) on Postgres 18+, or generate v7 ids client-side.
{ "Engine": { "Data": { "IdStrategy": "Long" } } }
AuditLog.cs
public sealed class AuditLog
{
public long Id { get; init; } // DB-issued (IDENTITY / SERIAL / AUTO_INCREMENT)
public DateTime CreatedAt { get; init; }
public string Action { get; init; } = string.Empty;
}

EF Core wires up IDENTITY (SQL Server / MySQL / Oracle) or BIGSERIAL (Postgres) automatically. Inserts are batchable and the smallest possible (8 bytes vs 16).

Trade-offs:

ProCon
Smallest index footprintNot globally unique — id only meaningful in one DB
Append-friendlyCan’t generate client-side without DB round-trip
Trivial to share with humansPredictable — don’t use in URLs without ACL guard

A real app often mixes strategies. The setup:

appsettings.json
{ "Engine": { "Data": { "IdStrategy": "Sfid" } } } // default

Then override per entity:

ProductsDbContext.cs
public sealed class ProductsDbContext : DbContext
{
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// New entities: Sfid (engine default — no override needed)
modelBuilder.Entity<Product>().UseSfidPrimaryKey();
modelBuilder.Entity<Catalog>().UseSfidPrimaryKey();
// High-volume append table: Long
modelBuilder.Entity<AuditLog>().HasKey(a => a.Id);
modelBuilder.Entity<AuditLog>().Property(a => a.Id).ValueGeneratedOnAdd();
// External integration: Guid v7 (issued upstream)
modelBuilder.Entity<ExternalOrder>().HasKey(o => o.UpstreamId);
}
}

For Twitter-style snowflake ids (used at Discord, X, others):

appsettings.json
{ "Engine": { "Data": { "IdStrategy": "Custom" } } }

Register your generator:

Program.cs
builder.Services.AddSingleton<IIdGenerator<long>>(new SnowflakeGenerator(machineId: 1));
SnowflakeGenerator.cs
public sealed class SnowflakeGenerator(int machineId) : IIdGenerator<long>
{
private long _sequence;
private long _lastTimestamp;
private readonly object _lock = new();
public long NewId()
{
lock (_lock)
{
var timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
// ... snowflake bit-packing
return (timestamp << 22) | ((long)machineId << 12) | _sequence++;
}
}
}

The engine doesn’t care about the custom format — your generator owns it. The IdStrategy: "Custom" flag just tells the runtime not to auto-register Sfid / Guid / Long generators.

Common pattern to prevent “passing OrderId where UserId is expected” bugs:

OrderId.cs
public readonly record struct OrderId(Sfid Value)
{
public static OrderId New() => new(Sfid.NewSfid());
public override string ToString() => Value.ToString();
public static implicit operator Sfid(OrderId id) => id.Value;
}
Order.cs
public sealed class Order
{
public OrderId Id { get; init; }
public UserId CustomerId { get; init; }
}
OrdersDbContext.cs
modelBuilder.Entity<Order>()
.Property(o => o.Id)
.HasConversion(id => id.Value, value => new OrderId(value));

Now customerService.GetById(orderId) fails to compile — you must convert explicitly.

StrategyGeneration costStorage costIndex localityDB round-trip
Sfid~50ns (RNG + clock)16 bytes★★★★★ (k-sortable)None (client-side)
Guid v7~30ns16 bytes★★★★☆ (k-sortable)None (client-side or uuidv7())
Guid v4~30ns16 bytes★ (random)None
Long (DB-issued)trivial8 bytes★★★★★ (sequential)1 round-trip on insert
Snowflake~50ns8 bytes★★★★★ (k-sortable)None (client-side)

Numbers are illustrative; actual cost depends on hardware. The “Index locality” stars correlate with insert throughput on Postgres / SQL Server — random Guids can cut insert throughput by 10× on large tables.

  • Sfid is 16 bytes, the same as a Guid. There’s no storage saving over Guid v7. The benefit is k-sortability + URL-safe text form + type safety.
  • Sfid doesn’t carry tenant identity. Don’t try to encode tenant id in the Sfid suffix — use the tenancy capability (Cephalon.MultiTenancy) for tenant resolution.
  • Don’t index on the text form. Always store and index the binary form; the text representation is for URLs, logs, and JSON.
  • Client-side generated ids can collide if you rewind the clock. Make sure your servers don’t NTP-jump backwards more than a few seconds at a time.
  • Sfid is not a UUID. It’s wire-compatible with binary(16) but the text format is different. Don’t try to Parse a UUID string as a Sfid.
  • IIdGenerator<Sfid> is a singleton. Don’t register a per-request or scoped variant — it’d serialise insert workloads.
  • You need a 64-bit id for legacy compatibility. Use Long (DB-issued) or a Snowflake Long.
  • Your platform issues UUIDv7s as the canonical id format. Use Guid. (Especially common with Postgres 18 + uuidv7().)
  • You need a smaller-than-16-byte id in a write-heavy table. Consider Long.
  • The team is allergic to non-standard id formats. Use Guid v7 — same k-sortability, more familiar shape.
  • PK column name: Id for the entity’s primary key. EntityNameId for foreign keys (e.g. CustomerId).
  • Strong-typed wrapper: EntityNameId (no Sfid suffix). The implementation detail of “it wraps an Sfid” doesn’t belong in the type name.
  • JSON property casing: lowercase id, even when the C# property is Id. Standard ASP.NET JSON convention.
  • Migrating Guid v4 → Sfid is feasible but expensive. Add a new Sfid column, backfill in batches, swap PKs, drop the old column. Plan for read-only downtime during the swap.
  • Migrating Guid v4 → Guid v7 is cheaper. Same column type, new ids only. Old ids stay as-is. Index locality improves over time as old rows get pruned.
  • Long → Sfid is hard because foreign-key relations need updating. Usually only worth it if you’re moving away from a single-DB monolith.
  • Sfids round-trip cleanly through logs / traces / OTel because they’re plain strings. Search for an Sfid in your log aggregator and you’ll find every mention.
  • Sfid.Parse(string).Timestamp gives you the creation moment — useful for “when was this row created?” forensics without needing a CreatedAt column.
  • ToString() on a Sfid is allocation-free in .NET 10 thanks to ISpanFormattable. Safe to use in hot paths.
Don’tDo
Use Guid v4 for new tablesUse Sfid (engine default) or Guid v7
Store Sfid as VARCHAR(26)Store as BINARY(16) — half the size, better index locality
Pass raw string IDs through your domainWrap in EntityIdSfid strong-typed records
Generate ids on the database for every entityGenerate client-side (Sfid / Guid v7 / Snowflake) so you have the id before insert — enables event sourcing, idempotency keys, etc.
Use Sfid + created_at columnSfid already encodes the timestamp; drop the duplicate column unless you need DST-aware local time
Compose Sfids with random suffixes (“{sfid}-v2”)If you need to disambiguate, use a versioned domain entity, not a versioned id