Step 2: Withdraw money
In step 1 the account stored every command it received. This step adds withdrawals and the rules a bank needs: an account opens once, only an open account takes money, amounts are positive, and a withdrawal cannot take more than the balance. A command that breaks a rule gets a reply that says why, and nothing is stored.
The same feature in a CRUD application
A CRUD application reads the balance, checks it in application code, and writes the new balance. Two withdrawals of 60 from a balance of 70 that arrive together can both pass the check:
-- Both requests read the balance before either one writes.
SELECT balance FROM accounts WHERE id = 'alice'; -- first: 70
SELECT balance FROM accounts WHERE id = 'alice'; -- second: 70
-- Both check 60 <= 70 in application code, then subtract.
UPDATE accounts SET balance = balance - 60 WHERE id = 'alice'; -- 10
UPDATE accounts SET balance = balance - 60 WHERE id = 'alice'; -- -50
Each check was correct when it ran, and the balance still ends at -50. The usual fixes are a row lock
(SELECT ... FOR UPDATE), a version column that makes the second update fail, or a conditional
UPDATE ... WHERE balance >= 60 whose affected-row count says whether the withdrawal happened. Every
code path that changes the balance has to use the same fix.
In FCQRS, every command for Alice's account goes to the same aggregate instance, and the instance handles one command at a time. The second withdrawal is decided after the first one is stored, against a balance of 10.
Name the messages
module Account
open FCQRS.Common
// What a caller can ask an account to do.
type AccountCommand =
| Open of owner: string
| Deposit of amount: decimal
| Withdraw of amount: decimal
// What the account replies. Rejected is a reply only: it is never stored.
type AccountEvent =
| Opened of owner: string
| Deposited of amount: decimal
| Withdrawn of amount: decimal
| Rejected of reason: string
// What the account knows now, rebuilt from its events.
type AccountState = { Owner: string option; Balance: decimal }
// The state before the account's first event.
let initial = { Owner = None; Balance = 0m }
Fcqrs_250-tutorial_002-withdraw-money.md_page.AccountFCQRSFCQRS.CommonContains common types like Events and Commands Functionality for Write Side.
Fcqrs_250-tutorial_002-withdraw-money.md_page.Account.AccountCommandOpenowner: stringstringAn abbreviation for the CLI type . Basic Types
Depositamount: decimaldecimalAn abbreviation for the CLI type . Basic Types
WithdrawFcqrs_250-tutorial_002-withdraw-money.md_page.Account.AccountEventOpenedDepositedWithdrawnRejectedreason: stringFcqrs_250-tutorial_002-withdraw-money.md_page.Account.AccountStateOwner: string optionoptionThe type of optional values. When used from other CLI languages the empty option is the null value. Use the constructors Some and None to create values of this type. Use the values in the Option module to manipulate values of this type, or pattern match against the values directly. 'None' values will appear as the value null to other CLI languages. Instance methods on this type will appear as static methods to other CLI languages due to the use of null as a value representation. Options
Balance: decimalinitial: AccountStateNoneThe representation of "No value"
// What a caller can ask an account to do.
public union AccountCommand(Open, Deposit, Withdraw);
public sealed record Open(string Owner);
public sealed record Deposit(decimal Amount);
public sealed record Withdraw(decimal Amount);
// What the account replies. Rejected is a reply only: it is never stored.
public union AccountEvent(Opened, Deposited, Withdrawn, Rejected);
public sealed record Opened(string Owner);
public sealed record Deposited(decimal Amount);
public sealed record Withdrawn(decimal Amount);
public sealed record Rejected(string Reason);
// What the account knows now, rebuilt from its events.
public sealed record AccountState(string? Owner = null, decimal Balance = 0m);
Withdraw and Withdrawn follow the pattern from step 1: the command asks, the event records.
Rejected is the reply to a command the account turns away, and it carries the reason. It is a case
of AccountEvent because every reply has that type, but the account never stores it.
Write the rules
// Chooses what to do with a command, based on the current state.
let decide (command: Command<AccountCommand>) (state: AccountState) =
match command.CommandDetails, state.Owner with
| Open _, Some _ -> DeferEvent(Rejected "The account is already open")
| Open owner, None -> PersistEvent(Opened owner)
| _, None -> DeferEvent(Rejected "The account is not open")
| (Deposit amount | Withdraw amount), _ when amount <= 0m ->
DeferEvent(Rejected "The amount must be positive")
| Deposit amount, _ -> PersistEvent(Deposited amount)
| Withdraw amount, _ when amount > state.Balance ->
DeferEvent(Rejected $"Insufficient funds: {state.Balance} available")
| Withdraw amount, _ -> PersistEvent(Withdrawn amount)
// Applies one event to the state. A rejection changes nothing.
let fold (event: Event<AccountEvent>) (state: AccountState) =
match event.EventDetails with
| Opened owner -> { state with Owner = Some owner }
| Deposited amount -> { state with Balance = state.Balance + amount }
| Withdrawn amount -> { state with Balance = state.Balance - amount }
| 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_250-tutorial_002-withdraw-money.md_page.Account.AccountCommandstate: AccountStateFcqrs_250-tutorial_002-withdraw-money.md_page.Account.AccountStateCommandDetails: 'CommandDetailsThe specific details or payload of the command.
Owner: string optionOpenSomeThe representation of "Value of type 'T" The input value. An option representing the value.
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.
Rejectedowner: stringNoneThe representation of "No value"
PersistEventPersist the event to the journal. The actor's state will be updated using the event handler *after* persistence succeeds.
OpenedDepositamount: decimalWithdraw(<=): '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
Deposited(>): '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: decimalWithdrawnfold: 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_250-tutorial_002-withdraw-money.md_page.Account.AccountEventEventDetails: 'EventDetailsThe specific details or payload of the event.
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();
// Chooses what to do with a command, based on the current state.
public override EventAction<AccountEvent> HandleCommand(
Command<AccountCommand> command, AccountState state) =>
(command.CommandDetails, state.Owner) switch
{
(Open, not null) => Reject("The account is already open"),
(Open open, null) => Store(new Opened(open.Owner)),
(_, null) => Reject("The account is not open"),
(Deposit { Amount: <= 0m } or Withdraw { Amount: <= 0m }, _) =>
Reject("The amount must be positive"),
(Deposit deposit, _) => Store(new Deposited(deposit.Amount)),
(Withdraw withdraw, _) when withdraw.Amount > state.Balance =>
Reject($"Insufficient funds: {state.Balance} available"),
(Withdraw withdraw, _) => Store(new Withdrawn(withdraw.Amount))
};
// Applies one event to the state. A rejection changes nothing.
public override AccountState ApplyEvent(
Event<AccountEvent> stored, AccountState state) =>
stored.EventDetails switch
{
Opened opened => state with { Owner = opened.Owner },
Deposited deposited =>
state with { Balance = state.Balance + deposited.Amount },
Withdrawn withdrawn =>
state with { Balance = state.Balance - withdrawn.Amount },
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));
}
decide now checks each command against the state before accepting it. The cases are tried from top
to bottom, and the first one that matches decides. Open is accepted only while the account has no
owner, and every other command is rejected until it has one.
decide can reply in two ways:
PersistEventstores the event, applies it to the state withfold, and replies with it. The version goes up by one.DeferEventreplies with the event without storing it. The version stays the same, and the journal never sees the event. The name comes from Akka.NET, the actor library FCQRS runs on.
In C#, Reject is a helper like Store from step 1. It creates a DeferEvent with
EventActions.Defer.
FCQRS applies a deferred event with fold too, so fold returns the state unchanged for Rejected.
A change made there would last only until the account is loaded again, because loading folds stored
events only.
Turn a command away with a rejection, not an exception. An exception in decide or fold does not
reach the caller: FCQRS stops the whole program, for reasons
When FCQRS stops the process explains.
Send the commands
Shared setup
open System
open System.IO
open Microsoft.Extensions.Configuration
open Microsoft.Extensions.Logging
open FCQRS.Actor
open FCQRS.Common
open FCQRS.FSharp
open Account
let database = Path.Combine(AppContext.BaseDirectory, "accounts.db")
// Startup is the same as in step 1.
let logging = LoggerFactory.Create(fun _ -> ())
let configuration = ConfigurationBuilder().Build()
let connection = Fcqrs.connect DBType.Sqlite $"Data Source={database};"
let api = Fcqrs.actor configuration logging (Some connection) "accounts"
let accounts =
Fcqrs.aggregate api
{ Name = "Account"
Initial = initial
Decide = decide
Fold = fold
Snapshots = Default
Passivation = PassivationPolicy.Default }
Fcqrs.wireSagaStarters api []
let describe event =
match event with
| Opened owner -> $"Opened for {owner}"
| Deposited amount -> $"Deposited {amount}"
| Withdrawn amount -> $"Withdrew {amount}"
| Rejected reason -> $"Rejected: {reason}"
SystemIOMicrosoftExtensionsConfigurationLoggingFCQRSFCQRS.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 -> ...)
Fcqrs_250-tutorial_002-withdraw-money.md_page.Accountdatabase: 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.
accounts: AggregateHandle<AccountCommand,AccountEvent>aggregate: 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).
Name: 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.
describe: AccountEvent -> stringevent: AccountEventOpenedowner: stringDepositedamount: decimalWithdrawnRejectedreason: string// Each account ID gets its own aggregate instance.
let alice = Fcqrs.aggregateId "alice"
// Send a command and return the account's reply.
let request command =
accounts.Send (Fcqrs.newCid ()) alice command (fun _ -> true)
// Print a reply. Journaled tells whether FCQRS stored the event.
let show (reply: Event<AccountEvent>) =
let stored = if reply.Journaled = Some true then "stored" else "not stored"
let description = describe reply.EventDetails
printfn $"{description} (version {reply.Version}, {stored})"
let send command = request command |> Async.RunSynchronously |> show
send (Open "Alice")
send (Deposit 100m)
send (Withdraw 30m)
send (Withdraw 500m)
send (Open "Alice")
alice: FCQRS.Model.Data.AggregateIdFCQRS.FSharp.FcqrsaggregateId: 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.
request: AccountCommand -> Async<Event<AccountEvent>>command: AccountCommandaccounts: 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.
newCid: unit -> FCQRS.Model.Data.CIDA fresh correlation id (UUID v7).
show: Event<AccountEvent> -> unitreply: 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_250-tutorial_002-withdraw-money.md_page.Account.AccountEventstored: stringJournaled: bool optionWhether this envelope's event was journaled, read from the delivery stamp: Some true (a projection event will follow), Some false (a deferred/publish-only reply — nothing to await), or None (an envelope that never passed through aggregate delivery, e.g. read back from the journal, or produced by a pre-stamp FCQRS).
(=): '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
SomeThe representation of "Value of type 'T" The input value. An option representing the value.
description: stringdescribe: AccountEvent -> stringEventDetails: 'EventDetailsThe specific details or payload of the event.
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.
send: AccountCommand -> unit(|>): '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 * Threading.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.
OpenDepositWithdraw// Sends commands to accounts and returns the event each one replied with.
var accounts = host.Services
.GetRequiredService<Handler<AccountCommand, AccountEvent>>();
// Each account ID gets its own aggregate instance.
var alice = Values.CreateAggregateId("alice");
// Send a command and return the account's reply.
Task<Event<AccountEvent>> Request(AccountCommand command) =>
accounts(_ => true, Values.NewCID(), alice, command);
// Print a reply. Journaled tells whether FCQRS stored the event.
void Show(Event<AccountEvent> reply)
{
var stored = reply.Journaled?.Value == true ? "stored" : "not stored";
var description = Describe(reply.EventDetails);
Console.WriteLine($"{description} (version {reply.Version}, {stored})");
}
async Task Send(AccountCommand command) => Show(await Request(command));
await Send(new Open("Alice"));
await Send(new Deposit(100m));
await Send(new Withdraw(30m));
await Send(new Withdraw(500m));
await Send(new Open("Alice"));
Startup is the same as in step 1. request sends a command and returns the pending reply, so the
next example can send two commands before waiting for either. send waits for the reply and prints
it. Journaled on a reply says whether FCQRS stored the event.
Send two withdrawals together
// Two withdrawals of 60 arrive at the same moment.
let replies =
[ request (Withdraw 60m); request (Withdraw 60m) ]
|> Async.Parallel
|> Async.RunSynchronously
// Print the stored one first.
replies
|> Array.sortBy (fun reply -> reply.Journaled <> Some true)
|> Array.iter show
replies: Event<AccountEvent> arrayrequest: AccountCommand -> Async<Event<AccountEvent>>Withdraw(|>): '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
Parallel: Async<'T> seq -> Async<'T array>Creates an asynchronous computation that executes all the given asynchronous computations, initially queueing each as work items and using a fork/join pattern. If all child computations succeed, an array of results is passed to the success continuation. If any child computation raises an exception, then the overall computation will trigger an exception, and cancel the others. The overall computation will respond to cancellation while executing the child computations. If cancelled, the computation will cancel any remaining child computations but will still wait for the other child computations to complete. A sequence of distinct computations to be parallelized. A computation that returns an array of values from the sequence of input computations. Composing Async Computations let primes = [ 2; 3; 5; 7; 10; 11 ] let t = [ for i in primes do async { do! Async.Sleep(System.Random().Next(1000, 2000)) if i % 2 > 0 then printfn $"{i}" return true else return false } ] |> Async.Parallel |> Async.StartAsTask t.Wait() printfn $"%A{t.Result}" This will print "3", "5", "7", "11" (in any order) in 1-2 seconds and then [| false; true; true; true; false; true |].
RunSynchronously: Async<'T> * int option * Threading.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.
Microsoft.FSharp.Collections.ArrayModuleContains operations for working with arrays. See also F# Language Guide - Arrays.
sortBy: ('T -> 'Key) -> 'T array -> 'T arraySorts the elements of an array, using the given projection for the keys and returning a new array. Elements are compared using . This is not a stable sort, i.e. the original order of equal elements is not necessarily preserved. For a stable sort, consider using . The function to transform array elements into the type that is compared. The input array. The sorted array. Thrown when the input array is null. let input = [| "a"; "bbb"; "cccc"; "dd" |] input |> Array.sortBy (fun s -> s.Length) Evaluates to [|"a"; "dd"; "bbb"; "cccc"|].
reply: Event<AccountEvent>Journaled: bool optionWhether this envelope's event was journaled, read from the delivery stamp: Some true (a projection event will follow), Some false (a deferred/publish-only reply — nothing to await), or None (an envelope that never passed through aggregate delivery, e.g. read back from the journal, or produced by a pre-stamp FCQRS).
(<>): 'T -> 'T -> boolStructural inequality The first parameter. The second parameter. The result of the comparison. 5 <> 5 // Evaluates to false 5 <> 6 // Evaluates to true [1; 2] <> [1; 2] // Evaluates to false
SomeThe representation of "Value of type 'T" The input value. An option representing the value.
iter: ('T -> unit) -> 'T array -> unitApplies the given function to each element of the array. The function to apply. The input array. Thrown when the input array is null. let inputs = [| "a"; "b"; "c" |] inputs |> Array.iter (printfn "%s") Evaluates to unit and prints a b c in the console.
show: Event<AccountEvent> -> unit// Two withdrawals of 60 arrive at the same moment.
var replies = await Task.WhenAll(
Request(new Withdraw(60m)), Request(new Withdraw(60m)));
// Print the stored one first.
foreach (var reply in replies.OrderBy(reply => reply.Journaled?.Value != true))
Show(reply);
Both withdrawals are in flight at once, and the balance is 70. The account takes one of them first,
stores Withdrawn 60, and then decides the other against a balance of 10. Which one arrives first is
not fixed, so the program prints the stored reply first.
This guarantee covers one account. Commands for different accounts run in parallel, and each account decides only with its own state. Moving money between two accounts needs a workflow across both, which step 5 builds.
Run it
From samples/accounts:
dotnet run --project 2-withdraw-money/fsharp
dotnet run --project 2-withdraw-money/csharp
Opened for Alice (version 1, stored)
Deposited 100 (version 2, stored)
Withdrew 30 (version 3, stored)
Rejected: Insufficient funds: 70 available (version 3, not stored)
Rejected: The account is already open (version 3, not stored)
Withdrew 60 (version 4, stored)
Rejected: Insufficient funds: 10 available (version 4, not stored)
Journal rows for Account/default-shard/alice:
1 {"Case":"Opened","owner":"Alice"}
2 {"Case":"Deposited","amount":100}
3 {"Case":"Withdrawn","amount":30}
4 {"Case":"Withdrawn","amount":60}
Opened for Alice (version 1, stored)
Deposited 100 (version 2, stored)
Withdrew 30 (version 3, stored)
Rejected: Insufficient funds: 70 available (version 3, not stored)
Rejected: The account is already open (version 3, not stored)
Withdrew 60 (version 4, stored)
Rejected: Insufficient funds: 10 available (version 4, not stored)
Journal rows for Account/default-shard/alice:
1 {"$case":"Opened","$value":{"Owner":"Alice"}}
2 {"$case":"Deposited","$value":{"Amount":100}}
3 {"$case":"Withdrawn","$value":{"Amount":30}}
4 {"$case":"Withdrawn","$value":{"Amount":60}}
Seven commands produced seven replies and four journal rows. A rejected reply shows the version the account already had, because nothing was stored.
Run it again
Rejected: The account is already open (version 4, not stored)
Deposited 100 (version 5, stored)
Withdrew 30 (version 6, stored)
Rejected: Insufficient funds: 80 available (version 6, not stored)
Rejected: The account is already open (version 6, not stored)
Withdrew 60 (version 7, stored)
Rejected: Insufficient funds: 20 available (version 7, not stored)
The first Open is now rejected. FCQRS loaded Alice's account from its four stored events before
handling the new commands, so the account was already open and held 10. The rules use that loaded
state, and the rejections from the first run were never stored, so none of them were replayed.
Next
Step 3: Restart the bank looks at loading: when an account loads, how
snapshots shorten loading for a long history, and why fold must give the same result every time it
runs.