Snapshots and Tombstones
The current state of one aggregate, published for a Module that keeps its own copy of it, and the Tombstone that says it no longer exists.
What it is
A Snapshot is the current state of one aggregate, published for other Modules, and it supersedes every earlier Snapshot of that aggregate. A Tombstone is the Snapshot that says the aggregate no longer exists.
An Integration Event is a fact, and each one matters on its own. A Snapshot is state, and only the newest matters: once a newer one is applied, an older one is worth nothing. One aggregate may have both, as Catalog's product does.
Feature code never publishes a Snapshot
The Unit of Work publishes it, in the commit of every change of the aggregate, built by a mapper you write. The receiver applies the newest version it is given and skips older ones, so a Snapshot handler keeps a copy current: it never counts or reacts to each change.
When to use it
When another Module keeps its own copy of this Module's data, as Inventory keeps a copy of each catalog product. When another Module reacts to something that happened, publish an Integration Event instead.
How it works
Declare the Snapshot
A public record in the publishing Module's Contracts, with the aggregate's key, the state other Modules need, a field that says whether it is the Tombstone, and the aggregate's version.
/// <summary>
/// The current state of one product, which supersedes every earlier Snapshot of it: <paramref name="Version"/> grows
/// with every change Catalog commits, so apply it only when it is newer than the one applied. When
/// <paramref name="IsRemoved"/> is true it is the product's Tombstone: the product has left the catalog. Catalog publishes
/// it under the wire name <c>catalog.product-snapshot.v1</c>; a change other Modules could not read is a new version,
/// not an edit of this one.
/// </summary>
public sealed record ProductSnapshotV1(Guid ProductId, string Name, bool IsRemoved, long Version);Write its mapper
Map builds the Snapshot of an aggregate that exists, and Tombstone the Snapshot of one the Operation removed. The mapper runs after every reaction, with the version being committed, and only reads.
// The product as other Modules see it. The Kit calls it once for every product an Operation changed, after every
// reaction ran and the version is the one being committed; it only reads.
internal sealed class ProductSnapshotMapper : ISnapshotMapper<Product, ProductSnapshotV1>
{
public ProductSnapshotV1 Map(Product product) =>
new(product.Id.Value, product.Name, IsRemoved: false, product.Version);
public ProductSnapshotV1 Tombstone(Product product) =>
new(product.Id.Value, product.Name, IsRemoved: true, product.Version);
}Declare it in the Composition Root
The publishing Module declares the aggregate, the Snapshot type, the mapper and the wire name with PublishSnapshot, as Catalog declares ProductSnapshotV1: see Declaring a Snapshot. The receiving Module subscribes an ordinary Integration Event Handler with Subscribe.
Keep the copy
Write only the effect: insert or update for a Snapshot, delete for a Tombstone. The copy is a table of the receiving Module's own that no aggregate maps, written with plain SQL in the receiving Module's Unit of Work. The handler compares no version: the Kit runs it only for a newer one.
// Keeps Inventory's copy of each catalog product current, in a table of its own that is no aggregate: it holds what
// Catalog last published and nothing Inventory decides, keyed by tenant and product under the tenant the Snapshot was
// delivered for. Only the effect: the Kit runs this only for a Snapshot newer than every one applied of that product,
// so a late, duplicated or concurrent older one never arrives here, and the copy simply follows each one, deleted on the
// Tombstone.
internal sealed class CopyProductIntegrationEventHandler(
IUnitOfWorkConnection unitOfWork,
OperationContext context
) : IIntegrationEventHandler<ProductSnapshotV1>
{
public async Task<Result> Handle(
ProductSnapshotV1 snapshot,
CancellationToken cancellationToken
)
{
var (connection, transaction) = unitOfWork.Join();
var sql = snapshot.IsRemoved
? "delete from inventory.catalog_products where tenant = @Tenant and product_id = @ProductId"
: """
insert into inventory.catalog_products (tenant, product_id, name, version) values (@Tenant, @ProductId, @Name, @Version)
on conflict (tenant, product_id) do update set name = excluded.name, version = excluded.version
""";
await connection.ExecuteAsync(
new CommandDefinition(
sql,
new
{
context.Tenant,
snapshot.ProductId,
snapshot.Name,
snapshot.Version,
},
transaction,
cancellationToken: cancellationToken
)
);
return Result.Success();
}
}The receiving Module's migration creates the copy's table and the table where the Kit keeps the versions applied, with CreateSnapshotVersionTable, beside its Receipt table.
What the Kit guarantees
| Guarantee | Stated in |
|---|---|
| One per changed aggregate, per Snapshot type, per Operation, built from what commits | What the Kit guarantees for a Snapshot |
| Applied only when newer, whatever the order of delivery | What the Kit guarantees for a Snapshot |
| No copy before the aggregate's next change | What the Kit guarantees for a Snapshot |
| What a mapper and an aggregate's key must keep to | The rules of a mapper and a key |
| A Snapshot of a tenant-scoped Module's aggregate | Declaring a tenant-scoped Module |