Mappings
An entity mapping is Wallaby's unit of routing: it declares how one entity type is captured, transformed and delivered to the destination in a sink. Mappings are declared per sink inside WithMappings(...):
sink.Map<Product>()
.ToDestination("products")
.WithBackfillVersion("v1")
.UsingTransform(/* ... */);A mapping's core components:
- Entity:
Map<T>()declares the backing table for capture and routes its changes to the enclosing sink. The same entity may be mapped under several sinks as each mapping runs its own transform. - Destination:
ToDestination(...)names where documents land within the sink: a search index, a table, an endpoint route - whatever the sink maps it to. - Transform:
UsingTransform(...)specifies how a batch of entity changes into the destination documents. See below. - Backfill version:
WithBackfillVersion(...)re-snapshots the table when the version changes, so destinations are rebuilt whenever the output shape changes. See Backfill. - Provider extensions:
DependsOn(...)re-emits an entity when a related table changes, and per-row scoping routes each row through tenant-specific contexts and destinations.
Transforms
A transform turns the changes for one entity type into the documents you want in a destination. For trivial shaping, pass a lambda:
sink.Map<Product>()
.ToDestination("products")
.UsingTransform((_, changes, _) =>
{
var docs = new Dictionary<DocumentKey, WallabyDocument?>(changes.Count);
foreach (var c in changes)
docs[c.Key] = new WallabyDocument { ["name"] = c.Entity!.Name };
return Task.FromResult<IReadOnlyDictionary<DocumentKey, WallabyDocument?>>(docs);
});For more complex transforms, or anything with dependencies, implement your provider's transform interface as a class - IWallabyEfTransform<T> (EF Core) or IWallabyMartenTransform<T> (Marten) - and register it with UsingTransform<TEntity, TTransform>(). This class is registered & resolved from the container.
Transforms can also enrich documents with vector embeddings for semantic search - see RAG & Embeddings.
Mapping classes
Inline mappings grow the AddWallaby callback and can make your Program.cs unwieldy. Move each mapping into a class implementing IWallabyEntityMapping<TEntity>. This is also convenient if you want to store your mappings alongside the transform it wires up:
public sealed class ProductSearchMapping : IWallabyEntityMapping<Product>
{
public void Configure(EntityMapBuilder<Product> map) => map
.ToDestination("products")
.WithBackfillVersion("v1")
.UsingTransform<Product, ProductSearchTransform>();
}Apply it by type:
.WithMappings(sink => sink
.Apply<ProductSearchMapping>()
.Apply<CategorySearchMapping>());For a mapping that needs constructor arguments, pass an instance directly: sink.Apply(new ProductSearchMapping(indexName)).
Internals
Transforms are batch-invoked: you receive all the insert/update/read changes for the entity in a commit (or a backfill chunk) and return one document per source key. This lets you resolve many keys in a single round-trip. Return a null document or omit the key to delete that key's document from the sink: an omitted key is a delete, never a skip, so a transform must return an entry for every change it wants to keep.
TIP
Deletes never reach a transform as the row is already gone. The engine deletes by key directly, using the mapping's id rule. Your transform only sees inserts, updates, and backfill reads.
Document ids
Documents are keyed by the source primary key by default; KeyedBy(...) derives the id from the entity instead:
sink.Map<Product>()
.ToDestination("products")
.KeyedBy(p => p.Sku);Because the engine deletes by key, the custom id must also be computable when the row is gone. A KeyedBy mapping therefore requires REPLICA IDENTITY FULL on its table, and self-configuration fails at startup when it is missing (with the DDL to run).
With full identity, EF Core materializes the deleted entity from the old row's values, and Marten rehydrates the deleted document from the old tuple's data. If a delete still arrives without an entity, it fails with an error rather than falling back to the primary key. The same applies to an entity-derived ScopedBy paired with ScopedDestination (deletes must resolve their destination); the ChangeEvent overload of ScopedBy reads captured columns instead and carries no such requirement.
Return a tuple for a composite id, e.g. KeyedBy(p => (p.TenantId, p.Sku)). When several rows select the same id, the last change in commit order wins within a batch.
Id format
Every sink receives the same canonical id, built from the key values:
| Key | Id |
|---|---|
| Single value | The value itself: 42, 3f2b8c1e-…, sku-1 |
| Composite key | Values joined with |: tenant-a|42 |
DateTime / DateTimeOffset / TimeOnly | ISO 8601 round-trip form: 2026-09-17T10:30:15.1230000Z |
DateOnly | 2026-09-17 |
byte[] | Lowercase hex |
null | Empty |
A % or | inside a value is escaped as %25 or %7C, so two keys of one table never share an id. DocumentKey.SplitId(id) turns an id back into its values. Tables that share a destination share one id space, so give them distinct ids (for example with KeyedBy) when their keys can overlap.
Sinks whose ids have a restricted alphabet re-encode this id reversibly; see Meilisearch.
Documents
A document is a WallabyDocument, simply a field bag keyed by destination field name. It derives from Dictionary<string, object?>, so it supports the usual initializer syntax alongside a slightly more fluent one:
var doc = new WallabyDocument { ["name"] = product.Name, ["price"] = product.Price };
// or fluent:
var doc2 = new WallabyDocument().Set("name", product.Name).Set("price", product.Price);Sinks consume the document as an IReadOnlyDictionary<string, object?>.
The change event
Each ChangeEvent<TEntity> exposes:
| Member | Description |
|---|---|
Entity | The current row materialized as TEntity (non-null for insert/update/read). |
Record | Current column values keyed by EF property name. |
Changes | Previous values of changed columns (updates), subject to REPLICA IDENTITY. |
PrimaryKey / Key | The source primary key, and its DocumentKey. |
GetPrimaryKey<TKey>() | The single-column key cast to TKey; throws for a composite key. |
Metadata | Action, IsBackfill, CommitTimestamp, CommitLsn, table name. |
Per-row scoping
When the enrichment context or the destination depends on the row's own data (e.g. a TenantId), see multi-tenancy for EF Core (ScopedBy / UseScopedDbContext / ScopedDestination) or Marten (ScopedByTenant / UseTenantSessions).