Evolve persisted events
A document journal originally stored its body in a Text field. The current event calls that field
Content and records a content format. All historical documents in this example contain plain text:
type DocumentSavedV1 = { Id: string; Text: string }
type DocumentSavedV2 = { Id: string; Content: string }
type DocumentSavedV3 = { Id: string; Content: string; Format: string }
let v1ToV2 (old: DocumentSavedV1) : DocumentSavedV2 =
{ Id = old.Id; Content = old.Text }
let v2ToV3 (old: DocumentSavedV2) : DocumentSavedV3 =
{ Id = old.Id; Content = old.Content; Format = "text/plain" }
Fcqrs_450-how-to_009-evolve-events.md_page.DocumentSavedV1Id: stringstringAn abbreviation for the CLI type . Basic Types
Text: stringFcqrs_450-how-to_009-evolve-events.md_page.DocumentSavedV2Content: stringFcqrs_450-how-to_009-evolve-events.md_page.DocumentSavedV3Format: stringv1ToV2: DocumentSavedV1 -> DocumentSavedV2old: DocumentSavedV1v2ToV3: DocumentSavedV2 -> DocumentSavedV3old: DocumentSavedV2public sealed record DocumentSavedV1(string Id, string Text);
public sealed record DocumentSavedV2(string Id, string Content);
public sealed record DocumentSavedV3(string Id, string Content, string Format);
public static class DocumentUpcasters
{
public static DocumentSavedV2 V1ToV2(DocumentSavedV1 old) =>
new(old.Id, old.Text);
public static DocumentSavedV3 V2ToV3(DocumentSavedV2 old) =>
new(old.Id, old.Content, "text/plain");
}
An event upcaster converts a stored payload to the representation current journal consumers use. Here, a chain converts V1 to V2 and then V2 to V3. A stored V2 event needs only the second conversion; a stored V3 event already has the current shape.
The registration APIs on this page require FCQRS 6.5.0 or later.
The default "text/plain" is valid because it describes the old documents. Do not invent a default
that changes the historical meaning. Converters must be deterministic: use the old payload and fixed
compatibility rules, without database queries, external calls, current time, or random values.
Different actors or projections can call the converters concurrently, so they must also be
thread-safe. Pure functions that leave the input and shared state unchanged satisfy both requirements.
Register names and conversions before consumers
Keep all three payload types readable, with distinct stable journal names. A stable name identifies the serialized payload; it is not a request to convert its fields. Keep the old name mapped to the old type so that deserialization can succeed before the upcaster runs.
F# registers stable names before creating the actor system. Register upcasters on the returned API before registering any aggregates, sagas, or projections:
open FCQRS.FSharp
let startRuntime configuration loggerFactory connectionString =
Fcqrs.journalTypes
[ journalType<DocumentSavedV1> "document.saved.v1"
journalType<DocumentSavedV2> "document.saved.v2"
journalType<DocumentSavedV3> "document.saved.v3" ]
let api =
Fcqrs.actor configuration loggerFactory
(Some(Fcqrs.connect FCQRS.Actor.DBType.Sqlite connectionString)) "documents"
let api = Fcqrs.withEventUpcaster<DocumentSavedV1, DocumentSavedV2> api v1ToV2
let api = Fcqrs.withEventUpcaster<DocumentSavedV2, DocumentSavedV3> api v2ToV3
// Register aggregates, sagas, and projections with this API next.
api
FCQRSFCQRS.FSharpIdiomatic-F# functional facade for FCQRS. Gives F# consumers the same one-call ergonomics the C# host-builder (HostExtensions.fs) gives C#, but with F# idioms: records-of-functions for the definitions, typed handles for the results, an explicit wiring pipeline, and plain helpers for saga side effects. It is a *pure addition* that wraps only the existing primitives (IActor.InitializeActor / SagaBuilder.initSimple / Query.init / InitializeSagaStarter / CreateCommandSubscription / Actor.api) and changes nothing in the C# interop layer or the core. open FCQRS.FSharp let api = Fcqrs.actor config loggerFactory (Some (Fcqrs.connect DBType.Sqlite conn)) "Cluster" let documents = Fcqrs.aggregate api { Name="Document"; Initial=...; Decide=...; Fold=... } let slugs = Fcqrs.aggregate api { Name="Slug"; Initial=...; Decide=...; Fold=... } let publication = Fcqrs.saga api (publicationDef documents.Factory slugs.Factory) Fcqrs.wireSagaStarters api [ publication ] let subs = Fcqrs.projection api (Projection.single 0 updateReadModel) // (Projection.multi when you must control which notifications publish) // send a command and await the matching aggregate reply: let! ev = documents.Send (Fcqrs.newCid()) (Fcqrs.aggregateId id) cmd (fun e -> ...)
startRuntime: Extensions.Configuration.IConfiguration -> Extensions.Logging.ILoggerFactory -> string -> FCQRS.Common.IActorconfiguration: Extensions.Configuration.IConfigurationloggerFactory: Extensions.Logging.ILoggerFactoryconnectionString: stringFCQRS.FSharp.FcqrsjournalTypes: (System.Type * string) list -> unitRegister stable journal names for payload types (see JournalTypes): Fcqrs.journalTypes [ journalType<Document.Event> "doc.event"; ... ] Call before the actor system writes anything.
journalType: string -> System.Type * stringA (type, stable-journal-name) pair for Fcqrs.journalTypes.
Fcqrs_450-how-to_009-evolve-events.md_page.DocumentSavedV1Fcqrs_450-how-to_009-evolve-events.md_page.DocumentSavedV2Fcqrs_450-how-to_009-evolve-events.md_page.DocumentSavedV3api: FCQRS.Common.IActoractor: Extensions.Configuration.IConfiguration -> Extensions.Logging.ILoggerFactory -> FCQRS.Actor.Connection option -> string -> FCQRS.Common.IActorCreate the actor system from plain values (cluster name as a string).
SomeThe representation of "Value of type 'T" The input value. An option representing the value.
connect: FCQRS.Actor.DBType -> string -> FCQRS.Actor.ConnectionBuild a SQLite/etc. Connection from a raw connection string (ShortString hidden).
SqliteSQLite using Microsoft.Data.Sqlite provider
FCQRS.ActorFCQRS.Actor.DBTypeRepresents the type of database connection
withEventUpcaster: FCQRS.Common.IActor -> ('Old -> 'New) -> FCQRS.Common.IActorRegister a deterministic, one-to-one historical event conversion for this actor system. Call after creating the actor API and before registering any aggregate, saga, or projection. Chains follow the envelope's declared payload type; duplicate sources and cycles are rejected. FCQRS converts journal events during recovery and projection reads, preserving every envelope field except EventDetails. Stored events, live messages, and application-owned snapshot state remain unchanged. Historical payloads must still deserialize before conversion can run. Converters may run concurrently across consumers and must be thread-safe.
v1ToV2: DocumentSavedV1 -> DocumentSavedV2v2ToV3: DocumentSavedV2 -> DocumentSavedV3using FCQRS;
using Microsoft.Extensions.Hosting;
var builder = Host.CreateApplicationBuilder(args);
var fcqrs = builder.Services
.AddFcqrs(connectionString, "documents")
.WithJournalTypes(types =>
{
types.Type<DocumentSavedV1>("document.saved.v1");
types.Type<DocumentSavedV2>("document.saved.v2");
types.Type<DocumentSavedV3>("document.saved.v3");
})
.WithEventUpcaster<DocumentSavedV1, DocumentSavedV2>(DocumentUpcasters.V1ToV2)
.WithEventUpcaster<DocumentSavedV2, DocumentSavedV3>(DocumentUpcasters.V2ToV3);
// Continue with fcqrs.AddAggregate<...>(), sagas, and a projection before starting the host.
Complete C# builder registrations before building or resolving the host. The builder's configuration
freezes when dependency injection first resolves IActor, and the builder installs its conversions
before any registered consumer starts.
Stable journal names use the existing process-wide registry. Upcasters are scoped to one actor system; configuring another actor system does not add conversions to this one. The actor system's conversion registry freezes when the first aggregate, saga, or projection initializes, so complete any direct runtime registrations before that point too.
Chain resolution follows the payload types, independently of registration order. Each conversion
returns exactly one payload of a different type. Splitting an event, dropping it, or returning null
is unsupported. FCQRS allows one converter per source type and rejects cycles. A converter that
throws or returns null fails the read instead of skipping the event.
Converter failures follow the consumer's failure policy. Aggregate or saga recovery and ordinary
projections terminate the process under FCQRS's fail-fast policy. A transactional projection rolls
back the current event's transaction and faults IProjection.Completion; its checkpoint does not
advance past that event.
The source type is the envelope's declared payload type. For Event<DocumentSavedV1>, register
DocumentSavedV1. For an F# union or C# record hierarchy stored as Event<DocumentEventV1>, register
DocumentEventV1 and convert all of its cases. Registering only a derived C# case does not match an
envelope whose payload parameter is the base type.
Consumers of the example's converted journal events must accept Event<DocumentSavedV3>. The
application chooses which type new command decisions persist; an upcaster does not change writers.
Understand what is converted
FCQRS converts journal events after deserialization, before passing them into these paths:
- aggregate recovery;
- the ordinary and transactional projection handlers;
- historical originator events carried by FCQRS saga journal wrappers, including a starting event embedded in an FCQRS saga snapshot.
The journal rows are unchanged. Conversion preserves the event envelope's ID, creation date, sender, correlation ID, persisted version, and metadata. Projection offsets, persistence IDs, and journal sequence numbers also stay unchanged. One old event still represents one position in its history.
Upcasting does not run for live commands, published replies, or the live Persisted and Deferred
callbacks. It does not change standalone deserialization through the FCQRS serializer. A live
subscriber receiving Event<DocumentSavedV1> still receives that type; registering V1-to-V3 journal
conversions does not make the subscriber understand it.
Without stable names, older rows may carry CLR assembly and type names. Keep those identities resolvable until the old payload has been decoded. For a type move that leaves its serialized shape unchanged, keep the existing stable name mapped to the moved type. For a shape conversion such as V1 to V2 above, retain separate old and new mappings. Never reuse the old name for incompatible data.
Handle snapshots separately
An aggregate snapshot contains state produced by earlier folds. Recovery loads that state and replays only the journal events after the snapshot. Registering an upcaster does not recalculate the history already represented by that state.
Keep the snapshot state shape and meaning compatible, or plan and test a snapshot migration or full
recovery from retained journal history. If the new fold would derive different state from old events,
verify that loading an old snapshot and replaying its remaining events produces the same result as
replaying the complete converted history. NoSnapshots controls future automatic snapshotting; it
does not tell recovery to ignore an existing snapshot.
Application-owned saga data and state also remain unchanged. FCQRS can rebuild its own generic saga wrappers around a converted historical originator event while retaining that application data. This does not migrate the fields or meaning of a saved workflow state.
Choose the change strategy
Use the smallest compatible strategy:
| Change | Strategy |
|---|---|
| New business outcome | Add a new event case |
| New data not required for old history | Add optional data or a new version with a valid historical default |
| Field renamed in source only | Keep its serialized name or provide compatibility decoding |
| A readable old payload needs a new representation | Register a one-to-one upcaster or chain |
| Field meaning changes | Create a new event case or version |
| Type moves namespace or assembly | Keep the registered journal name |
| Old event no longer affects current state | Keep a fold case that applies its historical meaning |
Do not change an old event's meaning to match a new rule. Replay must reproduce the decision that was recorded at the time, not reinterpret history using today's rule.
Keep readers ahead of writers
For a rolling deployment:
- deploy readers and mappings for both old and new event shapes, with the required journal upcasters;
- verify recovery, snapshots, projection replay, and live message compatibility on that version;
- deploy code that starts writing the new shape;
- remove old decoding only after no stored event or active node needs it.
Every participant must understand the live command, event, and saga message shapes exchanged during
the overlap. Journal upcasters do not convert pub-sub traffic or ContinueOrAbort wire types. The
reader rollout therefore needs an application compatibility plan for those messages too; installing
upcasters alone does not make a mixed-version cluster compatible.
Add compatibility tests
Keep representative serialized events from previous releases as fixtures. Check that the old types still deserialize, that V1-to-V2-to-V3 conversion preserves the historical facts, and that direct V2 conversion reaches the same representation.
Replay mixed V1, V2, and V3 histories through aggregate recovery and both projection styles. Assert that envelope identities and versions survive conversion and that each source event produces one result. Raw deserialization tests alone do not exercise the runtime's upcasting path.
Test recovery with old snapshots as well as full journal replay. For sagas, cover old starting-event wrappers and snapshots while checking that workflow data remains compatible. Include mixed-node live messages when the deployment will overlap versions.
Rebuild derived data
When an event remains valid but a projection interpretation changes, leave the journal alone. Deploy the corrected projection and rebuild its read model with the required conversion chain.
An upcaster does not revisit rows a projection has already committed. Stable names identify the stored types; upcasters adapt readable historical payloads, and the application remains responsible for their meaning and snapshot compatibility.