Serve an aggregate over HTTP
Put the tutorial's account behind ASP.NET Core endpoints: open an account, deposit, withdraw, and read
the statement. The sample builds on Show a statement and compiles
that step's Account and Statement files unchanged. Jump to the requests.
Send a command and wait for the statement
Each POST sends one command to the account. When the account stores an event, the endpoint waits for the statement projection to handle it, as Read your writes describes, and then reads the balance from the statement. The response therefore includes the caller's own change. It can also include a later change: two concurrent deposits can both report the balance after the second.
Shared setup
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 }
// 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
module Statement =
open System.Data.Common
open System.Threading.Tasks
open Akka.Persistence.Query
open Dapper
open FCQRS.Common
open Account
// The read model: one row per stored event, with the balance after it.
let createTable (connection: DbConnection) =
connection.Execute
"CREATE TABLE IF NOT EXISTS statement (
account TEXT NOT NULL,
version INTEGER NOT NULL,
entry TEXT NOT NULL,
amount NUMERIC NOT NULL,
balance NUMERIC NOT NULL,
PRIMARY KEY (account, version))"
|> ignore
// Adds a row whose balance continues from the account's previous row.
let private addRow =
"INSERT INTO statement (account, version, entry, amount, balance)
SELECT @Account, @Version, @Entry, @Amount,
COALESCE((SELECT balance FROM statement WHERE account = @Account
ORDER BY version DESC LIMIT 1), 0) + @Amount"
// FCQRS calls this for each stored event, inside a transaction it commits.
let handle (connection: DbConnection) (transaction: DbTransaction)
(envelope: EventEnvelope) =
task {
match envelope.Event with
// Only account events go on a statement; Sender is the account's ID.
| :? Event<AccountEvent> as stored ->
let add (entry: string) (amount: decimal) =
let row =
{| Account = string stored.Sender.Value
Version = envelope.SequenceNr
Entry = entry
Amount = amount |}
connection.ExecuteAsync(addRow, row, transaction) :> Task
match stored.EventDetails with
| Opened owner -> do! add $"Opened for {owner}" 0m
| Deposited amount -> do! add "Deposit" amount
| Withdrawn amount -> do! add "Withdrawal" -amount
// A rejection is a reply only; the journal never holds one.
| Rejected _ -> ()
| _ -> ()
}
:> Task
open System
open System.Threading
open System.Threading.Tasks
open Dapper
open Microsoft.AspNetCore.Builder
open Microsoft.AspNetCore.Http
open Microsoft.Data.Sqlite
open FCQRS.Common
open FCQRS.FSharp
open FCQRS.Projections
open Account
let sender (accounts: AggregateHandle<AccountCommand, AccountEvent>)
(statement: IProjection) (connectionString: string) =
Fcqrs_450-how-to_015-serve-over-http.md_page.AccountFCQRSFCQRS.CommonContains common types like Events and Commands Functionality for Write Side.
Fcqrs_450-how-to_015-serve-over-http.md_page.Account.AccountCommandOpenowner: stringstringAn abbreviation for the CLI type . Basic Types
Depositamount: decimaldecimalAn abbreviation for the CLI type . Basic Types
WithdrawFcqrs_450-how-to_015-serve-over-http.md_page.Account.AccountEventOpenedDepositedWithdrawnRejectedreason: stringFcqrs_450-how-to_015-serve-over-http.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"
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>
state: AccountStateCommandDetails: 'CommandDetailsThe specific details or payload of the command.
SomeThe 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.
PersistEventPersist the event to the journal. The actor's state will be updated using the event handler *after* persistence succeeds.
(<=): '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
(>): '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
fold: 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>
EventDetails: 'EventDetailsThe specific details or payload of the event.
Fcqrs_450-how-to_015-serve-over-http.md_page.StatementSystemDataCommonThreadingTasksAkkaPersistenceQueryDappercreateTable: DbConnection -> unitconnection: DbConnectionSystem.Data.Common.DbConnectionDefines the core behavior of database connections and provides a base class for database-specific connections.
Execute: string * obj * System.Data.IDbTransaction * System.Nullable<int> * System.Nullable<System.Data.CommandType> -> intExecute parameterized SQL. The connection to query on. The SQL to execute for this query. The parameters to use for this query. The transaction to use for this query. Number of seconds before command execution timeout. Is it a stored proc or a batch? The number of rows affected.
(|>): '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
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 ()
addRow: stringhandle: DbConnection -> DbTransaction -> EventEnvelope -> Tasktransaction: DbTransactionSystem.Data.Common.DbTransactionDefines the core behavior of database transactions and provides a base class for database-specific transactions.
envelope: EventEnvelopeAkka.Persistence.Query.EventEnvelopeEvent wrapper adding meta data for the events in the result stream of query, or similar queries. The is the time the event was stored, in ticks. The value of this property represents the number of 100-nanosecond intervals that have elapsed since 12:00:00 midnight, January 1, 0001 in the Gregorian calendar (same as `DateTime.Now.Ticks`).
task: TaskBuilderBuilds a task using computation expression syntax.
Event: objstored: Event<AccountEvent>add: string -> decimal -> Taskentry: stringrow: {| Account: string; Amount: decimal; Entry: string; Version: int64 |}Account: 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"
Value: FCQRS.Model.Data.AggregateIdGet the value of a 'Some' option. A NullReferenceException is raised if the option is 'None'.
Sender: FCQRS.Model.Data.AggregateId optionAn optional identifier for the actor that generated the event.
Version: int64SequenceNr: int64Entry: stringAmount: decimalExecuteAsync: string * obj * System.Data.IDbTransaction * System.Nullable<int> * System.Nullable<System.Data.CommandType> -> Task<int>Execute a command asynchronously using Task. The connection to query on. The SQL to execute for this query. The parameters to use for this query. The transaction to use for this query. Number of seconds before command execution timeout. Is it a stored proc or a batch? The number of rows affected.
System.Threading.Tasks.TaskRepresents an asynchronous operation.
(~-): ^T -> ^TOverloaded unary negation. The value to negate. The result of the operation.
MicrosoftAspNetCoreBuilderHttpSqliteFCQRS.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.ProjectionsTransactional projections with a journal-wide, durable catch-up boundary.
sender: AggregateHandle<AccountCommand,AccountEvent> -> IProjection -> string -> string -> AccountCommand -> CancellationToken -> Task<IResult>accounts: AggregateHandle<AccountCommand,AccountEvent>FCQRS.FSharp.AggregateHandle`2What you get back after registering an aggregate.
statement: IProjectionFCQRS.Projections.IProjectionA running projection and its request-scoped notification subscriptions. Catch-up covers every application persistence ID in one committed journal snapshot. Akka's own cluster-sharding records (IDs starting with "/sharding/") are excluded. It does not wait for other projections, later writes, or external effects. A correlation-ID subscription (`Subscribe(cid, ...)`, which `sendAwaiting` uses) receives its notifications after the whole snapshot containing them commits, so it can wait longer than the commit of its own event. If the projection fails before then, the subscription is cancelled. Other subscriptions receive each notification when its event commits.
connectionString: string// Sends a command and waits until the statement includes the event it
// stored, so the balance in the response includes this change.
let send (id: string) (command: AccountCommand) (ct: CancellationToken) =
task {
try
let! reply =
Fcqrs.sendAwaiting statement accounts (Fcqrs.newCid ())
(Fcqrs.aggregateId id) command (fun _ -> true)
|> fun work -> Async.StartAsTask(work, cancellationToken = ct)
match reply.EventDetails with
// A rejection is not stored. Report the account's reason.
| Rejected reason ->
return Results.UnprocessableEntity({| error = reason |})
| _ ->
use connection = new SqliteConnection(connectionString)
let! balance =
connection.ExecuteScalarAsync<decimal>(
"SELECT balance FROM statement WHERE account = @Id
ORDER BY version DESC LIMIT 1",
{| Id = id |})
return Results.Ok({| balance = balance |})
with :? TimeoutException ->
// The account may have stored the event after all.
return Results.Problem(
"The command may have completed. "
+ "Read the statement before retrying.",
statusCode = StatusCodes.Status503ServiceUnavailable)
}
send: string -> AccountCommand -> CancellationToken -> Task<IResult>id: stringstringAn abbreviation for the CLI type . Basic Types
command: AccountCommandFcqrs_450-how-to_015-serve-over-http.md_page.Account.AccountCommandct: CancellationTokenSystem.Threading.CancellationTokenPropagates notification that operations should be canceled.
task: TaskBuilderBuilds a task using computation expression syntax.
reply: Event<AccountEvent>FCQRS.FSharp.FcqrssendAwaiting: FCQRS.Query.ISubscribe<FCQRS.Model.Data.IMessageWithCID> -> AggregateHandle<'Command,'Event> -> FCQRS.Model.Data.CID -> FCQRS.Model.Data.AggregateId -> 'Command -> ('Event -> bool) -> Async<Event<'Event>>Read-your-writes in one call: subscribe on the CID BEFORE sending, send, then await the projection ONLY if the delivered ack was journaled. A deferred (rejection-style) ack never reaches the journal, so a naive await would hang until timeout. The subscribe-before-send ordering is what makes the wait race-free; owning it here means callers cannot get it backwards. Awaits exactly ONE projected event: a batch persist (PersistAllEvents) caller should Subscribe with an explicit take instead. Envelopes without the delivery stamp (pre-stamp FCQRS) are treated as journaled. The projection wait is bounded by `akka.fcqrs.command-timeout` (default 30s, bare number = seconds): a projection that suppresses or filters out the matching notification raises TimeoutException instead of hanging the caller forever.
statement: IProjectionaccounts: AggregateHandle<AccountCommand,AccountEvent>newCid: 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.
(|>): '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
work: Async<Event<AccountEvent>>Microsoft.FSharp.Control.FSharpAsyncHolds static members for creating and manipulating asynchronous computations. See also F# Language Guide - Async Workflows. Async Programming
StartAsTask: Async<'T> * TaskCreationOptions option * CancellationToken option -> Task<'T>Executes a computation in the thread pool. If no cancellation token is provided then the default cancellation token is used. A that will be completed in the corresponding state once the computation terminates (produces the result, throws exception or gets canceled) Starting Async Computations printfn "A" let t = async { printfn "B" do! Async.Sleep(1000) printfn "C" } |> Async.StartAsTask printfn "D" t.Wait() printfn "E" Prints "A", then "D", "B" quickly in any order, then "C", "E" in 1 second.
cancellationTokenEventDetails: 'EventDetailsThe specific details or payload of the event.
Rejectedreason: stringMicrosoft.AspNetCore.Http.ResultsA factory for .
UnprocessableEntity: obj -> IResultProduces a response. An error object to be included in the HTTP response body. The created for the response.
error: stringconnection: SqliteConnectionMicrosoft.Data.Sqlite.SqliteConnectionRepresents a connection to a SQLite database. Connection Strings Async Limitations
connectionString: stringbalance: decimalExecuteScalarAsync: string * obj * Data.IDbTransaction * Nullable<int> * Nullable<Data.CommandType> -> Task<'T>Execute parameterized SQL that selects a single value. The type to return. The connection to execute on. The SQL to execute. The parameters to use for this command. The transaction to use for this command. Number of seconds before command execution timeout. Is it a stored proc or a batch? The first cell returned, as .
decimalAn abbreviation for the CLI type . Basic Types
Id: stringOk: obj -> IResultProduces a response. The value to be included in the HTTP response body. The created for the response.
System.TimeoutExceptionThe exception that is thrown when the time allotted for a process or operation has expired.
Problem: string * string * Nullable<int> * string * string * Collections.Generic.KeyValuePair<string,obj> seq -> IResultProduces a response. The value for . The value for . The value for . The value for . The value for . The value for . The created for the response.
(+): ^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"
statusCodeMicrosoft.AspNetCore.Http.StatusCodesA collection of constants for HTTP status codes. Descriptions for status codes are available from .
Status503ServiceUnavailable: intHTTP status code 503.
// Sends a command and waits until the statement includes the event it stored,
// so the balance in the response includes this change.
async Task<IResult> Send(
string id, AccountCommand command, CancellationToken ct)
{
var accounts = app.Services
.GetRequiredService<Handler<AccountCommand, AccountEvent>>();
var statement = app.Services.GetRequiredService<IProjection>();
var cid = Values.NewCID();
// Subscribe first: a notification sent before the subscription is lost.
using var projected = statement.SubscribeForFirst(cid);
try
{
var account = Values.CreateAggregateId(id);
var reply = await accounts(_ => true, cid, account, command)
.WaitAsync(ct);
// A rejection is not stored. Report the account's reason.
if (reply.EventDetails is Rejected rejected)
return Results.UnprocessableEntity(new { error = rejected.Reason });
await projected.Task.WaitAsync(TimeSpan.FromSeconds(30), ct);
using var connection = new SqliteConnection(connectionString);
var balance = await connection.ExecuteScalarAsync<decimal>(
"""
SELECT balance FROM statement WHERE account = @Id
ORDER BY version DESC LIMIT 1
""",
new { Id = id });
return Results.Ok(new { balance });
}
catch (TimeoutException)
{
// The account may have stored the event after all.
return Results.Problem(
"The command may have completed. " +
"Read the statement before retrying.",
statusCode: StatusCodes.Status503ServiceUnavailable);
}
}
A rejection is a reply the account did not store, so no projection waits for it. The endpoint returns 422 Unprocessable Entity with the account's reason. A timeout returns 503: the account may have stored the event, and the caller cannot tell from the response.
Map the endpoints
The request bodies and statement rows are plain types:
Shared setup
send
send: string -> AccountCommand -> CancellationToken -> Task<IResult>// The JSON bodies the POST endpoints accept.
[<CLIMutable>]
type OpenRequest = { Owner: string }
[<CLIMutable>]
type AmountRequest = { Amount: decimal }
// One statement row, as the GET endpoint returns it.
[<CLIMutable>]
type Row =
{ Version: int64
Entry: string
Amount: decimal
Balance: decimal }
Microsoft.FSharp.Core.CLIMutableAttributeAdding this attribute to a record type causes it to be compiled to a CLI representation with a default constructor with property getters and setters. Attributes
Fcqrs_450-how-to_015-serve-over-http.md_page.OpenRequestOwner: stringstringAn abbreviation for the CLI type . Basic Types
Fcqrs_450-how-to_015-serve-over-http.md_page.AmountRequestAmount: decimaldecimalAn abbreviation for the CLI type . Basic Types
Fcqrs_450-how-to_015-serve-over-http.md_page.RowVersion: int64int64An abbreviation for the CLI type . Basic Types
Entry: stringBalance: decimal// The JSON bodies the POST endpoints accept.
record OpenRequest(string? Owner);
record AmountRequest(decimal Amount);
// One statement row, as the GET endpoint returns it.
sealed class Row
{
public long Version { get; init; }
public string Entry { get; init; } = "";
public decimal Amount { get; init; }
public decimal Balance { get; init; }
}
Each POST endpoint validates the account ID and turns its body into one command:
Shared setup
let valid (value: string) =
not (String.IsNullOrWhiteSpace value) && value.Length <= 255
let invalid = Results.BadRequest({| error = "Invalid request." |})
let endpoints (app: WebApplication) (connectionString: string)
(send: string -> AccountCommand -> CancellationToken -> Task<IResult>) =
valid: string -> boolvalue: stringstringAn abbreviation for the CLI type . Basic Types
``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
System.StringRepresents text as a sequence of UTF-16 code units.
IsNullOrWhiteSpace: string -> boolIndicates whether a specified string is , empty, or consists only of white-space characters. The string to test. if the parameter is or , or if consists exclusively of white-space characters.
(&&): bool -> bool -> boolBinary 'and'. When used as a binary operator the right hand value is evaluated only on demand The first value. The second value. The result of the operation.
Length: int(<=): '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
invalid: IResultMicrosoft.AspNetCore.Http.ResultsA factory for .
BadRequest: obj -> IResultProduces a response. An error object to be included in the HTTP response body. The created for the response.
error: stringendpoints: WebApplication -> string -> (string -> AccountCommand -> CancellationToken -> Task<IResult>) -> unitapp: WebApplicationMicrosoft.AspNetCore.Builder.WebApplicationThe web application used to configure the HTTP pipeline, and routes.
connectionString: stringsend: string -> AccountCommand -> CancellationToken -> Task<IResult>Fcqrs_450-how-to_015-serve-over-http.md_page.Account.AccountCommandSystem.Threading.CancellationTokenPropagates notification that operations should be canceled.
System.Threading.Tasks.Task`1Represents an asynchronous operation that can return a value. The type of the result produced by this .
Microsoft.AspNetCore.Http.IResultDefines a contract that represents the result of an HTTP endpoint.
// Each POST turns its request into one account command.
app.MapPost("/accounts/{id}",
Func<string, OpenRequest, CancellationToken, Task<IResult>>(
fun id request ct ->
if valid id && valid request.Owner then
send id (Open request.Owner) ct
else Task.FromResult invalid)) |> ignore
app.MapPost("/accounts/{id}/deposits",
Func<string, AmountRequest, CancellationToken, Task<IResult>>(
fun id request ct ->
if valid id then send id (Deposit request.Amount) ct
else Task.FromResult invalid)) |> ignore
app.MapPost("/accounts/{id}/withdrawals",
Func<string, AmountRequest, CancellationToken, Task<IResult>>(
fun id request ct ->
if valid id then send id (Withdraw request.Amount) ct
else Task.FromResult invalid)) |> ignore
app: WebApplicationMapPost: string * Delegate -> RouteHandlerBuilderAdds a to the that matches HTTP POST requests for the specified pattern. The to add the route to. The route pattern. The delegate executed when the endpoint is matched. A that can be used to further customize the endpoint.
System.Func`4Encapsulates a method that has three parameters and returns a value of the type specified by the parameter. The first parameter of the method that this delegate encapsulates. The second parameter of the method that this delegate encapsulates. The third parameter of the method that this delegate encapsulates. The type of the first parameter of the method that this delegate encapsulates. The type of the second parameter of the method that this delegate encapsulates. The type of the third parameter of the method that this delegate encapsulates. The type of the return value of the method that this delegate encapsulates. The return value of the method that this delegate encapsulates.
stringAn abbreviation for the CLI type . Basic Types
Fcqrs_450-how-to_015-serve-over-http.md_page.OpenRequestSystem.Threading.CancellationTokenPropagates notification that operations should be canceled.
System.Threading.Tasks.Task`1Represents an asynchronous operation that can return a value. The type of the result produced by this .
Microsoft.AspNetCore.Http.IResultDefines a contract that represents the result of an HTTP endpoint.
id: stringrequest: OpenRequestct: CancellationTokenvalid: string -> bool(&&): bool -> bool -> boolBinary 'and'. When used as a binary operator the right hand value is evaluated only on demand The first value. The second value. The result of the operation.
Owner: stringsend: string -> AccountCommand -> CancellationToken -> Task<IResult>OpenSystem.Threading.Tasks.TaskRepresents an asynchronous operation.
FromResult: 'TResult -> 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.
invalid: IResult(|>): '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
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 ()
Fcqrs_450-how-to_015-serve-over-http.md_page.AmountRequestrequest: AmountRequestDepositAmount: decimalWithdraw// Each POST turns its request into one account command.
app.MapPost("/accounts/{id}",
(string id, OpenRequest request, CancellationToken ct) =>
Valid(id) && Valid(request.Owner)
? Send(id, new Open(request.Owner), ct)
: Task.FromResult(invalid));
app.MapPost("/accounts/{id}/deposits",
(string id, AmountRequest request, CancellationToken ct) =>
Valid(id) ? Send(id, new Deposit(request.Amount), ct)
: Task.FromResult(invalid));
app.MapPost("/accounts/{id}/withdrawals",
(string id, AmountRequest request, CancellationToken ct) =>
Valid(id) ? Send(id, new Withdraw(request.Amount), ct)
: Task.FromResult(invalid));
The account decides what the command means. The endpoints check only what the account cannot: the shape of the ID and the owner's name.
Run the API
From samples/accounts, whose global.json selects the .NET 11 SDK:
dotnet run --project serve-over-http/fsharp -- --urls http://localhost:5080
dotnet run --project serve-over-http/csharp -- --urls http://localhost:5080
Wait for Listening on http://localhost:5080. In a second terminal, open Alice's account and deposit
100:
curl http://localhost:5080/accounts/alice \
-H 'Content-Type: application/json' -d '{"owner":"Alice"}'
curl http://localhost:5080/accounts/alice/deposits \
-H 'Content-Type: application/json' -d '{"amount":100}'
The responses are 200 OK with the balance after each command:
{"balance":0}
{"balance":100}
Withdraw more than the balance:
curl -i http://localhost:5080/accounts/alice/withdrawals \
-H 'Content-Type: application/json' -d '{"amount":500}'
The response is 422 Unprocessable Entity with the account's reason:
{"error":"Insufficient funds: 100 available"}
Read the statement:
curl http://localhost:5080/accounts/alice/statement
The response has one row per stored event:
[
{ "version": 1, "entry": "Opened for Alice", "amount": 0, "balance": 0 },
{ "version": 2, "entry": "Deposit", "amount": 100, "balance": 100 }
]
The rejected withdrawal has no row: the account did not store it.
GET the statement
The GET endpoint reads the statement table with SQL:
// Reads one account's statement with SQL.
let statementOf (id: string) =
task {
use connection = new SqliteConnection(connectionString)
let! rows =
connection.QueryAsync<Row>(
"SELECT version, entry, amount, balance FROM statement
WHERE account = @Id ORDER BY version",
{| Id = id |})
return
if Seq.isEmpty rows then Results.NotFound()
else Results.Ok rows
}
// A lambda keeps the parameter name `id`, which binds the route value.
app.MapGet("/accounts/{id}/statement",
Func<string, Task<IResult>>(fun id -> statementOf id)) |> ignore
statementOf: string -> Task<IResult>id: stringstringAn abbreviation for the CLI type . Basic Types
task: TaskBuilderBuilds a task using computation expression syntax.
connection: SqliteConnectionMicrosoft.Data.Sqlite.SqliteConnectionRepresents a connection to a SQLite database. Connection Strings Async Limitations
connectionString: stringrows: Row seqQueryAsync: string * obj * Data.IDbTransaction * Nullable<int> * Nullable<Data.CommandType> -> Task<'T seq>Execute a query asynchronously using Task. The type of results to return. The connection to query on. The SQL to execute for the query. The parameters to pass, if any. The transaction to use, if any. The command timeout (in seconds). The type of command to execute. A sequence of data of ; if a basic type (int, string, etc) is queried then the data from the first column is assumed, otherwise an instance is created per row, and a direct column-name===member-name mapping is assumed (case insensitive).
Fcqrs_450-how-to_015-serve-over-http.md_page.RowId: stringMicrosoft.FSharp.Collections.SeqModuleContains operations for working with values of type .
isEmpty: 'T seq -> boolReturns true if the sequence contains no elements, false otherwise. The input sequence. True if the sequence is empty; false otherwise. Thrown when the input sequence is null. [] |> Seq.isEmpty Evaluates to true ["pear"; "banana"] |> Seq.isEmpty Evaluates to false
Microsoft.AspNetCore.Http.ResultsA factory for .
NotFound: obj -> IResultProduces a response. The value to be included in the HTTP response body. The created for the response.
Ok: obj -> IResultProduces a response. The value to be included in the HTTP response body. The created for the response.
app: WebApplicationMapGet: string * Delegate -> RouteHandlerBuilderAdds a to the that matches HTTP GET requests for the specified pattern. The to add the route to. The route pattern. The delegate executed when the endpoint is matched. A that can be used to further customize the endpoint.
System.Func`2Encapsulates a method that has one parameter and returns a value of the type specified by the parameter. The parameter of the method that this delegate encapsulates. The type of the parameter of the method that this delegate encapsulates. The type of the return value of the method that this delegate encapsulates. The return value of the method that this delegate encapsulates.
System.Threading.Tasks.Task`1Represents an asynchronous operation that can return a value. The type of the result produced by this .
Microsoft.AspNetCore.Http.IResultDefines a contract that represents the result of an HTTP endpoint.
(|>): '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
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 ()
// Reads one account's statement with SQL.
app.MapGet("/accounts/{id}/statement", async (string id) =>
{
using var connection = new SqliteConnection(connectionString);
var rows = (await connection.QueryAsync<Row>(
"""
SELECT version, entry, amount, balance FROM statement
WHERE account = @Id ORDER BY version
""",
new { Id = id })).AsList();
return rows.Count == 0 ? Results.NotFound() : Results.Ok(rows);
});
Stop with Ctrl+C and run again. The journal and the statement are both in
bin/Debug/net11.0/accounts.db inside the sample's folder, so the statement is still there, and the
projection continues from its stored progress.
- 400: the ID or owner is blank or longer than 255 characters, or the JSON body is invalid.
- 404: the statement has no rows for this account, because it was never opened.
- 422: the account rejected the command. Nothing was stored;
errorholds the reason. - 503: the command or the 30-second statement wait timed out. The command may have completed.
A deposit is not idempotent: sending the same POST twice deposits twice. After a 503, read the statement before retrying. An API that retries automatically needs a request ID from the client and an account that rejects a repeated one, as the transfer IDs in Transfer money do.
The sample has no authentication: any caller can move any account's money. Add ASP.NET Core authentication and authorization before exposing endpoints like these.
Complete source: F# ยท C#. For the HTTP binding rules, see ASP.NET Core parameter binding.