Remoting
Overview
Shopfoo uses Fable.Remoting for type-safe, contract-first communication between the Fable/Elmish client and the ASP.NET/Giraffe server. The API contract is a set of F# record types defined in the shared project, so the compiler guarantees that both sides agree on the shape of every request and response.
On top of the library itself, Shopfoo adds a small framework:
Shared (
Shopfoo.Shared/Remoting.fs) — The API contract: request/response wrappers, command/query type aliases, and the{Area}Apirecords.Server (
Shopfoo.Server/WebApp.fs,Remoting/folder) — Giraffe HTTP handlers, authorization, and the handler class hierarchy.Client (
Shopfoo.Client/Server.fs,Shopfoo.Client/Remoting.fs) — The proxy, theCmderhelper, and theRemote<'a>type for MVU models.
The API contract (Shared)
Route convention
All Remoting routes follow a single pattern defined in Route.builder:
module Route =
let builder pageName methodName = $"/api/%s{pageName}/%s{methodName}"For example, CatalogApi.GetProducts resolves to /api/CatalogApi/GetProducts. This builder is used identically on both sides (server and client), so the routes always match.
Do not confuse /api/... routes (Remoting RPC) with client-side page routes (/books/..., /about...) handled by Feliz.Router.
Request<'t> and Response<'a>
Request<'t> and Response<'a>Every Remoting call transmits a Request<'t> envelope and returns a Response<'a>:
Token— The encrypted auth token (see Security).Nonefor anonymous users.Lang— The user's current language, so the server can return the right translations.Body— The actual payload, generic over the request type.ServerError— Either a domain/technical error (ApiError) or an authentication failure (AuthError).
Command and Query type aliases
Command and Query type aliasesThree type aliases capture the two main interaction patterns:
Command— A write request (with side effects) that returns nothing on success (only errors). Examples:SaveProduct,AdjustStock.Query— A read request (no side effects) that returns a typed response. Examples:GetBooksData,SearchAuthors.QueryWithTranslations— A query that also returns aTranslationsbundle alongside the response. The client sends aQueryDataAndTranslationsthat includes the set ofPageCodes whose translations are missing locally, and the server piggybacks the translations in the response. This avoids a separate round-trip for translations.
ResponseBuilder
ResponseBuilderThe ResponseBuilder module provides two factory functions that create IResponseBuilder instances used by server-side handlers:
Both handle ApiError conversion (mapping domain Error to ApiError with the right detail level for the user) and the Ok path. The withTranslations variant tacks a Translations bundle onto the success value.
The {Area}Api records and IAreaApi
{Area}Api records and IAreaApiEach functional area of the front-end — HomeApi, CatalogApi, PricesApi, AdminApi — exposes its contract as a record type implementing the IAreaApi marker interface. Every field is either a Command<_>, a Query<_, _>, or a QueryWithTranslations<_, _>:
These Remoting APIs follow the Backend For Frontend (BFF) principle: they are designed around the client's needs (pages, UI flows), not around the domain structure. There is a deliberate decoupling between the Remoting APIs (HomeApi, CatalogApi, PricesApi, AdminApi) and the server-side Feat projects (Home, Product). Even when the names overlap (e.g. HomeApi and the Home Feat), the split is intentional — it allows each side to evolve independently.
All APIs are aggregated into a single RootApi record:
This RootApi is the single entry point used by both the server (to wire handlers) and the client (to build the proxy).
Request/response DTOs
Each {Area}Api module also defines its own request and response DTOs as small records. These are pure data containers shared between client and server:
Notice that these DTOs directly reference domain types (BookAuthor, BookTag, Product, SKU...). This is one of the major benefits of the SAFE stack: since the same F# code compiles to both .NET (server) and JavaScript (client via Fable), there is no need to maintain separate DTO layers or map between different languages (e.g. C# on the backend, TypeScript on the frontend). Domain types — records and discriminated unions — are immutable and carry no behavior by convention, which makes them natural DTOs: they transfer data safely without exposing server-side internals.
This remains compatible with domain types that use smart constructors to prevent invalid states (e.g. a SKU that enforces a format, or a Prices record that validates business rules). Smart constructors are only enforced on the server, at the domain boundary where data enters the system. Once a value has been validated and persisted, it is safe to serialize and send to the client as-is — deserialization bypasses the smart constructor, but this is fine because the data was already validated at the source. The client mostly reads and displays these values. When it does need to create a domain object (e.g. building a Product from form inputs), a smart constructor failure simply surfaces as a validation error that the UI can report to the user.
ApiError — the front-end-friendly error type
ApiError — the front-end-friendly error typeDomain logic uses an Error discriminated union (BusinessError, Bug, DataError, Validation...). This union is server-side only — it relies on exception types and internal details that are not Fable-compatible. But even if it were, exposing it to the front-end would be a security breach: the Bug case carries an exception whose stack trace reveals internal class names and file paths.
ApiError (defined in Shopfoo.Shared/Errors.fs) is the client-facing replacement, designed specifically for error display in the UI (toast notifications, form feedback, error pages):
Key design points:
ErrorTypedistinguishes business errors (user-friendly message, suitable for toast or form feedback) from technical errors (unexpected failures, shown differently in the UI).ErrorCategoryis a classification string exposed only to admin users (empty for non-admins).ErrorKeyis an optional translation key that the client can use to display a localized error message.ErrorDetail(exception text / stack trace) is only populated for admin users, preventing technical disclosure to regular users.Translationscan carry additional translations needed to render the error in the UI.
The ApiError.FromError factory method converts a domain Error into an ApiError, adapting the detail level based on the user's ErrorDetailLevel:
An ApiError.FromException factory is also available for unhandled exceptions (e.g. ProxyRequestException on the client side).
Server side
The server side wires together authorization, request handling, and Giraffe routing. Each {Area}Api record is built by a dedicated builder that instantiates handlers and wraps them with security. The assembled APIs are then exposed as HTTP endpoints through Giraffe's choose combinator.
SecureRequestHandler — the handler base type
SecureRequestHandler — the handler base typeEach Remoting endpoint is implemented as a sealed class inheriting from SecureRequestHandler<'requestBody, 'response>:
The following aliases make intent explicit:
Using a class rather than a plain function (more idiomatic in F#) is a deliberate choice for several reasons:
CQRS convention — This follows the classic
CommandHandler/QueryHandlerpattern found in CQRS architectures (typically in C#). The base type serves as a marker thatSecurity.authorizeHandlercan target: it guarantees that the requesting user's claims have been verified before the handler executes.Screaming Architecture — A sealed class naturally maps to a dedicated file, making the application's capabilities visible in the file tree. See the Screaming Architecture principle.
SecureRequestHandler is an abstract class rather than an interface, even though it has a single abstract member (which would normally favor an interface for better decoupling). The reason is pragmatic: in F#, interface implementations are always explicit — calling code must upcast to the interface to access its members. With a single-member type, this trade-off is acceptable and keeps the code more concise and readable.
Inheritance must stop at one level: each concrete handler is [<Sealed>] and directly inherits one of the aliases below. There is no deeper hierarchy. The compiler does not and can not enforce this, but the architecture test Remoting API request handlers should be sealed and in their dedicated file verifies that all handlers inheriting SecureRequestHandler are sealed.
A typical handler receives its dependencies via constructor injection, calls into domain APIs, and returns through the ResponseBuilder:
FeatApi — the domain gateway
FeatApi — the domain gatewayHandlers receive their domain dependencies through FeatApi, a singleton that aggregates the feature-level APIs:
This keeps handlers decoupled from the DI container — they only see the domain interfaces they need.
Authorization with Security.authorizeHandler
Security.authorizeHandlerEvery handler is wrapped with Security.authorizeHandler before being assigned to its {Area}Api field:
authorizeHandler decrypts and validates the AuthToken from the request, checks that the user holds the required claims, and either returns an AuthError or delegates to the handler's Handle method with the verified User:
{Area}ApiBuilder — wiring the APIs
{Area}ApiBuilder — wiring the APIsEach API area has a builder class (e.g. CatalogApiBuilder, HomeApiBuilder) that instantiates the handlers and wraps them with authorization. A top-level RootApiBuilder composes them all:
All builders are registered as singletons in DependencyInjection.fs.
Giraffe routing with choose
chooseIn WebApp.fs, each API record is turned into a Giraffe HttpHandler via areaApiHttpHandler, then combined with Giraffe's choose combinator:
This wiring is not compile-safe: if a new {Area}Api is added to RootApi but not listed in choose, there is no compiler error — the missing API simply returns HTTP 404 at runtime. Be sure to update webApp when adding a new API.
The custom errorHandler logs the exception and returns a case of Fable.Remoting's ErrorResult union: Propagate sends the error to the client as an ApiError (for known F# exceptions like failwith, invalidArg...), while Ignore swallows it (for unexpected errors), preventing technical disclosure:
Client side
Server.fs — the Remoting proxy
Server.fs — the Remoting proxyServer.fs builds the Fable.Remoting client proxy — a single RootApi value whose fields are auto-generated proxies that perform HTTP calls:
Calling Server.api.Catalog.GetProducts request transparently serializes the request, issues a POST to /api/CatalogApi/GetProducts, and deserializes the response — all type-safe, no manual HTTP code.
Remoting.fs — the MVU integration layer
Remoting.fs — the MVU integration layerThis file provides the types and helpers that connect Remoting to the Elmish MVU architecture.
ApiResult<'response> and ApiCall<'response>
ApiResult<'response> and ApiCall<'response>Two types structure the messages flowing through the Elmish loop:
The key difference between the two:
ApiResultis used for data fetched at page load time. Theinitfunction fires aCmdand the resultingMsgdirectly carries theApiResult— there is no need for aStartphase since the call is triggered automatically:
ApiCallis used for actions triggered by the user (save, delete...). TheStartcase is dispatched from the view (e.g. on button click), while theDonecase is handled in theupdatefunction when the server responds:
Note that the Product parameter is placed in the Msg case itself, not inside a Start payload. This makes it available in both the Start and Done branches of the update function, but it comes at a cost: the Cmd function must explicitly rewrap the body in the Done case, making the cmder.ofApiRequest call more verbose:
These types are inspired by similar helpers in the ecosystem:
The SAFE.Utils package provides
ApiCall<'TStart, 'TFinished> = Start of 'TStart | Finished of 'TFinished— a more generic variant with a parameterizedStart. In practice, aStartparameter is rarely needed, so Shopfoo omits it. When one is needed, it is placed directly in theMsgcase (e.g.SaveProduct of Product * ApiCall<unit>), as shown above.The Elmish Book proposes
AsyncOperationStatus<'t> = Started | Finished of 't, which is equivalent to ourApiCallup to naming.
Both recommend using Result to model operations that might fail. Shopfoo's ApiResult does the same, while being more specific about the error type (ApiError).
Cmder — the command builder
Cmder — the command builderCmder wraps calls to the Remoting API into Elmish Cmd values. It is obtained from FullContext:
The UnitTestSession field supports unit testing: when Some, ofApiRequest uses the provided MockedApi instead of the real Server.api proxy. In production, this is always None.
Its single method, ofApiRequest, takes an ApiRequestArgs record and produces a Cmd<'msg>:
Call— A function that takes theRootApiproxy and returns the async call.Error/Success— Callbacks that wrap the result into the page'sMsgtype.
Internally, ofApiRequest calls Cmd.OfAsync.either, and handles both ServerError variants (converting AuthError to ApiError) and unhandled exceptions (converting ProxyRequestException to ApiError.Technical).
Compared to calling Cmd.OfAsync.either directly, ofApiRequest is slightly more verbose but much more readable: the named fields Call, Success, and Error make the intent of each callback immediately visible, whereas the curried arguments of Cmd.OfAsync.either are positional and easy to mix up. The Call field taking a lambda (fun api -> ...) also improves developer experience: it enables IntelliSense and auto-completion on the api record and its methods, making it easy to discover available endpoints.
FullContext.PrepareRequest and PrepareQueryWithTranslations
FullContext.PrepareRequest and PrepareQueryWithTranslationsFullContext provides two helpers that produce the (Cmder, Request<'a>) tuple expected by Cmd module functions:
PrepareQueryWithTranslations automatically includes the set of translation pages that the client is still missing, so the server can piggyback them in the response. More details in translations.
The module Cmd convention
module Cmd conventionEach page or component that talks to the server defines a private Cmd module inside its own module. Each function in this module takes a (Cmder, Request<_>) tuple and returns a Cmd<Msg>:
This convention keeps all remoting side-effects grouped together and separate from the update logic. The [<RequireQualifiedAccess>] attribute brings an additional benefit: every call site reads Cmd.loadPrices, Cmd.saveProduct... making it immediately obvious that a command is being issued. It also unifies the code style in the Cmd track returned by init and update (the second element of the Model * Cmd pair): it always starts with Cmd., whether it refers to this module or to Elmish's built-in helpers (Cmd.none, Cmd.batch, Cmd.ofEffect...).
Usage in init and update
init and updateTranslations are typically needed only once per page, at load time. As a result, PrepareQueryWithTranslations is generally called only once, in the init function. All subsequent calls (in update) use PrepareRequest.
Remote<'a> — tracking async data in the model
Remote<'a> — tracking async data in the modelThe Remote<'a> discriminated union represents the lifecycle of data obtained through a remote call:
Empty— No data and no request in flight (initial state for optional data).Loading— A request is in progress (the view can show a spinner).LoadError— The request failed (the view can show an error message).Loaded— Data is available.
The Remote module provides two conversion helpers:
A typical model uses Remote fields for any data fetched from the server. Remote acts as a marker type: scanning the Model fields instantly reveals which data comes from the server and which is local state.
The update function transitions between Remote cases as API calls progress — Loading when the call starts, Loaded on success, LoadError on failure:
As with ApiCall, similar types exist in the ecosystem:
SAFE.Utils provides
RemoteData<'T> = NotStarted | Loading of 'T option | Loaded of 'TThe Elmish Book proposes
Deferred<'t> = HasNotStartedYet | InProgress | Resolved of 't
Both choose not to model the error case explicitly, relying on Result for that (e.g. Deferred<Result<'t, 'e>>). This is more generic but also more verbose at usage sites. Shopfoo's Remote takes an opinionated stance: a remote call can always fail — this is inherent — so the error case is modeled directly in the type, giving a flat union rather than a composition of two types.
All three approaches are equivalent — it comes down to what one finds more practical and what one wants to make explicit in the modeling. The SAFE stack and the Elmish Book target a broad audience and must remain generic; the Safe Clean Architecture (like the SAFEr template) can afford to be more opinionated.
Data flow summary
The full round-trip for a query follows this path:
Client
init/update— CallsfullContext.PrepareQueryWithTranslations(query)to build the(Cmder, Request)tuple, then passes it to aCmd.xxxfunction.Client
Cmdmodule —cmder.ofApiRequestwraps the call as aCmd.OfAsync.either.Client proxy (
Server.api) — Fable.Remoting serializes theRequestand POSTs to/api/{Area}Api/MethodName.Server
choose— Giraffe routes the request to the matchingareaApiHttpHandler.Security.authorizeHandler— Validates the token and checks claims.Handler — Calls domain logic via
FeatApi, builds the response viaResponseBuilder.Server response —
Response<'a>(aResult) is serialized back.Client callback —
ofApiRequestdispatchesSuccessorErroras the appropriateMsg.Client
update— Updates theRemote<'a>field in the model.Client view — Pattern-matches on the
Remotevalue to show a spinner, a skeleton, an error, or the content using the resulting data.
Last updated