The model is identical to F#; the C# interop layer gives you typed entry points so you never touch an
F# function value. Commands and events are C# 15 union types (the framework serializes them
natively).
Compiler requirement. The union keyword is a preview language feature: use the .NET 11
preview SDK (preview 6 or later) with <LangVersion>preview</LangVersion> until C# 15 ships it as
stable. Since .NET 11 preview 6 the compiler support types (UnionAttribute, IUnion) ship in the
box when you target net11.0; on a net10.0 target you must hand-author them. FCQRS packages ship
net10.0 assets and run fine on net11.0 hosts. If your team cannot enable a preview
LangVersion, put the domain (commands, events, aggregates, sagas) in a small F# project — that is
the fully supported stable-compiler path today — and keep the host, endpoints, and projections in C#.
Note that .NET 11's System.Text.Json also serializes unions natively — but caseless (it writes
only the active case's value), which cannot disambiguate same-shaped cases in an event journal.
FCQRS's serializer therefore keeps its own explicit {"$case": ..., "$value": ...} format; it takes
precedence automatically and the journal format does not change on .NET 11 (verified against
preview 6).
public union UserCommand(UserCommand.Register, UserCommand.Login)
{
public record Register(string Username, string Password);
public record Login(string Password);
}
public union UserEvent(
UserEvent.Registered, UserEvent.AlreadyRegistered,
UserEvent.LoginSucceeded, UserEvent.LoginFailed)
{
public record Registered(string Username, string Password);
public record AlreadyRegistered;
public record LoginSucceeded;
public record LoginFailed;
}
|
State is a record; the two functions are switch expressions; EventActions builds the action.
public record UserState(string? Username = null, string? Password = null)
{
public static readonly UserState Initial = new();
}
public EventAction<UserEvent> HandleCommand(
Command<UserCommand> cmd, UserState state) =>
(cmd.CommandDetails, state) switch
{
(UserCommand.Register r, { Username: null }) =>
EventActions.Persist<UserEvent>(
new UserEvent.Registered(r.Username, r.Password)),
(UserCommand.Register, _) =>
EventActions.Defer<UserEvent>(new UserEvent.AlreadyRegistered()),
(UserCommand.Login l, { Username: not null, Password: { } pw })
when l.Password == pw =>
EventActions.Persist<UserEvent>(new UserEvent.LoginSucceeded()),
_ => EventActions.Defer<UserEvent>(new UserEvent.LoginFailed())
};
public UserState ApplyEvent(Event<UserEvent> evt, UserState state) =>
evt.EventDetails switch
{
UserEvent.Registered e =>
state with { Username = e.Username, Password = e.Password },
_ => state
};
public EntityFac<object> Init(IActor actorApi) =>
IActorExtensions.InitActor<UserState, UserCommand, UserEvent>(
actorApi, UserState.Initial, "User", HandleCommand, ApplyEvent);
|
var actorApi = ActorApi.Create(
builder.Configuration, loggerFactory, "Data Source=app.db;", "app");
var factory = entityId => Init(actorApi).RefFor(DEFAULT_SHARD, entityId);
var cid = Helpers.NewCID();
var aggregateId = Helpers.CreateAggregateId("alice");
// subscribe BEFORE sending
using var awaiter = ISubscribeExtensions.SubscribeFor(subs, cid, 1);
await IActorExtensions.SendCommandAsync(
actorApi, factory, cid, aggregateId,
new UserCommand.Register("alice", "s3cret"),
e => e is UserEvent.Registered or UserEvent.AlreadyRegistered);
await awaiter.Task; // read side is current
|
The read side uses QueryApi.InitWithList, and sagas use SagaBuilderCSharp.InitSimple with
SagaCommands.ToOriginator / SagaEventActions / SagaTransitions. A complete C# application —
aggregate, saga, projection, HTTP API, and deployment — is the
focument-csharp sample. Background:
C# interop and serialization.