Skip to main content

Identity Map

The Identity Map Store provides a write-side authoritative mapping between external identifiers and internal aggregate IDs. It sits in the integration boundary between your domain and external systems.

When to Use

ScenarioExample
CDC IngestionLegacy system sends transactions by ExternalTransactionId -- you need to map to your Transaction aggregate ID
Cross-Aggregate ReferenceCreating a Transaction from a command that contains AccountNumber -- you need the Account aggregate's ID
Anti-Corruption LayerAn API receives ERP codes and must resolve them to domain aggregate IDs before dispatching commands
Idempotent ImportEnsuring the same external record is not imported twice as separate aggregates

Installation

dotnet add package Excalibur.Data.IdentityMap.SqlServer

Registration

services.AddIdentityMap(identity =>
{
identity.UseSqlServer(sql =>
{
sql.ConnectionString(connectionString)
.SchemaName("dbo") // default
.TableName("IdentityMap") // default
.MaxBatchSize(200); // default: 100
});
});

Or via the unified Excalibur builder:

services.AddExcalibur(excalibur =>
{
excalibur.AddIdentityMap(identity =>
identity.UseSqlServer(sql => sql.ConnectionString(connectionString)));
});

For testing, use the in-memory provider:

services.AddInMemoryIdentityMap();

Core Operations

Resolve

Look up an internal aggregate ID from an external identifier:

// String result
string? aggregateId = await identityMap.ResolveAsync(
"LegacyCore", externalTransactionId, "Transaction", ct);

// Typed result (Guid, int, long, etc.)
Guid? transactionId = await identityMap.ResolveAsync<Guid>(
"LegacyCore", externalTransactionId, "Transaction", ct);

Bind (Upsert)

Create or update a mapping:

await identityMap.BindAsync(
"LegacyCore", externalTransactionId, "Transaction",
newTransactionId.ToString(), ct);

// Typed convenience
await identityMap.BindAsync<Guid>(
"LegacyCore", externalTransactionId, "Transaction", newTransactionId, ct);

TryBind (Insert-if-not-exists)

Atomically bind only if no mapping exists. Returns the existing mapping on conflict:

var result = await identityMap.TryBindAsync(
"LegacyCore", externalId, "Transaction",
Guid.NewGuid().ToString(), ct);

if (result.WasCreated)
{
// New mapping -- create the aggregate
}
else
{
// Mapping already existed -- result.AggregateId is the existing one
}

Unbind

Remove a mapping:

bool removed = await identityMap.UnbindAsync(
"LegacyCore", externalId, "Transaction", ct);

Batch Resolve

Resolve multiple external IDs in one call (uses chunked IN clauses, no TVPs):

var resolved = await identityMap.ResolveBatchAsync(
"LegacyCore",
[txnId1, txnId2, txnId3],
"Transaction", ct);

// resolved is IReadOnlyDictionary<string, string>
// Keys are external IDs, values are aggregate IDs

Composite Keys

When an external system identifies records by multiple fields, use CompositeKey:

using Excalibur.Data.IdentityMap;

// Two-part key: ClientNo + AccountNo uniquely identifies an Account
var key = CompositeKey.Create("ClientNo", clientNo, "AccountNo", accountNo);

var accountId = await identityMap.ResolveAsync<Guid>(
"LegacyCore", key, "Account", ct);

CompositeKey.Create produces a deterministic, pipe-delimited string like CLIENTNO=C-123|ACCOUNTNO=A-456. Names are normalized to uppercase, values are trimmed, and pipe characters within values are escaped.

Overloads

// Two parts
CompositeKey.Create("Name1", value1, "Name2", value2);

// Three parts
CompositeKey.Create("Name1", value1, "Name2", value2, "Name3", value3);

// Arbitrary parts
CompositeKey.Create(
("Branch", branchCode),
("Dept", deptCode),
("EmpId", empId));

Real-World Example: CDC Transaction Ingestion

public class TransactionCdcHandler : IDataChangeHandler
{
private readonly IIdentityMapStore _identityMap;
private readonly IEventSourcedRepository<Transaction, Guid> _repo;

public async Task HandleAsync(DataChange change, CancellationToken ct)
{
var legacyTxnId = change.GetValue<string>("TransactionId");
var legacyAccountKey = CompositeKey.Create(
"ClientNo", change.GetValue<string>("ClientNo"),
"AccountNo", change.GetValue<string>("AccountNo"));

// 1. Resolve the Account aggregate
var accountId = await _identityMap.ResolveAsync<Guid>(
"LegacyCore", legacyAccountKey, "Account", ct)
?? throw new InvalidOperationException(
$"No Account mapped for {legacyAccountKey}");

// 2. Idempotent bind for the Transaction
var bind = await _identityMap.TryBindAsync(
"LegacyCore", legacyTxnId, "Transaction",
Guid.NewGuid().ToString(), ct);

if (bind.WasCreated)
{
// First time -- create the Transaction aggregate
var txn = Transaction.Create(
Guid.Parse(bind.AggregateId),
accountId.Value,
change.GetValue<decimal>("Amount"));
await _repo.SaveAsync(txn, ct);
}
else
{
// Already imported -- reconcile
var txn = await _repo.LoadAsync(Guid.Parse(bind.AggregateId), ct);
txn.Reconcile(change.GetValue<decimal>("Amount"));
await _repo.SaveAsync(txn, ct);
}
}
}

SQL Server Schema

The SQL Server provider requires a single table (no TVPs, no stored procedures):

CREATE TABLE [dbo].[IdentityMap] (
ExternalSystem NVARCHAR(128) NOT NULL,
ExternalId NVARCHAR(256) NOT NULL,
AggregateType NVARCHAR(256) NOT NULL,
AggregateId NVARCHAR(256) NOT NULL,
CreatedAt DATETIMEOFFSET NOT NULL DEFAULT SYSUTCDATETIME(),
UpdatedAt DATETIMEOFFSET NOT NULL DEFAULT SYSUTCDATETIME(),

CONSTRAINT PK_IdentityMap PRIMARY KEY CLUSTERED
(ExternalSystem, ExternalId, AggregateType),
INDEX IX_IdentityMap_AggregateId (AggregateType, AggregateId)
);

The schema and table name are configurable. A migration script is included in the package at Scripts/CreateIdentityMapTable.sql.

Architecture

┌──────────────────────────────────────────────────┐
│ CDC Handler / Command Handler / API Controller │
│ │
│ "I have an external ID, I need an aggregate ID" │
└──────────────────┬───────────────────────────────┘


┌──────────────────────────────────────────────────┐
│ IIdentityMapStore │
│ │
│ ResolveAsync BindAsync TryBindAsync │
│ UnbindAsync ResolveBatchAsync │
└──────────────────┬───────────────────────────────┘

┌────────┼────────┐
▼ ▼ ▼
SqlServer InMemory (future)

The identity map is not a read model or projection. It is a write-side infrastructure concern:

  • Event Store: source of truth for domain events
  • Identity Map: source of truth for external-to-internal ID translation
  • Projections: source of truth for read/query UX only