C# interop and serialization
FCQRS is implemented in F#, but its architecture is not specific to F# syntax. A C# application still has commands, events, immutable state, a decision function, a fold, projections, and sagas.
The important interop question is not “how do I call an F# method?” It is “how do I preserve the same closed message model and durable serialized contracts in C#?”
Motivation: Language interop should change the surface syntax, not weaken the domain model. Closed cases and stable event representations protect the same decisions and histories in either language.
Start from the language-independent model
Suppose a document supports two requests and four outcomes:
commands: Create | Edit
events: Created | Edited | AlreadyExists | NoSuchDocument
Those are closed sets. The aggregate must handle each possible command and event case. Adding a case should produce a compiler-visible place to update decisions, folds, tests, and serialization.
F# discriminated unions express the model directly. Current C# compilers have two practical paths.
Path 1: stable C# with record hierarchies
The registration quickstart uses stable .NET 10 C#. Ordinary derived records represent the message cases. For example, the creation and editing events share a base record:
[JsonPolymorphic(TypeDiscriminatorPropertyName = "$case")]
[JsonDerivedType(typeof(DocumentCreated), "created")]
[JsonDerivedType(typeof(DocumentEdited), "edited")]
public abstract record DocumentEvent;
public sealed record DocumentCreated(Document Document) : DocumentEvent;
public sealed record DocumentEdited(string Id, string Content) : DocumentEvent;
This excerpt needs System.Text.Json.Serialization; a hierarchy must register each derived event case. The discriminator names are serialized contracts. Keep each case registered and
test serialization through the base type, which is the type the journal envelope carries.
An aggregate derives from Aggregate<TState,TCommand,TEvent>, implements HandleCommand, and
implements ApplyEvent. Pattern matching selects the derived case. C# does not enforce an exhaustive
closed set for this hierarchy, so a new case needs explicit decision, fold, projection, and test updates.
Run samples/registration-csharp
for an example with one command and event type. It needs no polymorphic event hierarchy.
Evolve persisted events covers serialized compatibility when adding cases.
Path 2: C# union types for closed cases
Some reference and how-to examples use the optional C# union syntax:
public union DocumentCommand(DocumentCommand.Create, DocumentCommand.Edit)
{
public record Create(Document Document);
public record Edit(string Id, string Content);
}
public union DocumentEvent(
DocumentEvent.Created,
DocumentEvent.Edited,
DocumentEvent.AlreadyExists,
DocumentEvent.NoSuchDocument)
{
public record Created(Document Document);
public record Edited(string Id, string Content);
public record AlreadyExists;
public record NoSuchDocument;
}
At the time of writing, the union keyword requires a .NET 11 preview SDK and
<LangVersion>preview</LangVersion>. FCQRS targets net10.0 and can be referenced by a newer host.
The C# how-to tracks the exact compiler setup used by these examples.
If preview language features are not acceptable, use concrete C# message types or place the closed domain model in a small F# class library while keeping the host, endpoints, and infrastructure in C#.
The C# aggregate preserves decide and fold
The interop API uses virtual methods instead of curried F# functions:
public sealed class DocumentAggregate
: Aggregate<DocumentState, DocumentCommand, DocumentEvent>
{
public override DocumentState InitialState => DocumentState.Initial;
public override string EntityName => "Document";
public override EventAction<DocumentEvent> HandleCommand(
Command<DocumentCommand> command,
DocumentState state) => /* decide */;
public override DocumentState ApplyEvent(
Event<DocumentEvent> stored,
DocumentState state) => /* fold */;
}
EventActions constructs persist, defer, ignore, and batch actions. Hosting extensions register
aggregates, sagas, the saga starter, projections, and the runtime in dependency injection order. The
surface is idiomatic C#, while the recovery and consistency model remains the same.
Envelopes carry framework context
Application payloads are wrapped in Command<T> and Event<T>. The envelopes carry identity and
coordination data such as message id, aggregate id, correlation id, creation time, version, and
metadata.
Domain code should switch on CommandDetails or EventDetails and read envelope values only when the
decision genuinely needs them. Tests can create envelopes with the C# test helpers instead of starting
an actor system.
Serialized events outlive the code that wrote them
Commands travel between nodes, and persisted events remain in the journal across deployments. Their serialized form is therefore part of the system's durable contract.
FCQRS registers System.Text.Json support for F# records and unions. Its C# union converter writes an explicit representation like:
{ "$case": "Created", "$value": { "document": { "id": "doc-1" } } }
The case discriminator matters. Approved and Rejected might both carry a string, but equal field
shapes do not give them equal domain meaning.
Do not casually rename persisted event cases, change their meaning, remove required fields, or replace the serializer with a representation that cannot distinguish cases. New application code must still read every retained journal event needed for recovery and rebuilds.
WithEventUpcaster<OldPayload, NewPayload> adapts an already-readable payload on FCQRS journal reads.
Its source type must match the envelope's declared payload type, including a base record or union
when the event is stored through that type. The converter can feed another converter in a chain.
Evolve persisted events covers registration, live-message limits, and
snapshot compatibility.
Keep mixed-language boundaries boring
A practical mixed solution can use:
- an F# project for domain values, unions, decisions, and folds;
- a C# host for dependency injection, HTTP endpoints, database access, and projections;
- shared event contracts referenced by both.
Keep F#-specific composition behind small functions or interfaces rather than exposing complex curried functions to every C# caller. FCQRS's C# builders and base classes already provide this adapter for the runtime.
Choose based on team and contract needs
Use stable concrete C# types when they express the current message set clearly. Use C# union syntax when the preview compiler is acceptable and exhaustive closed cases improve the model. Use an F# domain library when discriminated unions and functional composition are valuable but the surrounding application belongs in C#.
The architecture and persistence responsibilities are identical in all three options. The choice is about source representation, not a different FCQRS runtime.
Run the C# getting-started project, then follow Use FCQRS from C# for aggregates, hosting, commands, and isolated tests. Read Evolve persisted events before changing a deployed message contract.