Step 6: Charge the customer
An order must be paid before it ships. Each customer has an account, and the workflow asks the account to take the money. The account can refuse, and then the order must give its stock back.
This page changes the program from step 5.
Add an account
The account is an aggregate: FCQRS keeps one instance per customer, gives it one command at a time, and stores the events it accepts. Its balance is computed from those events. The CQRS tutorial builds an account step by step; this one has only what the order needs.
Add a file Account.fs (Account.cs):
module Account
open FCQRS.Common
// What the program and the order workflow can ask an account to do.
type AccountCommand =
| Deposit of amount: decimal
| Debit of payment: string * amount: decimal
// What the account replies. Rejected is a reply only: it is never stored.
type AccountEvent =
| Deposited of amount: decimal
| Debited of payment: string * amount: decimal
| Rejected of reason: string
// What the account knows now. Payments holds the payments it has taken, so
// a repeated debit takes the money once.
type AccountState = { Balance: decimal; Payments: Set<string> }
// The state before the account's first event.
let initial = { Balance = 0m; Payments = Set.empty }
Fcqrs_350-workflow-tutorial_006-charge-the-customer.md_page.AccountFCQRSFCQRS.CommonContains common types like Events and Commands Functionality for Write Side.
Fcqrs_350-workflow-tutorial_006-charge-the-customer.md_page.Account.AccountCommandDepositamount: decimaldecimalAn abbreviation for the CLI type . Basic Types
Debitpayment: stringstringAn abbreviation for the CLI type . Basic Types
Fcqrs_350-workflow-tutorial_006-charge-the-customer.md_page.Account.AccountEventDepositedDebitedRejectedreason: stringFcqrs_350-workflow-tutorial_006-charge-the-customer.md_page.Account.AccountStateBalance: decimalPayments: Set<string>Microsoft.FSharp.Collections.FSharpSet`1Immutable sets based on binary trees, where elements are ordered by F# generic comparison. By default comparison is the F# structural comparison function or uses implementations of the IComparable interface on element values. See the module for further operations on sets. All members of this class are thread-safe and may be used concurrently from multiple threads.
initial: AccountStateMicrosoft.FSharp.Collections.SetModuleContains operations for working with values of type .
empty: Set<'T>The empty set for the type 'T. Set.empty<int> Evaluates to set [ ].
using System.Collections.Immutable;
using static FCQRS.Common;
using static FCQRS.CSharp;
// What the program and the order workflow can ask an account to do.
public union AccountCommand(Deposit, Debit);
public sealed record Deposit(decimal Amount);
public sealed record Debit(string Payment, decimal Amount);
// What the account replies. Rejected is a reply only: it is never stored.
public union AccountEvent(Deposited, Debited, Rejected);
public sealed record Deposited(decimal Amount);
public sealed record Debited(string Payment, decimal Amount);
public sealed record Rejected(string Reason);
// What the account knows now. Payments holds the payments it has taken, so
// a repeated debit takes the money once.
public sealed record AccountState(
decimal Balance, ImmutableHashSet<string> Payments);
A command asks the account to do something. An event records what it did, or, as
Rejected, answers without recording anything. In C#, union (new in C# 15) declares a closed set
of cases, as an F# union does.
// Chooses what to do with a command, based on the current state.
let decide (command: Command<AccountCommand>) (state: AccountState) =
match command.CommandDetails with
| Deposit amount -> PersistEvent(Deposited amount)
// A repeated debit gets the first answer. It is checked before the
// balance, because the first debit has already lowered the balance.
| Debit(payment, amount) when state.Payments.Contains payment ->
DeferEvent(Debited(payment, amount))
| Debit(_, amount) when amount > state.Balance ->
DeferEvent(Rejected $"Insufficient funds: {state.Balance} available")
| Debit(payment, amount) -> PersistEvent(Debited(payment, amount))
// Applies one event. A rejection or a repeated reply changes nothing.
let fold (event: Event<AccountEvent>) (state: AccountState) =
match event.EventDetails with
| Deposited amount -> { state with Balance = state.Balance + amount }
| Debited(payment, _) when state.Payments.Contains payment -> state
| Debited(payment, amount) ->
{ state with
Balance = state.Balance - amount
Payments = state.Payments.Add payment
}
| Rejected _ -> state
decide: Command<AccountCommand> -> AccountState -> EventAction<AccountEvent>command: Command<AccountCommand>FCQRS.Common.Command`1Represents a command to be processed by an aggregate actor. <typeparam name="'CommandDetails">The specific type of the command payload.</typeparam>
Fcqrs_350-workflow-tutorial_006-charge-the-customer.md_page.Account.AccountCommandstate: AccountStateFcqrs_350-workflow-tutorial_006-charge-the-customer.md_page.Account.AccountStateCommandDetails: 'CommandDetailsThe specific details or payload of the command.
Depositamount: decimalPersistEventPersist the event to the journal. The actor's state will be updated using the event handler *after* persistence succeeds.
DepositedDebitpayment: stringContains: string -> boolA useful shortcut for Set.contains. See the Set module for further operations on sets. The value to check. True if the set contains value. let set = Set.empty.Add(2).Add(3) printfn $"Does the set contain 1? {set.Contains(1)}" The sample evaluates to the following output: Does the set contain 1? false
Payments: Set<string>DeferEventPublish and fold the event in the live actor without storing it or incrementing the persisted version. A deferred event does not start a saga; running sagas still receive it.
Debited(>): 'T -> 'T -> boolStructural greater-than The first parameter. The second parameter. The result of the comparison. 5 > 1 // Evaluates to true 5 > 5 // Evaluates to false (1, "a") > (1, "z") // Evaluates to false
Balance: decimalRejectedfold: Event<AccountEvent> -> AccountState -> AccountStateevent: Event<AccountEvent>FCQRS.Common.Event`1Represents an event generated by an aggregate actor as a result of processing a command. <typeparam name="'EventDetails">The specific type of the event payload.</typeparam>
Fcqrs_350-workflow-tutorial_006-charge-the-customer.md_page.Account.AccountEventEventDetails: 'EventDetailsThe specific details or payload of the event.
(-): ^T1 -> ^T2 -> ^T3Overloaded subtraction operator The first parameter. The second parameter. The result of the operation. 10 - 2 // Evaluates to 8
Add: string -> Set<string>A useful shortcut for Set.add. Note this operation produces a new set and does not mutate the original set. The new set will share many storage nodes with the original. See the Set module for further operations on sets. The value to add to the set. The result set. let set = Set.empty.Add(1).Add(1).Add(2) printfn $"The new set is: {set}" The sample evaluates to the following output: The new set is: set [1; 2]
public sealed class Account
: Aggregate<AccountState, AccountCommand, AccountEvent>
{
// The name stored with every event of this aggregate.
public override string EntityName => "Account";
// The state before the account's first event.
public override AccountState InitialState => new(0m, []);
// Chooses what to do with a command, based on the current state.
public override EventAction<AccountEvent> HandleCommand(
Command<AccountCommand> command, AccountState state) =>
command.CommandDetails switch
{
Deposit deposit => Store(new Deposited(deposit.Amount)),
// A repeated debit gets the first answer. It is checked before
// the balance, because the first debit has already lowered it.
Debit debit when state.Payments.Contains(debit.Payment) =>
Repeat(new Debited(debit.Payment, debit.Amount)),
Debit debit when debit.Amount > state.Balance =>
Reject($"Insufficient funds: {state.Balance} available"),
Debit debit => Store(new Debited(debit.Payment, debit.Amount))
};
// Applies one event. A rejection or a repeated reply changes nothing.
public override AccountState ApplyEvent(
Event<AccountEvent> stored, AccountState state) =>
stored.EventDetails switch
{
Deposited deposited =>
state with { Balance = state.Balance + deposited.Amount },
Debited debited when state.Payments.Contains(debited.Payment) =>
state,
Debited debited => state with
{
Balance = state.Balance - debited.Amount,
Payments = state.Payments.Add(debited.Payment)
},
Rejected => state
};
// Stores the event and replies with it.
static EventAction<AccountEvent> Store(AccountEvent @event) =>
EventActions.Persist(@event);
// Replies without storing anything.
static EventAction<AccountEvent> Reject(string reason) =>
EventActions.Defer<AccountEvent>(new Rejected(reason));
// Replies with an earlier answer again, without storing it.
static EventAction<AccountEvent> Repeat(AccountEvent @event) =>
EventActions.Defer(@event);
}
decide (HandleCommand in C#) answers a command. PersistEvent stores the event and replies
with it. DeferEvent replies without storing. fold (ApplyEvent) applies a stored event to the
state.
The first rule for Debit is the one a workflow depends on. A workflow can send the same command
twice, as the next sections show. The account recognises a repeat by its payment ID and answers it
with Debited again, without taking the money twice. It checks for the repeat before it checks the
balance: after the first debit, the balance may be too low for a second one, and a repeat answered
with Rejected would tell the workflow that a payment that went through had failed.
In F#, add Account.fs to the project before Order.fs. In C#, the project compiles every .cs
file in the folder, so Orders.csproj does not change:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net11.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<Compile Include="Account.fs" />
<Compile Include="Order.fs" />
<Compile Include="Program.fs" />
<PackageReference Include="FCQRS.Workflows" Version="7.7.0" />
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3"
Version="3.0.2" />
<PackageReference Include="SourceGear.sqlite3" Version="3.50.4.5" />
</ItemGroup>
</Project>
Charge in the workflow
In F#, Order.fs opens two more namespaces: FCQRS.FSharp for the account handle's type, and
Account. Order.cs needs no change here:
module Order
open System
open System.Threading.Tasks
open FCQRS.Common
open FCQRS.FSharp
open FCQRS.Workflows
open Account
// What an order workflow is started with. FCQRS stores it in the journal.
type OrderRequest = {
Customer: string
Item: string
Quantity: int
UnitPrice: decimal
}
// What the workflow returns. FCQRS stores it too.
type OrderResult = {
Status: string
Reservation: string
Shipment: string
Confirmation: Guid
PlacedAt: DateTime
}
Fcqrs_350-workflow-tutorial_006-charge-the-customer.md_page.OrderSystemThreadingTasksFCQRSFCQRS.CommonContains common types like Events and Commands Functionality for Write Side.
FCQRS.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 / Projections.startTracked / 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 -> ...)
WorkflowsFcqrs_350-workflow-tutorial_006-charge-the-customer.md_page.AccountFcqrs_350-workflow-tutorial_006-charge-the-customer.md_page.Order.OrderRequestCustomer: stringstringAn abbreviation for the CLI type . Basic Types
Item: stringQuantity: intintAn abbreviation for the CLI type . Basic Types
UnitPrice: decimaldecimalAn abbreviation for the CLI type . Basic Types
Fcqrs_350-workflow-tutorial_006-charge-the-customer.md_page.Order.OrderResultStatus: stringReservation: stringShipment: stringConfirmation: GuidSystem.GuidRepresents a globally unique identifier (GUID).
PlacedAt: DateTimeSystem.DateTimeRepresents an instant in time, typically expressed as a date and time of day.
The warehouse gets a second operation, which gives back a reservation:
// The warehouse is an external system. The key is the same for every
// attempt of one step, so the warehouse can recognise a repeated call.
type IWarehouse =
abstract Reserve:
key: string * item: string * quantity: int -> Task<string>
abstract Release: key: string * reservation: string -> Task<string>
// The carrier is another external system. It returns a shipment ID.
type ICarrier =
abstract Ship: key: string * reservation: string -> Task<string>
Fcqrs_350-workflow-tutorial_006-charge-the-customer.md_page.Order.IWarehouseReserve: IWarehouse -> string * string * int -> Task<string>keystringAn abbreviation for the CLI type . Basic Types
itemquantityintAn abbreviation for the CLI type . Basic Types
System.Threading.Tasks.Task`1Represents an asynchronous operation that can return a value. The type of the result produced by this .
Release: IWarehouse -> string * string -> Task<string>reservationFcqrs_350-workflow-tutorial_006-charge-the-customer.md_page.Order.ICarrierShip: ICarrier -> string * string -> Task<string>// The warehouse is an external system. The key is the same for every
// attempt of one step, so the warehouse can recognise a repeated call.
public interface IWarehouse
{
Task<string> Reserve(string key, string item, int quantity);
Task<string> Release(string key, string reservation);
}
// The carrier is another external system. It returns a shipment ID.
public interface ICarrier
{
Task<string> Ship(string key, string reservation);
}
Replace the workflow:
Shared setup
// Unchanged from step 2.
let reserveRetry = Retry.times 5
reserveRetry: RetryPolicyFCQRS.Workflows.RetryCommon retry policies.
times: int -> RetryPolicyAt most `attempts` attempts, with backoff from one second to one minute.
// "order" is stored with every instance. It must not change.
let definition
(warehouse: IWarehouse)
(carrier: ICarrier)
(accounts: AggregateHandle<AccountCommand, AccountEvent>)
=
Workflow.define "order" (fun (request: OrderRequest) ->
workflow {
// A failed attempt is not stored. FCQRS tries again until an
// attempt succeeds or the policy gives up.
let! reservation =
activity "reserve-stock" reserveRetry (fun attempt ->
warehouse.Reserve(
attempt.IdempotencyKey, request.Item, request.Quantity))
// The body must give the same answers on every replay, so it
// reads the clock and makes GUIDs only through steps. FCQRS
// stores their values and replays them.
let! placedAt = now
let! confirmation = newGuid
let finish status shipment = {
Status = status
Reservation = reservation
Shipment = shipment
Confirmation = confirmation
PlacedAt = placedAt
}
// Gives the stock back when the order does not ship.
let release =
activity "release-stock" reserveRetry (fun attempt ->
warehouse.Release(attempt.IdempotencyKey, reservation))
// An order over 1000 waits for a person to approve it, for at
// most three seconds in this program. With no answer by then,
// the wait returns None.
let total = request.UnitPrice * decimal request.Quantity
let approvalWait = TimeSpan.FromSeconds 3.0
let! approved =
workflow {
if total <= 1000m then
return true
else
let! answer =
waitSignalFor<bool> "approval" approvalWait
return defaultArg answer false
}
if not approved then
let! _ = release
return finish "not approved" ""
else
// The confirmation number identifies the payment, so a
// repeated debit is recognised as the same payment.
let payment = string confirmation
let! reply =
send accounts request.Customer (Debit(payment, total))
match reply with
| Rejected _ ->
let! _ = release
return finish "payment refused" ""
| _ ->
// Packing takes two seconds. The timer's due time is
// stored, so a restart does not start the wait again.
do! sleep (TimeSpan.FromSeconds 2.0)
let! shipment =
activity "ship" (Retry.times 3) (fun attempt ->
carrier.Ship(attempt.IdempotencyKey, reservation))
return finish "shipped" shipment
})
definition: IWarehouse -> ICarrier -> AggregateHandle<AccountCommand,AccountEvent> -> WorkflowDefinition<OrderRequest,OrderResult>warehouse: IWarehouseFcqrs_350-workflow-tutorial_006-charge-the-customer.md_page.Order.IWarehousecarrier: ICarrierFcqrs_350-workflow-tutorial_006-charge-the-customer.md_page.Order.ICarrieraccounts: AggregateHandle<AccountCommand,AccountEvent>FCQRS.FSharp.AggregateHandle`2What you get back after registering an aggregate.
Fcqrs_350-workflow-tutorial_006-charge-the-customer.md_page.Account.AccountCommandFcqrs_350-workflow-tutorial_006-charge-the-customer.md_page.Account.AccountEventFCQRS.Workflows.WorkflowDefines workflows.
define: string -> ('Input -> Workflow<'Result>) -> WorkflowDefinition<'Input,'Result>A workflow named `name` whose instances run `body` on their input.
request: OrderRequestFcqrs_350-workflow-tutorial_006-charge-the-customer.md_page.Order.OrderRequestworkflow: WorkflowBuilderBuilds a workflow: `workflow { let! x = activity ... }`.
reservation: stringactivity: string -> RetryPolicy -> (AttemptContext -> Task<'T>) -> Workflow<'T>Calls an external system. The call runs at least once: a crash after the call and before its result is stored repeats it, so pass `IdempotencyKey` to the external system. `name` is stored and must not change while instances are running.
reserveRetry: RetryPolicyattempt: AttemptContextReserve: string * string * int -> Task<string>IdempotencyKey: stringThe same for every attempt of this step, across restarts: workflow name, workflow ID, and step path. Pass it to the external system so a repeated call is recognised.
Item: stringQuantity: intplacedAt: DateTimenow: Workflow<DateTime>The current UTC time, stored so that replay returns the same value.
confirmation: GuidnewGuid: Workflow<Guid>A new GUID, stored so that replay returns the same value.
finish: string -> string -> OrderResultstatus: stringshipment: stringStatus: stringReservation: stringShipment: stringConfirmation: GuidPlacedAt: DateTimerelease: Workflow<string>Release: string * string -> Task<string>total: decimalUnitPrice: decimal(*): ^T1 -> ^T2 -> ^T3Overloaded multiplication operator The first parameter. The second parameter. The result of the operation. 8 * 6 // Evaluates to 48
decimal: ^T -> decimalConverts the argument to System.Decimal using a direct conversion for all primitive numeric types. For strings, the input is converted using UInt64.Parse() with InvariantCulture settings. Otherwise the operation requires an appropriate static conversion method on the input type. The input value. The converted decimal. decimal "42.23" // evaluates to 42.23M decimal 0xff // evaluates to 255M decimal -10 // evaluates to -10M
approvalWait: TimeSpanSystem.TimeSpanRepresents a time interval.
FromSeconds: float -> TimeSpanReturns a that represents a specified number of seconds, where the specification is accurate to the nearest millisecond. A number of seconds, accurate to the nearest millisecond. is less than TimeSpan.MinValue or greater than TimeSpan.MaxValue. -or- is . -or- is . is equal to . An object that represents .
approved: bool(<=): 'T -> 'T -> boolStructural less-than-or-equal comparison The first parameter. The second parameter. The result of the comparison. 5 <= 1 // Evaluates to false 5 <= 5 // Evaluates to true [1; 5] <= [1; 6] // Evaluates to true
answer: bool optionwaitSignalFor: string -> TimeSpan -> Workflow<'T option>Waits for a signal named `name` for at most `timeout`, measured from the stored start of the step. Gives `None` when the time passes first; a signal that arrives later stays stored for the next wait.
boolAn abbreviation for the CLI type . Basic Types
defaultArg: 'T option -> 'T -> 'TUsed to specify a default value for an optional argument in the implementation of a function An option representing the argument. The default value of the argument. The argument value. If it is None, the defaultValue is returned. type Vector(x: double, y: double, ?z: double) = let z = defaultArg z 0.0 member this.X = x member this.Y = y member this.Z = z let v1 = Vector(1.0, 2.0) v1.Z // Evaluates to 0. let v2 = Vector(1.0, 2.0, 3.0) v2.Z // Evaluates to 3.0
``not``: bool -> boolNegate a logical value. Not True equals False and not False equals True The value to negate. The result of the negation. not (2 + 2 = 5) // Evaluates to true // not is a function that can be compose with other functions let fileDoesNotExist = System.IO.File.Exists >> not
payment: stringstring: 'T -> stringConverts the argument to a string using ToString. For standard integer and floating point values and any type that implements IFormattable, ToString conversion uses CultureInfo.InvariantCulture. The input value. The converted string. string 'A' // evaluates to "A" string 0xff // evaluates to "255" string -10 // evaluates to "-10"
reply: AccountEventsend: AggregateHandle<'Command,'Event> -> string -> 'Command -> Workflow<'Event>`sendWith` under `Retry.command`: send again when no reply came in time, with backoff, for five minutes.
Customer: stringDebitRejectedsleep: TimeSpan -> Workflow<unit>Waits for `duration`. The due time is stored when the step is first reached, so a restart does not postpone it.
FCQRS.Workflows.RetryCommon retry policies.
times: int -> RetryPolicyAt most `attempts` attempts, with backoff from one second to one minute.
Ship: string * string -> Task<string>// "order" is stored with every instance. It must not change.
public sealed class OrderWorkflow(
IWarehouse warehouse, ICarrier carrier, AggregateFactory accounts)
: Workflow<OrderRequest, OrderResult>("order")
{
// Up to five attempts, 200 ms apart.
private static readonly RetryPolicy ReserveRetry = Retry.times(5)
.WithSchedule(RetrySchedules.Fixed(TimeSpan.FromMilliseconds(200)));
public override async WorkflowTask<OrderResult> Run(
WorkflowContext ctx, OrderRequest request)
{
// A failed attempt is not stored. FCQRS tries again until an
// attempt succeeds or the policy gives up.
var reservation = await ctx.Activity("reserve-stock", ReserveRetry,
attempt => warehouse.Reserve(
attempt.IdempotencyKey, request.Item, request.Quantity));
// The body must give the same answers on every replay, so it
// reads the clock and makes GUIDs only through steps. FCQRS
// stores their values and replays them.
var placedAt = await ctx.Now();
var confirmation = await ctx.NewGuid();
OrderResult Finish(string status, string shipment) =>
new(status, reservation, shipment, confirmation, placedAt);
// Gives the stock back when the order does not ship.
Step<string> Release() =>
ctx.Activity("release-stock", ReserveRetry, attempt =>
warehouse.Release(attempt.IdempotencyKey, reservation));
// An order over 1000 waits for a person to approve it, for at
// most three seconds in this program. With no answer by then,
// Received is false.
var total = request.UnitPrice * request.Quantity;
if (total > 1000m)
{
var answer = await ctx.WaitSignal<bool>(
"approval", TimeSpan.FromSeconds(3));
if (!(answer.Received && answer.Value))
{
await Release();
return Finish("not approved", "");
}
}
// The confirmation number identifies the payment, so a repeated
// debit is recognised as the same payment.
var payment = confirmation.ToString();
var reply = await ctx.Send<AccountCommand, AccountEvent>(
accounts, request.Customer, new Debit(payment, total));
if (reply is Rejected)
{
await Release();
return Finish("payment refused", "");
}
// Packing takes two seconds. The timer's due time is stored, so
// a restart does not start the wait again.
await ctx.Sleep(TimeSpan.FromSeconds(2));
var shipment = await ctx.Activity("ship", Retry.times(3),
attempt => carrier.Ship(attempt.IdempotencyKey, reservation));
return Finish("shipped", shipment);
}
}
send accounts request.Customer (Debit(payment, total)) (ctx.Send in C#) is a command step.
It sends Debit to the customer's account and gives back the event the account replied with. A
Rejected reply is the account's verdict, not a failed step: the step succeeds, and the body
decides what a refusal means for the order.
The step's result is stored like an activity's, so a replay does not send the command again. If the process stops after the step began and before the reply was stored, FCQRS sends the command again, with the same correlation ID, and the account's repeat rule answers it.
The step waits up to five minutes for a reply, and while it waits it sends the command again,
with the same correlation ID, each time an attempt times out. With no reply by then, it fails as
OutcomeUnknown. That is not
a refusal: the account may have taken the money, and its reply may have been lost.
Step 7 shows how a body catches a failed step.
release-stock undoes reserve-stock when the order does not ship, either because nobody approved
it or because the account refused. Such an undo step is called compensation. It is an ordinary
activity with its own key, so the warehouse must recognise a repeated release as well.
Run the account and the new orders
In F#, Program.fs opens FCQRS.Common for the aggregate's settings, and Account:
open System
open System.Collections.Generic
open System.IO
open System.Threading
open Microsoft.Data.Sqlite
open Microsoft.Extensions.Configuration
open Microsoft.Extensions.Logging
open FCQRS.Actor
open FCQRS.Common
open FCQRS.FSharp
open FCQRS.Workflows
open Account
open Order
let database = Path.Combine(AppContext.BaseDirectory, "orders.db")
// FCQRS logs through Microsoft.Extensions.Logging; this program stays quiet.
let logging = LoggerFactory.Create(fun _ -> ())
// FCQRS reads optional settings from IConfiguration; this program sets none.
let configuration = ConfigurationBuilder().Build()
// The journal is a SQLite file next to the program.
let connection = Fcqrs.connect DBType.Sqlite $"Data Source={database};"
let api = Fcqrs.actor configuration logging (Some connection) "orders"
SystemCollectionsGenericIOThreadingMicrosoftDataSqliteExtensionsConfigurationLoggingFCQRSFCQRS.ActorFCQRS.CommonContains common types like Events and Commands Functionality for Write Side.
FCQRS.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 / Projections.startTracked / 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 -> ...)
WorkflowsFcqrs_350-workflow-tutorial_006-charge-the-customer.md_page.AccountFcqrs_350-workflow-tutorial_006-charge-the-customer.md_page.Orderdatabase: stringSystem.IO.PathPerforms operations on instances that contain file or directory path information. These operations are performed in a cross-platform manner.
Combine: string * string -> stringCombines two strings into a path. The first path to combine. The second path to combine. .NET Framework and .NET Core versions older than 2.1: or contains one or more of the invalid characters defined in . or is . The combined paths. If one of the specified paths is a zero-length string, this method returns the other path. If contains an absolute path, this method returns .
System.AppContextProvides members for setting and retrieving data about an application's context.
BaseDirectory: stringGets the file path of the base directory that the assembly resolver uses to probe for assemblies. The file path of the base directory that the assembly resolver uses to probe for assemblies.
logging: ILoggerFactoryMicrosoft.Extensions.Logging.LoggerFactoryProduces instances of classes based on the given providers.
Create: Action<ILoggingBuilder> -> ILoggerFactoryCreates new instance of configured using provided delegate. A delegate to configure the . The that was created.
configuration: IConfigurationRoot``.ctor``: unit -> unitBuild: unit -> IConfigurationRootBuilds an with keys and values from the set of providers registered in . An with keys and values from the registered providers.
connection: ConnectionFCQRS.FSharp.Fcqrsconnect: DBType -> string -> ConnectionBuild a SQLite/etc. Connection from a raw connection string of any length.
FCQRS.Actor.DBTypeRepresents the type of database connection
SqliteSQLite using Microsoft.Data.Sqlite provider
api: IActoractor: IConfiguration -> ILoggerFactory -> Connection option -> string -> 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.
Replace the stand-in warehouse. It now releases, once per key:
// A stand-in for the warehouse. It is busy for the first two calls with a
// key. It remembers keys, so a repeated call returns the first reservation.
type Warehouse() =
let calls = Dictionary<string, int>()
let reservations = Dictionary<string, string>()
let releases = HashSet<string>()
interface IWarehouse with
member _.Reserve(key, item, quantity) =
task {
calls[key] <- (match calls.TryGetValue key with
| true, count -> count + 1
| _ -> 1)
if calls[key] <= 2 then
printfn $" warehouse busy (key {key})"
invalidOp "The warehouse is busy"
if not (reservations.ContainsKey key) then
reservations[key] <- $"r-{reservations.Count + 1}"
printfn
$" warehouse reserved {quantity} {item} (key {key})"
return reservations[key]
}
member _.Release(key, reservation) =
task {
if releases.Add key then
printfn $" warehouse released {reservation} (key {key})"
return reservation
}
Fcqrs_350-workflow-tutorial_006-charge-the-customer.md_page.Warehousecalls: Dictionary<string,int>``.ctor``: unit -> unitInitializes a new instance of the class that is empty, has the default initial capacity, and uses the default equality comparer for the key type.
stringAn abbreviation for the CLI type . Basic Types
intAn abbreviation for the CLI type . Basic Types
reservations: Dictionary<string,string>releases: HashSet<string>``.ctor``: unit -> unitInitializes a new instance of the class that is empty and uses the default equality comparer for the set type.
Fcqrs_350-workflow-tutorial_006-charge-the-customer.md_page.Order.IWarehouse_: WarehouseReserve: Warehouse -> string * string * int -> Tasks.Task<string>key: stringitem: stringquantity: inttask: TaskBuilderBuilds a task using computation expression syntax.
Item: intGets or sets the value associated with the specified key. The key of the value to get or set. is . The property is retrieved and does not exist in the collection. The value associated with the specified key. If the specified key is not found, a get operation throws a , and a set operation creates a new element with the specified key.
TryGetValue: string * byref<int> -> boolGets the value associated with the specified key. The key of the value to get. When this method returns, contains the value associated with the specified key, if the key is found; otherwise, the default value for the type of the parameter. This parameter is passed uninitialized. is . if the contains an element with the specified key; otherwise, .
count: int(+): ^T1 -> ^T2 -> ^T3Overloaded addition operator The first parameter. The second parameter. The result of the operation. 2 + 2 // Evaluates to 4 "Hello " + "World" // Evaluates to "Hello World"
(<=): 'T -> 'T -> boolStructural less-than-or-equal comparison The first parameter. The second parameter. The result of the comparison. 5 <= 1 // Evaluates to false 5 <= 5 // Evaluates to true [1; 5] <= [1; 6] // Evaluates to true
printfn: Printf.TextWriterFormat<'T> -> 'TPrint to stdout using the given format, and add a newline. The formatter. The formatted result. See Printf.printfn (link: ) for examples.
invalidOp: string -> 'TThrow a exception The exception message. The result value. type FileReader(fileName: string) = let mutable isOpen = false member this.Open() = if isOpen then invalidOp "File is already open" // ... Here we may open the file ... isOpen <- true let reader = FileReader("journal.txt") reader.Open() // Executes fine reader.Open() // Throws System.InvalidOperationException: File is already open
``not``: bool -> boolNegate a logical value. Not True equals False and not False equals True The value to negate. The result of the negation. not (2 + 2 = 5) // Evaluates to true // not is a function that can be compose with other functions let fileDoesNotExist = System.IO.File.Exists >> not
ContainsKey: string -> boolDetermines whether the contains the specified key. The key to locate in the . is . if the contains an element with the specified key; otherwise, .
Item: stringGets or sets the value associated with the specified key. The key of the value to get or set. is . The property is retrieved and does not exist in the collection. The value associated with the specified key. If the specified key is not found, a get operation throws a , and a set operation creates a new element with the specified key.
Release: Warehouse -> string * string -> Tasks.Task<string>reservation: stringAdd: string -> boolAdds the specified element to a set. The element to add to the set. if the element is added to the object; if the element is already present.
// A stand-in for the warehouse. It is busy for the first two calls with a
// key. It remembers keys, so a repeated call returns the first reservation.
public sealed class Warehouse : IWarehouse
{
private readonly Dictionary<string, int> calls = new();
private readonly Dictionary<string, string> reservations = new();
private readonly HashSet<string> releases = new();
public Task<string> Reserve(string key, string item, int quantity)
{
calls[key] = calls.GetValueOrDefault(key) + 1;
if (calls[key] <= 2)
{
Console.WriteLine($" warehouse busy (key {key})");
throw new InvalidOperationException("The warehouse is busy");
}
if (!reservations.ContainsKey(key))
{
reservations[key] = $"r-{reservations.Count + 1}";
Console.WriteLine(
$" warehouse reserved {quantity} {item} (key {key})");
}
return Task.FromResult(reservations[key]);
}
public Task<string> Release(string key, string reservation)
{
if (releases.Add(key))
Console.WriteLine(
$" warehouse released {reservation} (key {key})");
return Task.FromResult(reservation);
}
}
Replace the registration. The program registers the account before the workflow, because registering the workflow resumes stored orders at once, and they may send commands:
Shared setup
// Unchanged from step 5.
type Carrier(crash: bool) =
interface ICarrier with
member _.Ship(_, _) = Tasks.Task.FromResult "s-1"
Fcqrs_350-workflow-tutorial_006-charge-the-customer.md_page.Carriercrash: boolboolAn abbreviation for the CLI type . Basic Types
Fcqrs_350-workflow-tutorial_006-charge-the-customer.md_page.Order.ICarrier_: CarrierShip: Carrier -> string * string -> Tasks.Task<string>TasksFromResult: 'TResult -> Tasks.Task<'TResult>Creates a that's completed successfully with the specified result. The result to store into the completed task. The type of the result returned by the task. The successfully completed task.
System.Threading.Tasks.TaskRepresents an asynchronous operation.
// Register the account rules; the workflow sends commands to them.
let accounts =
Fcqrs.aggregate api {
Name = "Account"
Initial = initial
Decide = decide
Fold = fold
Snapshots = Default
Passivation = PassivationPolicy.Default
}
// Finish the aggregate startup. This program has no sagas.
Fcqrs.wireSagaStarters api []
// Run the program with --crash to stop it in the middle of shipping.
let crash = Environment.GetCommandLineArgs() |> Array.contains "--crash"
// Registering the workflow also resumes the orders that were running when
// the process last stopped.
let orders =
Workflows.register api (definition (Warehouse()) (Carrier crash) accounts)
accounts: AggregateHandle<AccountCommand,AccountEvent>FCQRS.FSharp.Fcqrsaggregate: IActor -> Aggregate<'State,'Command,'Event> -> AggregateHandle<'Command,'Event>Register an aggregate and return its typed handle. Calling this IS the registration (it initializes the sharding region).
api: IActorName: stringInitial: 'Stateinitial: AccountStateDecide: Command<'Command> -> 'State -> EventAction<'Event>handleCommand (decide): command + current state -> what to do.
decide: Command<AccountCommand> -> AccountState -> EventAction<AccountEvent>Fold: Event<'Event> -> 'State -> 'StateapplyEvent (fold): event + current state -> next state (pure).
fold: Event<AccountEvent> -> AccountState -> AccountStateSnapshots: SnapshotPolicySnapshot cadence: Default (config / 30), NoSnapshots, or Every n.
DefaultUse the global config (config:akka:persistence:snapshot-version-count), or 30.
Passivation: PassivationPolicyIdle passivation: PassivationPolicy.Default (configuration, then Akka's 120s), After an idle period, or Never.
FCQRS.Common.PassivationPolicyIdle passivation for an aggregate type, set per entity at registration. Passivation stops an idle actor and releases its in-memory state; the next command recovers it from the journal. Only messages routed through cluster sharding count as activity. Sagas ignore this: their shard regions remember entities, which disables idle passivation in Akka.NET. A saga stops at StopSaga or abort instead.
DefaultUse configuration: `akka.cluster.sharding.<EntityName>.passivate-idle-entity-after`, then `akka.cluster.sharding.passivate-idle-entity-after`, then Akka.NET's 120s.
wireSagaStarters: IActor -> SagaHandle list -> unitWire every registered saga into one saga-starter (or the empty starter if none). Call after the aggregates + sagas are registered.
crash: boolSystem.EnvironmentProvides information about, and means to manipulate, the current environment and platform. This class cannot be inherited.
GetCommandLineArgs: unit -> string arrayReturns a string array containing the command-line arguments for the current process. The system does not support command-line arguments. An array of strings where each element contains a command-line argument. The first element is the executable file name, and the following zero or more elements contain the remaining command-line arguments.
(|>): 'T1 -> ('T1 -> 'U) -> 'UApply a function to a value, the value being on the left, the function on the right The argument. The function. The function result. let doubleIt x = x * 2 3 |> doubleIt // Evaluates to 6
Microsoft.FSharp.Collections.ArrayModuleContains operations for working with arrays. See also F# Language Guide - Arrays.
contains: 'T -> 'T array -> boolTests if the array contains the specified element. The value to locate in the input array. The input array. True if the input array contains the specified element; false otherwise. Thrown when the input array is null. [| 1; 2 |] |> Array.contains 2 // evaluates to true [| 1; 2 |] |> Array.contains 5 // evaluates to false
orders: WorkflowHandle<OrderRequest,OrderResult>FCQRS.Workflows.WorkflowsRegisters workflows with an actor system.
register: IActor -> WorkflowDefinition<'Input,'Result> -> WorkflowHandle<'Input,'Result>Registers the workflow and returns its handle. Instances that were running when the process last stopped resume as soon as this is called.
definition: IWarehouse -> ICarrier -> AggregateHandle<AccountCommand,AccountEvent> -> WorkflowDefinition<OrderRequest,OrderResult>``.ctor``: unit -> Warehouse``.ctor``: bool -> Carrier// Register the account rules; the workflow sends commands to them.
var accounts = new Account().Init(actor);
// Finish the aggregate startup. This program has no sagas.
ActorWiring.InitSagaStarterEmpty(actor);
// Run the program with --crash to stop it in the middle of shipping.
var crash = args.Contains("--crash");
// Registering the workflow also resumes the orders that were running when
// the process last stopped.
var orders = WorkflowHost.Register(actor, new OrderWorkflow(
new Warehouse(), new Carrier(crash), accounts.Factory));
Replace the code that starts the orders. It puts money in two accounts and places four orders:
// Put money in a customer's account.
let deposit customer amount =
accounts.Send
(Fcqrs.newCid ()) (Fcqrs.aggregateId customer) (Deposit amount)
(fun _ -> true)
|> Async.RunSynchronously
|> ignore
printfn $"Deposited {amount} to {customer}"
// Start an order. A start with an ID that is already in the journal stores
// nothing and returns the order's status.
let start id request =
match orders.Start id request |> Async.RunSynchronously with
| StartResult.Started -> printfn $"Started {id}"
| StartResult.AlreadyStarted status ->
printfn $"{id} is already started ({status})"
// Start an order, send it an approval if given one, and wait for its end.
let place id customer quantity approval =
let request = {
Customer = customer
Item = "widget"
Quantity = quantity
UnitPrice = 40m
}
start id request
match approval with
| Some approve ->
Workflows.signal orders id "approval" approve |> Async.RunSynchronously
| None -> ()
let outcome =
orders.Result id (TimeSpan.FromSeconds 30.0) |> Async.RunSynchronously
match outcome with
| WorkflowOutcome.Finished result when result.Status = "shipped" ->
printfn $"{id} finished: shipped as {result.Shipment}"
| WorkflowOutcome.Finished result ->
printfn $"{id} finished: {result.Status}"
| other -> printfn $"{id} ended: {other}"
deposit "ada" 1500m
deposit "bob" 50m
place "o-1" "ada" 2 None
// 30 widgets at 40 cost 1200. Ada approves, and her account pays.
place "o-2" "ada" 30 (Some true)
// 50 widgets cost 2000. Nobody approves this order.
place "o-3" "bob" 50 None
// 2 widgets cost 80, and Bob has 50.
place "o-4" "bob" 2 None
deposit: string -> decimal -> unitcustomer: stringamount: decimalaccounts: AggregateHandle<AccountCommand,AccountEvent>Send: FCQRS.Model.Data.CID -> FCQRS.Model.Data.AggregateId -> 'Command -> ('Event -> bool) -> Async<Event<'Event>>Send a command and await the first matching aggregate event. Fails with SagaAlreadyStartedException, with nothing stored, when the command would start a saga that its correlation ID already started on this aggregate.
FCQRS.FSharp.FcqrsnewCid: unit -> FCQRS.Model.Data.CIDA fresh correlation id (UUID v7).
aggregateId: string -> FCQRS.Model.Data.AggregateIdAn aggregate id from a string (e.g. a document/user key). Any non-blank id works: the shard names entity actors Uri.EscapeDataString(entityId), so characters Akka actor names would reject directly (spaces, %) are escaped before they reach an actor path.
Deposit(|>): 'T1 -> ('T1 -> 'U) -> 'UApply a function to a value, the value being on the left, the function on the right The argument. The function. The function result. let doubleIt x = x * 2 3 |> doubleIt // Evaluates to 6
Microsoft.FSharp.Control.FSharpAsyncHolds static members for creating and manipulating asynchronous computations. See also F# Language Guide - Async Workflows. Async Programming
RunSynchronously: Async<'T> * int option * CancellationToken option -> 'TRuns the asynchronous computation and await its result. If an exception occurs in the asynchronous computation then an exception is re-raised by this function. If no cancellation token is provided then the default cancellation token is used. The computation is started on the current thread if is null, has of true, and no timeout is specified. Otherwise the computation is started by queueing a new work item in the thread pool, and the current thread is blocked awaiting the completion of the computation. The timeout parameter is given in milliseconds. A value of -1 is equivalent to . The computation to run. The amount of time in milliseconds to wait for the result of the computation before raising a . If no value is provided for timeout then a default of -1 is used to correspond to . The cancellation token to be associated with the computation. If one is not supplied, the default cancellation token is used. The result of the computation. Starting Async Computations printfn "A" let result = async { printfn "B" do! Async.Sleep(1000) printfn "C" 17 } |> Async.RunSynchronously printfn "D" Prints "A", "B" immediately, then "C", "D" in 1 second. result is set to 17.
ignore: 'T -> unitIgnore the passed value. This is often used to throw away results of a computation. The value to ignore. ignore 55555 // Evaluates to ()
printfn: Printf.TextWriterFormat<'T> -> 'TPrint to stdout using the given format, and add a newline. The formatter. The formatted result. See Printf.printfn (link: ) for examples.
start: string -> OrderRequest -> unitid: stringrequest: OrderRequestorders: WorkflowHandle<OrderRequest,OrderResult>Start: string -> 'Input -> Async<StartResult>Starts the workflow with this ID. Returns once the start is stored. A second start with the same ID stores nothing and reports the existing instance.
FCQRS.Workflows.StartResultThe answer to a start request.
StartedThis request started the workflow.
AlreadyStartedA workflow with this ID already exists. Nothing was stored.
status: WorkflowStatusplace: string -> string -> int -> 'a option -> unitquantity: intapproval: 'a optionCustomer: stringItem: stringQuantity: intUnitPrice: decimalSomeThe representation of "Value of type 'T" The input value. An option representing the value.
approve: 'aFCQRS.Workflows.WorkflowsRegisters workflows with an actor system.
signal: WorkflowHandle<'Input,'Result> -> string -> string -> 'Signal -> Async<unit>Stores the signal `name` with a typed payload for the instance `id`.
NoneThe representation of "No value"
outcome: WorkflowOutcome<OrderResult>Result: string -> TimeSpan -> Async<WorkflowOutcome<'Result>>Waits up to the given time for the instance to end. The wait is in memory: the workflow continues when the caller stops waiting. Raises `TimeoutException` when the time passes, and `InvalidOperationException` when no workflow with this ID was started.
System.TimeSpanRepresents a time interval.
FromSeconds: float -> TimeSpanReturns a that represents a specified number of seconds, where the specification is accurate to the nearest millisecond. A number of seconds, accurate to the nearest millisecond. is less than TimeSpan.MinValue or greater than TimeSpan.MaxValue. -or- is . -or- is . is equal to . An object that represents .
FCQRS.Workflows.WorkflowOutcome`1How a workflow ended.
FinishedThe body returned this value.
result: OrderResultStatus: string(=): 'T -> 'T -> boolStructural equality The first parameter. The second parameter. The result of the comparison. 5 = 5 // Evaluates to true 5 = 6 // Evaluates to false [1; 2] = [1; 2] // Evaluates to true (1, 5) = (1, 6) // Evaluates to false
other: WorkflowOutcome<OrderResult>// Put money in a customer's account.
async Task Deposit(string customer, decimal amount)
{
await accounts.Handler(_ => true, Values.NewCID(),
Values.CreateAggregateId(customer), new Deposit(amount));
Console.WriteLine($"Deposited {amount} to {customer}");
}
// Start an order. A start with an ID that is already in the journal stores
// nothing and returns false.
async Task Start(string id, OrderRequest request)
{
if (await orders.StartAsync(id, request))
Console.WriteLine($"Started {id}");
else
Console.WriteLine(
$"{id} is already started ({await orders.StatusAsync(id)})");
}
// Start an order, send it an approval if given one, and wait for its end.
async Task Place(string id, string customer, int quantity, bool? approval)
{
await Start(id, new OrderRequest(customer, "widget", quantity, 40m));
if (approval is bool approve)
await orders.SignalAsync(id, "approval", approve);
var result = await orders.ResultAsync(id, TimeSpan.FromSeconds(30));
Console.WriteLine(result.Status == "shipped"
? $"{id} finished: shipped as {result.Shipment}"
: $"{id} finished: {result.Status}");
}
await Deposit("ada", 1500m);
await Deposit("bob", 50m);
await Place("o-1", "ada", 2, null);
// 30 widgets at 40 cost 1200. Ada approves, and her account pays.
await Place("o-2", "ada", 30, true);
// 50 widgets cost 2000. Nobody approves this order.
await Place("o-3", "bob", 50, null);
// 2 widgets cost 80, and Bob has 50.
await Place("o-4", "bob", 2, null);
Run it
Delete bin/Debug/net11.0/orders.db, then run dotnet run:
Deposited 1500 to ada
Deposited 50 to bob
Started o-1
warehouse busy (key order/o-1/0)
warehouse busy (key order/o-1/0)
warehouse reserved 2 widget (key order/o-1/0)
carrier shipped r-1 (key order/o-1/5)
o-1 finished: shipped as s-1
Started o-2
warehouse busy (key order/o-2/0)
warehouse busy (key order/o-2/0)
warehouse reserved 30 widget (key order/o-2/0)
carrier shipped r-2 (key order/o-2/6)
o-2 finished: shipped as s-2
Started o-3
warehouse busy (key order/o-3/0)
warehouse busy (key order/o-3/0)
warehouse reserved 50 widget (key order/o-3/0)
warehouse released r-3 (key order/o-3/4)
o-3 finished: not approved
Started o-4
warehouse busy (key order/o-4/0)
warehouse busy (key order/o-4/0)
warehouse reserved 2 widget (key order/o-4/0)
warehouse released r-4 (key order/o-4/4)
o-4 finished: payment refused
Journal of order/default-shard/o-1:
1 Started
2 StepScheduled
3 StepCompleted
4 StepCompleted
5 StepCompleted
6 StepScheduled
7 StepCompleted
8 StepScheduled
9 StepCompleted
10 StepScheduled
11 StepCompleted
12 Finished
o-1ando-2were paid from Ada's 1500, which leaves her 220. The payment step took position 3 ino-1and 4 ino-2, after the approval, so the carrier's keys end in 5 and 6.o-3was not approved, and its stock was released.o-4costs 80 and Bob has 50. The account refused the debit, and the stock was released.
o-1's journal has two more records than in step 5: records 6 and 7 are the payment step, a
StepScheduled when the command was sent and a StepCompleted with the reply. The packing timer
(8 and 9) and ship (10 and 11) follow it.
Next
Step 7: When a step fails for good handles a step whose attempts run out.