Researching Bolero, Photino, and Event Modeling
Attempting to get bolero running in Photino for .Net
- I@IvanTheGeek
Outline a plan to use bolero with photino for .net to build cross platform desktop app with the UI not being ran over the network. The idea is to use HTML as the UI part, with all the functionality and access of a native app running on the os. Using Photino because Blazor Hybrid is not for Linux
- I@IvanTheGeek
Goal
Build a cross‑platform desktop app for Windows, macOS, and Linux using:
- UI: HTML/CSS/JS rendered inside Photino’s native WebView (no network).
- App logic/UI framework: Bolero (F# + Elmish) compiled to Blazor WebAssembly.
- Native OS features: exposed by the .NET host process via Photino’s message bridge.
- Reason: Blazor Hybrid isn’t available on Linux; Photino works across all three platforms.
High‑level Architecture
App.Host(C# or F#): a .NET console app creating aPhotinoWindow. Responsible for:- Packaging/serving the Bolero static site locally without any HTTP server using a custom scheme (e.g.,
app://), or direct file loading. - Handling JS↔.NET messaging to provide native OS features.
- Locking navigation to only local content.
- Packaging/serving the Bolero static site locally without any HTTP server using a custom scheme (e.g.,
App.Client(F#, Bolero WebAssembly): the Elmish UI running in the WebView.- Uses Bolero’s
Program.mkProgramwithviewreturning HTML (via Bolero DSL) that compiles to WASM. - Calls native features through a thin JS bridge that forwards to Photino messaging.
- Uses Bolero’s
App.Shared(F#): shared DTOs and message definitions used by both sides to keep contracts in sync.
Project Layout
App.slnApp.Shared(F# .NET Standard or .NET 8 library)- Shared records/unions for messages and results
App.Client(F#, .NET 8, Bolero WebAssembly)- Standard Bolero client project producing
wwwrootassets (index.html,blazor.boot.json,.wasm,.dll) - Include a small
interop.jsbridge
- Standard Bolero client project producing
App.Host(C# or F#, .NET 8 console)- Photino.NET package
- Embeds
App.Clientpublished output and serves it via custom scheme
Key Decisions
- No network: Avoid a local HTTP server. Use Photino’s custom scheme handler (or
file://) to resolve all assets. - Messaging: Use Photino’s
window.external.sendMessageandreceiveMessageto bridge JS↔.NET. - Contracts: JSON with
{ requestId, kind, payload }plus reply{ requestId, ok, result|error }. - Dev vs Prod:
- Dev mode can load from
http://localhost:xxxx(Bolero dev server) for hot reload. - Prod mode uses embedded static assets via
app://scheme to stay offline.
- Dev mode can load from
- Performance/size: Consider .NET 8 WebAssembly AOT for speed (bigger payload), or interpretive for smaller size.
Host (Photino) setup
Packages
Photino.NETSystem.Text.Json(for message serialization)
Startup (C# example)
using PhotinoNET; using System.Reflection; using System.Text.Json; class Program { static void Main() { var win = new PhotinoWindow("App") .SetUseOsDefaultBrowser(false) .SetGrantBrowserPermissions(false) .RegisterWebMessageReceivedHandler((sender, message) => { // Handle JSON messages from JS var response = MessageRouter.Handle(message); ((PhotinoWindow)sender!).SendWebMessage(response); }) .SetLogVerbosity(PhotinoLogLevel.Info); // Register custom scheme app:// to serve embedded files win.SetCustomScheme("app", (object? sender, string scheme, string url, out string contentType) => { // Map app://index.html, app://_framework/..., etc. to embedded resources var path = UrlToResourcePath(url); // implement mapping contentType = MimeTypes.FromPath(path); var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream(path); if (stream == null) return null; // Photino will show not found using var ms = new MemoryStream(); stream.CopyTo(ms); return ms.ToArray(); }); // Load the Bolero app win.Load("app://index.html"); win.Center(); win.WaitForClose(); } }Notes:
- Embed the
App.Clientpublish folder intoApp.Hostas resources during build (see Build/Packaging section). - On Linux ensure
libwebkit2gtkis installed. On Windows ensure WebView2 runtime. On macOS WebKit is built‑in.
Client (Bolero) setup
Packages
BoleroFSharp.Core
Minimal Elmish program (F#)
module App open Bolero open Bolero.Html open Elmish type Model = { Text: string; NativeReply: string option } type Msg = | SetText of string | CallNative | NativeResponse of string let init _ = { Text = ""; NativeReply = None }, Cmd.none let update msg model = match msg with | SetText t -> { model with Text = t }, Cmd.none | CallNative -> model, Cmd.OfAsync.perform JSInterop.callNative { kind = "echo"; payload = model.Text } NativeResponse | NativeResponse r -> { model with NativeReply = Some r }, Cmd.none let view model dispatch = div [] [ input [ attr.value model.Text; on.input (fun e -> dispatch (SetText e.Value)) ] button [ on.click (fun _ -> dispatch CallNative) ] [ text "Call native" ] cond model.NativeReply <| function | None -> empty | Some r -> p [] [ text ($"Native replied: {r}") ] ] type MyApp() = inherit ProgramComponent<Model, Msg>() override this.Program = Program.mkProgram init update viewJS interop bridge (
wwwroot/interop.js)window.appBridge = (function(){ const pending = new Map(); let nextId = 1; // Receive messages from .NET host if (window.external && typeof window.external.receiveMessage === 'function') { window.external.receiveMessage(function (message) { try { const msg = JSON.parse(message); if (msg.requestId && pending.has(msg.requestId)) { const { resolve, reject } = pending.get(msg.requestId); pending.delete(msg.requestId); msg.ok ? resolve(msg.result) : reject(msg.error); } } catch (e) { console.error(e); } }); } function send(kind, payload) { return new Promise((resolve, reject) => { const requestId = nextId++; pending.set(requestId, { resolve, reject }); const envelope = JSON.stringify({ requestId, kind, payload }); if (window.external && typeof window.external.sendMessage === 'function') { window.external.sendMessage(envelope); } else { reject('native bridge not available'); } }); } return { send }; })();F# wrapper for JS bridge
module JSInterop open Microsoft.JSInterop let callNative (js: IJSRuntime) (req: obj) = js.InvokeAsync<string>("appBridge.send", [| box ("custom") ; box req |]) |> Async.AwaitTaskNote: If you prefer, wrap specific operations:
openFileDialog,readTextFile,writeTextFile, etc., each callingappBridge.sendwith a specifickind.
Message routing in Host
public static class MessageRouter { public static string Handle(string json) { try { var req = JsonSerializer.Deserialize<Request>(json); return req?.Kind switch { "echo" => Reply(req, ok: true, result: req.Payload), "openFile" => Reply(req, ok: true, result: Native.OpenFile()), "readTextFile" => Reply(req, ok: true, result: Native.ReadText((string)req.Payload!)), // add more cases... _ => Reply(req, ok: false, error: "Unknown kind") }; } catch (Exception ex) { return JsonSerializer.Serialize(new Response { Ok = false, Error = ex.ToString() }); } } static string Reply(Request? req, bool ok, object? result = null, string? error = null) { return JsonSerializer.Serialize(new Response { RequestId = req?.RequestId ?? 0, Ok = ok, Result = result, Error = error }); } public class Request { public int RequestId { get; set; } public string Kind { get; set; } = ""; public object? Payload { get; set; } } public class Response { public int RequestId { get; set; } public bool Ok { get; set; } public object? Result { get; set; } public string? Error { get; set; } } }
Serving Bolero without a web server
Blazor WebAssembly normally fetches
.dlland.wasmfiles over HTTP(s). To avoid a network server:- Use Photino’s
SetCustomSchemeto createapp://and serve static bytes from embedded resources. Blazor will happily fetch fromapp://_framework/...if paths are consistent. - Ensure
index.htmlreferences are relative (./_framework/...) so they resolve underapp://. - Alternatively,
Loadafile://path to the published folder. This can work, but some browsers blockfile://fetches for subresources or apply stricter CORS;app://is more robust.
Minimal
index.htmlexample:<!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <base href="/" /> <meta http-equiv="Content-Security-Policy" content="default-src 'self' app: 'unsafe-inline' 'unsafe-eval' data: blob:;"> <script src="_framework/blazor.webassembly.js" defer></script> <script src="interop.js" defer></script> </head> <body> <div id="main">Loading…</div> <app></app> </body> </html>Adjust CSP as needed; allow
app:so resources load.
Native capability examples
Implement in
App.Host:- File pickers: Use
PhotinoWindow’s platform dialogs or .NET APIs (OpenFileDialogon Windows; GTK dialogs on Linux via a small wrapper; NSSavePanel on macOS). Alternatively, rely on cross‑platform packages or write per‑OS code behindRuntimeInformation.IsOSPlatform. - File system:
File.ReadAllText,File.WriteAllText, directory enumeration. - Clipboard: platform‑specific clipboard calls or packages.
- Notifications/tray: Photino offers basic tray integration; otherwise use platform APIs/packages.
- Shell: open external URLs/files via
Process.StartwithUseShellExecute = true.
Each operation is exposed as a message
kindhandled byMessageRouter.
Build and packaging
- Build/publish the Bolero client:
dotnet publish App.Client -c Release- Output:
bin/Release/net8.0/browser-wasm/publish/wwwroot(or similar).
- Embed client assets into the host:
- Add an
ItemGroupinApp.Host.csproj:
- Add an
<ItemGroup> <EmbeddedResource Include="..\App.Client\bin\Release\net8.0\browser-wasm\publish\wwwroot\**\*" LogicalName="wwwroot/%(RecursiveDir)%(Filename)%(Extension)" /> </ItemGroup>- In your scheme handler, map
app://{path}towwwroot/{path}resource names.
- Optionally, copy assets next to the host executable and use
file://loading instead of embedding. - Single‑file publish for host:
dotnet publish App.Host -c Release -r win-x64 --self-contained true /p:PublishSingleFile=true(repeat forosx-x64,linux-x64).
Development workflow
- Fast iteration: run Bolero dev server (
dotnet watch runforApp.Client) and point Photino tohttp://localhost:xxxx/index.htmlin Debug builds. - Production: switch to
app://index.htmlload and embedded resources. - Put this behind a compile‑time flag or app setting.
Example:
#if DEBUG win.Load("http://localhost:5000"); #else win.Load("app://index.html"); #endif
Common pitfalls and fixes
- Resource paths: Ensure
_framework/and other folders match exactly whatblazor.boot.jsonexpects. Keep thebasehref consistent, or prefer relative paths inindex.html. - CSP: If CSP is too strict, WASM may be blocked. Allow
wasm-unsafe-evalor'unsafe-eval'depending on engine and Blazor version; includeblob:if needed. - WebKitGTK on Linux: Require
libwebkit2gtk-4.0(or 4.1 on some distros). Document dependency for users. - Large payloads: WASM AOT greatly increases size; consider Brotli‑compressed assets and ensure the scheme handler sets
Content-Typeand optionallyContent-Encodingif you ship pre‑compressed files. - Message ordering: Ensure your JS bridge queues messages until
receiveMessageis registered.
Optional enhancements
- Strongly‑typed interop: Generate F# discriminated unions for requests/replies in
App.Sharedand useThoth.Jsonfor safe serialization. - Background services: Run a .NET background Task for long operations; push progress to JS via
SendWebMessage. - Multi‑window: Create multiple
PhotinoWindowinstances and route messages per window. - Theming: Load CSS frameworks (Tailwind, Bulma) as static assets under the custom scheme.
Summary
- Use Bolero for the UI and app logic compiled to WebAssembly.
- Host the UI fully offline inside Photino by serving the published Bolero
wwwrootvia a customapp://scheme orfile://. - Bridge Bolero to the native .NET host using Photino’s message API (
window.external.sendMessage/receiveMessage). - Package everything in a single executable per platform; no network, no local server, and full access to native OS capabilities via your message handlers.
- IIn reply toIvanTheGeek⬆:@IvanTheGeek
Outline a plan to use bolero with photino for .net to build cross platform desktop app with the UI not being ran over the network. The idea is to use HTML as the UI part, with all the functionality and access of a native app running on the os. Using Photino because Blazor Hybrid is not for Linux. Using webassembly will not required. A better approach should be to have the "Server" side render the screen and simply use the client side to display the results.
- I@IvanTheGeek
From Google:
A plan to build a cross-platform desktop application using Photino and Bolero with F# involves leveraging Photino's native windowing and embedded browser capabilities, while Bolero handles the UI rendering and interaction logic without relying on client-side WebAssembly.
- Project Setup:
Create a new F# Photino application: Utilize the Photino templates or create a new F# console application and add the Photino.NET NuGet package.
Integrate Bolero: Add the Bolero NuGet package to your project. This will provide the necessary components for defining your UI with F# and handling server-side rendering. - Core Application Logic (F#):
Define your application state: Create immutable F# records or discriminated unions to represent the data and state of your application.
Implement business logic: Write pure F# functions to handle data manipulation, API calls, and any other core application functionality. These functions will operate on your application state.
Model-View-Update (MVU) pattern: Adopt the MVU pattern (similar to Elm) for managing UI state and interactions. Define:
Model: The current state of your application.
Msg: Discriminated union representing all possible user actions or events.
update: A function that takes a Msg and the current Model and returns a new Model (and potentially commands to perform side effects).
view: A function that takes the Model and renders it into HTML using Bolero's templating capabilities. - Photino Integration:
Create the Photino window: In your F# application's entry point, instantiate a PhotinoWindow and configure its properties (size, title, etc.).
Load the HTML: Instead of serving a static HTML file, use Bolero's server-side rendering capabilities to generate the initial HTML content based on your application's initial Model. This HTML will then be loaded into the Photino window.
Handle UI interactions: When a user interacts with the UI in the Photino window (e.g., clicks a button), the event will be captured by Photino and relayed back to your F# application. You will then map these events to corresponding Msg types in your Bolero application. - Bolero UI Rendering:
Define your UI components: Use Bolero's F# DSL to define your HTML structure and components within the view function of your MVU pattern. This will involve using functions like Html.div, Html.button, Html.text, and others to construct the UI tree.
Dynamic UI updates: When the update function produces a new Model, Bolero will efficiently re-render only the changed parts of the HTML, sending the minimal diff to the Photino window for display. This avoids full page reloads and provides a smooth user experience. - Native Functionality and OS Access:
Platform-specific interop: Use .NET's P/Invoke or platform-specific libraries to interact with OS-level features (e.g., file system access, system notifications, hardware integration). These interactions will be initiated from your F# business logic.
Expose native functions to the UI: If necessary, you can expose F# functions as JavaScript functions within the Photino context, allowing your Bolero UI to directly call native functionality when required. - Deployment:
Publish the F# application: Use the .NET SDK to publish your application as a self-contained executable for each target platform (Windows, macOS, Linux). This will bundle the .NET runtime and all necessary dependencies.
Package the application: Create platform-specific installers or packages (e.g., MSI for Windows, DMG for macOS, DEB/RPM for Linux) to distribute your application to users.
This plan outlines a robust approach to building cross-platform desktop applications with a native feel and full OS access, leveraging the power of F#, Photino, and Bolero for a streamlined development experience.
- Project Setup:
- I
- I@IvanTheGeek
Elmish is a library for F# that implements the Model-View-Update (MVU) architecture, a pattern for building user interfaces made popular by the Elm language. It is a powerful and predictable way to manage UI state, and it is known for producing robust and reliable applications.
The central idea is a unidirectional data flow, where the application is built around four primary concepts: Model, View, Message, and Update.-
Model
The Model is the heart of an Elmish application.
Immutable State: It is a single, immutable data structure that represents the entire state of your application at a given moment in time. This makes the state predictable and easy to reason about.
Single Source of Truth: With all application state in one place, there is no ambiguity about what the current state is.
For example, a simple counter application's model might be a single integer:
fsharp
type Model = {
value: int
}
Use code with caution. -
Message (Msg)
A Message is a discriminated union that represents every possible event that can change your application's state.
Explicit Events: This makes all state transitions explicit and easy to understand. You can tell exactly what caused a state change by looking at the message.
Compiler-Checked: F#'s compiler will ensure that your update function handles every possible message, preventing you from forgetting to handle a new type of event.
For the counter app, the messages would be Increment and Decrement:
fsharp
type Msg =
| Increment
| Decrement
Use code with caution. -
Update (update)
The update function is a pure function that defines how the application's state changes in response to messages.
Pure and Testable: Because it takes the current Model and a Msg and returns a new Model, it has no side effects and is very easy to test.
Side Effects via Cmd: If a state change needs to trigger a side effect (like an API call or a file write), the update function returns a Cmd alongside the new Model.
For the counter app, the update function would look like this:
fsharp
let update (msg: Msg) (model: Model) : Model * Cmd =
match msg with
| Increment -> { model with value = model.value + 1 }, Cmd.none
| Decrement -> { model with value = model.value - 1 }, Cmd.none
Use code with caution. -
View (view)
The view is a pure function that translates the current Model into a user interface representation.
Declarative UI: The view function describes what the UI should look like for a given state. The renderer (e.g., React, Avalonia, or Blazor) handles the efficient process of updating the DOM.
UI as a Function of State: The UI is a direct result of the Model. There is no need for manual DOM manipulation or state synchronization.
Dispatches Messages: The view function can also produce a dispatch function, which allows UI events (like a button click) to send a Msg back into the Elmish loop.
For the counter app, a view function using Feliz (a React renderer) might look like this:
fsharp
let view (model: Model) (dispatch: Msg -> unit) =
Html.div [
Html.button [
prop.onClick (fun _ -> dispatch Decrement)
prop.text "-"
]
Html.span [ prop.text (string model.value) ]
Html.button [
prop.onClick (fun _ -> dispatch Increment)
prop.text "+"
]
]
Use code with caution.
The Elmish flow
The init function provides the initial Model and any initial Cmds.
The view function renders the initial UI based on the initial Model.
User interactions (like clicks) trigger the dispatch function, which sends a Msg.
The update function receives the Msg and the current Model.
It produces a new Model and possibly a Cmd for side effects.
If a new Model is produced, the view function is called again to re-render the UI.
The Cmd (if any) is executed asynchronously and may produce a new Msg upon completion, starting the cycle over.
This cycle ensures that state changes are handled consistently and predictably, leading to more robust and maintainable UI applications. -
- I@IvanTheGeek
Using the term "Model" instead of "State" in Elmish is a deliberate choice inherited from the Elm programming language. While they both refer to the data that represents an application, "Model" has connotations that are more aligned with the functional and declarative nature of the MVU architecture.
Here are the key reasons for the distinction:- Immutability
State implies mutability: In many object-oriented or imperative programming paradigms, "state" is commonly associated with mutable data that can be changed in place. You might have an object with a state field that is updated directly.
Model implies immutability: In Elmish, the Model is always treated as an immutable snapshot of your application's data at a specific point in time. When a change occurs, the update function doesn't modify the existing Model; it creates and returns a completely new one. This makes the flow of data predictable and helps avoid common bugs related to state corruption. - Architectural context
State is a low-level concept: "State" can be localized to individual components, variables, or parts of a system.
Model is an architectural concept: In the MVU pattern, the Model represents the single, centralized source of truth for the entire application. It is the data that the view function renders, and it is the data that the update function changes. This architectural clarity is a central part of the pattern's value. - Separation of concerns
State can mix data and logic: Imperative state management often mixes data with the logic to manipulate it, a pattern often seen in object-oriented approaches.
Model is pure data: In Elmish, the Model is just data. The logic to transition between different Model states is contained entirely within the pure update function. This separation makes the code easier to reason about, test, and debug. - Semantic alignment with MVC
Familiarity with other patterns: The "Model-View-Controller" (MVC) pattern is a foundational concept in many programming languages. Using "Model" and "View" makes the pattern immediately recognizable to developers with that background, highlighting the architectural lineage of MVU. - Time-travel debugging
The "state" of the past: Because every new Model is an immutable replacement of the old one, the Elmish runtime can effectively record the entire history of your application's state. This enables "time-travel debugging," where you can move backward and forward through your application's history by replaying past Models. This is not easily done with mutable state.
In summary, calling it the "Model" is a deliberate choice that emphasizes immutability, architectural clarity, and the functional paradigm at the heart of Elmish. While you can describe it as "the application's state," referring to it as the "Model" reinforces the specific, predictable, and robust design pattern of MVU
- Immutability
- In reply toIvanTheGeek⬆:I@IvanTheGeek
How would Adam Dymitruk's Event Modeling relate to the elmish concepts
- I@IvanTheGeek
Adam Dymitruk's Event Modeling and F# Elmish share a deeply compatible, event-centric worldview, but they operate at different levels of abstraction. Event Modeling is a high-level, visual design technique for systems, while Elmish is a low-level, code-based architectural pattern for user interfaces. An event model can be seen as the blueprint that you could then implement using Elmish.
Here is a breakdown of how the concepts relate:
The big picture: Events and causality
Shared philosophy: Both approaches are fundamentally driven by events. An Elmish application reacts to user actions (messages/commands) by updating its state (model). An Event Model describes a system as a series of events that occur over time, often triggered by commands.
Complementary focus: Event Modeling focuses on the "what" and "why" from a business perspective: the significant, chronological events in a system and the user actions that cause them. Elmish focuses on the "how" from a code perspective: how to manage the UI state in response to those actions.
Event Modeling components and their Elmish counterparts- UI/UX (Wireframes/Mockups)
Event Modeling: These are the visual representations of the system that show what the user sees at any point in the timeline.
Elmish: This directly corresponds to the view function. An Elmish view function takes the current Model (which is the result of all past events) and produces the UI representation. The mockups from the Event Model provide a perfect guide for how to structure the view function's output. - Commands (User Input)
Event Modeling: Commands are user actions (like a button click or form submission) that are sent to the system to request a state change. In Event Modeling, they are visualized as blue sticky notes.
Elmish: These are the Msg (Message) values in Elmish. A user interaction in the view function calls a dispatch function, which wraps the action in a Msg and sends it to the update function. The commands identified during Event Modeling are precisely the messages you would define in your F# code. - Events (State Changes)
Event Modeling: Events represent a system's state change and are recorded as a historical fact. They are depicted as orange sticky notes in the timeline.
Elmish: These are implicit in the Elmish update function. When update receives a Msg, it produces a new Model. The fact that a new Model was produced is the record of the state change. For a persistent system, you would save these events (which are conceptually the same as the messages) to an event store. - Read Models (Data Views)
Event Modeling: Read Models are the green sticky notes that show a specialized, tailored view of the data for a specific user need. These are derived from the stream of events.
Elmish: This can manifest in two ways within an Elmish architecture:
The Model itself: The Elmish Model is the primary read model for the UI. It is a single, aggregated view of the entire UI state derived from the history of messages.
External read models: For more complex applications (e.g., those using a backend event store), Elmish might only store a limited set of UI-specific data. The application might issue commands that trigger backend processes, which then use Event Sourcing to update specialized read models that the Elmish UI can query.
Putting it all together
Think of the relationship as design vs. implementation:
You would start with an Event Modeling session involving all stakeholders to create a visual timeline. This would define the user experience (UI/UX wireframes), the user actions (commands), and the business facts (events).
Then, the developers would take this blueprint and use Elmish to implement the front-end application.
The UI wireframes become the code for the view function.
The commands on the timeline become the Msg discriminated union.
The events on the timeline become the logic inside the update function that produces the new Model.
The read models on the timeline become the shape of the Elmish Model and, potentially, other data queries to a backend.
This synergy allows a team to leverage the visual, business-centric design of Event Modeling while still benefiting from the robust, predictable, and functional code produced by the Elmish architecture.
- UI/UX (Wireframes/Mockups)
- In reply toIvanTheGeek⬆:I@IvanTheGeek
how could elmish terms be modified to better allign with Event Modeling in implimentation
- I@IvanTheGeek
- The Msg type becomes more specific
Instead of a general Msg discriminated union, you can distinguish between different types of events that align with Event Modeling.
Command: Represents a user intent or a request to change the system's state. These are the blue sticky notes in Event Modeling and should be named with an imperative verb (e.g., AddToCart, RegisterUser).
Event: Represents a fact that has already occurred in the system. These are the orange sticky notes and should be named with a past-tense verb (e.g., UserRegistered, ItemAddedToCart).
In this model, your update function would receive Commands from the view function. Once it processes the command, it might produce an Event. This Event is not dispatched directly by the view; instead, it might trigger a subsequent update cycle or be persisted to an event store.
F# implementation:
fsharp
// User-initiated actions (Commands)
type Command =
| AddItemToCart of itemId: string
| Checkout
// Facts that have occurred (Events)
type Event =
| ItemAddedToCart of itemId: string
| OrderPlaced of orderId: stringlet update (command: Command) (model: Model) : Model * Cmd =
match command with
| AddItemToCart itemId ->
// Logic to add item, potentially validate
// Return a new model and a command to produce the event
{ model with cart = ... }, Cmd.ofMsg (ItemAddedToCart itemId)
| Checkout ->
// Logic to finalize order, potentially call API
// Return a new model and a command to produce the event
{ model with order = ... }, Cmd.ofMsg (OrderPlaced Guid.NewGuid().ToString())
Use code with caution.Note: This is a slight deviation from pure Elmish where update returns Cmd. Here, we're returning Cmd to reflect the conceptual difference.
2. The Model is renamed to ReadModel or Projection
In Event Modeling, the UI state is a "Read Model" or "Projection," which is derived from the history of events.
Clarifies role: Renaming the Model to ReadModel clarifies that it's not the primary source of truth (that would be the event stream), but rather a snapshot of the current state optimized for a specific UI view.
Encourages event-first thinking: This forces developers to think about how the UI state is built from events, rather than just how it changes in place.
F# implementation:
fsharp
type CartReadModel = {
items: string list
status: string
}// Rename your init and update functions accordingly
let init () : CartReadModel * Cmd = ...
let update (command: Command) (readModel: CartReadModel) : CartReadModel * Cmd = ...
let view (readModel: CartReadModel) (dispatch: Command -> unit) = ...
Use code with caution.- The init function aligns with Rehydration from Event Sourcing
The init function typically sets up the initial state. In an Event-Sourced system, this initial state (the ReadModel) is not fixed; it's rehydrated by replaying all the events from the beginning of time.
Simulates event replay: A more Event Modeling-aligned init function would take a stream of Events as input and fold them into the initial ReadModel.
F# implementation:
fsharp
// A function that replays events to build the read model
let buildReadModel (events: Event list) : CartReadModel =
let folder (model: CartReadModel) (event: Event) =
match event with
| ItemAddedToCart itemId -> { model with items = model.items @ [itemId] }
| OrderPlaced _ -> { model with status = "Placed" }
List.fold folder { items = []; status = "Open" } events
// Your init function would call this with the initial event stream
let init (events: Event list) : CartReadModel * Cmd =
let initialReadModel = buildReadModel events
initialReadModel, Cmd.none
Use code with caution.Note: For a simple UI, the event stream might just be the initial events. For a persistent, real-world app, this would involve fetching events from a backend event store.
4. Side Effects (Cmds) become more explicit
Elmish's Cmd handles side effects, but in an Event Modeling context, you might want to make them more explicit about their purpose.
Command dispatch: Some Cmds might be about dispatching commands to a backend service.
Event storage: Others might be about persisting events.
You could create a new discriminated union for side effects to distinguish them from standard Elmish Cmds.
F# implementation:
fsharp
type CommandEffect =
| DispatchToBackend of command: Command
| PersistEvent of event: Eventlet update (command: Command) (readModel: ReadModel) : ReadModel * Cmd =
match command with
| Checkout ->
let orderPlacedEvent = OrderPlaced Guid.NewGuid().ToString()
let newReadModel = { readModel with status = "Placed" }
newReadModel, Cmd.ofMsg (PersistEvent orderPlacedEvent)
| _ -> readModel, Cmd.none
Use code with caution.Summary of term changes
Elmish Term Event Modeling Aligned Term Conceptual Shift
Msg Command or Event Separates user intent from system facts.
Model ReadModel or Projection Emphasizes that UI state is derived from events.
init init/Rehydrate Handles initialization by replaying a stream of events.
update handleCommand Explicitly states that the function processes a command.
Cmd CommandEffect Differentiates between backend and local side effects. - The Msg type becomes more specific
- In reply toIvanTheGeek⬆:I@IvanTheGeek
So really, there would be a set of state that relate to the UI aspect and the user experience (UX) and a set of state (events) that related to the business purpose of the app.
- I@IvanTheGeek
Yes, that is the key takeaway. You would split your state management into two distinct, but related, concepts:
Business Events (The Core Truth): An immutable, time-sequenced log of every business-significant action that has occurred in the system. These are the "events" from Event Modeling and form the single source of truth for the entire application. In a more complex, Event-Sourced system, this event log would be the persistent storage layer.
UI State (The Derived View): The Elmish ReadModel (or Model in standard Elmish) is a projection of the business events, filtered and structured specifically for rendering a particular user interface. It may also contain temporary, UX-specific data that has no business relevance, like whether a modal dialog is open or if a form has been touched.
Domain State VS UI State - Sangwin Gawande - Medium
Nov 16, 2021 — Domain State vs. UI State. There are mainly 2 types of states we have in an application. ... For the definition any pi...
Favicon
MediumState vs Event-Based Data Model - Kurrent Docs
Oct 15, 2025 — Two Perspectives: Current State and Historical Events Business objects in a system can be viewed as current state or h...
Favicon
Kurrent Docs
An example to illustrate the distinction
Imagine a food ordering app.
Business Events:
These are facts that are important to the business domain and would be stored in a persistent event stream.
ItemAddedToCart: The user added an item to their cart.
OrderPlaced: The user successfully placed an order.
PaymentReceived: The payment for the order was processed.
OrderDelivered: The food was delivered to the customer.
UI State (ReadModel):
This is the data the view function uses to render the UI, and it's built from the business events.
For the shopping cart page: The ReadModel for this screen would be built by replaying all ItemAddedToCart events that have not yet been followed by an OrderPlaced event. It might also include temporary UI state like isCheckoutButtonEnabled.
For the order history page: The ReadModel for this page would be built by replaying all OrderPlaced events, plus any subsequent events like OrderDelivered to show the current status.
For temporary UI state: The ReadModel would contain data not tied to business events, such as isLoading (a boolean for showing a spinner during an API call) or isValidationErrorVisible (a boolean for showing an error message).
How they relate in an Elmish implementation
In an Event Modeling-aligned Elmish application:
The update function acts as the command handler. It receives a Command (a user's intent) and uses it to determine which Business Event should be dispatched.
The Business Event is then used to update the ReadModel for the UI.
The view function uses the current ReadModel to render the UI.
If your system uses a persistent event store, the business events would be stored, and the UI's ReadModel would be rehydrated from that store when the application starts.
This separation of concerns offers several advantages:
Business logic is decoupled from UI details: The core business events don't care if the UI is a web app, a mobile app, or a CLI tool.
Improved testability: You can test the update function and the business event generation without needing to render or interact with a UI.
Easier maintenance: Changes to the UI (e.g., adding a new field or component) don't require changes to the core business logic, and vice-versa.
- In reply toIvanTheGeek⬆:I@IvanTheGeek
So if I wanted the UI state to be stored using event sourcing (even if just in memory) AND domain (business) events stored in an event store
- I@IvanTheGeek
If you want your Elmish application to use Event Sourcing for both UI state and business events, you would need to set up two separate event streams within your application's architecture. The core Elmish loop would manage the UI event stream, and side effects would be used to communicate with the domain event store.
This approach aligns with the Command Query Responsibility Segregation (CQRS) pattern, where the "write" side of your application (commands and domain events) is separate from the "read" side (queries and UI state).
Here's a breakdown of the architecture and an example implementation:- Architectural setup
Dual event streams: You would have a UiEvent type and a DomainEvent type.
Two event stores:
In-memory UI Event Store: This is essentially a list held within your main Elmish Model or Program.State. It serves the purpose of UI time-travel debugging and managing temporary UI-specific state.
Persistent Domain Event Store: A separate storage layer (e.g., an in-memory List for simple scenarios, or a database for production) that holds the immutable DomainEvent stream.
The Elmish update function: This function is the central coordinator. It handles Commands (from user actions) and, through side effects, interacts with both event stores. - Example implementation
Define the event types
fsharp
type UiEvent =
| ShowLoadingSpinner
| HideLoadingSpinner
| OpenCartModal
type DomainEvent =
| ItemAddedToCart of itemId: string
| OrderPlaced of orderId: string
Use code with caution.Define the model and commands
The Elmish Model now represents the in-memory UI event stream and the UI ReadModel derived from it.
fsharp
type UiReadModel = {
isLoading: bool
isCartModalOpen: bool
cartItems: string list
}type Model = {
uiEventLog: UiEvent list
domainEventLog: DomainEvent list
uiReadModel: UiReadModel
}type Command =
| AddItemToCart of itemId: string
| PlaceOrder
| ShowCart
Use code with caution.The update function: Orchestrating the flow
The update function acts as the command handler and is responsible for producing effects that update both event streams. It leverages Cmd for side effects.
fsharp
let handleCommand (cmd: Command) (model: Model) : Model * Cmd =
match cmd with
| AddItemToCart itemId ->
// 1. Append a new domain event to the domain event log.
let newDomainEvent = ItemAddedToCart itemId
let newDomainEventLog = newDomainEvent :: model.domainEventLog// 2. Derive a new UI read model from the updated domain events. let newUiReadModel = { model.uiReadModel with cartItems = model.uiReadModel.cartItems @ [itemId] } // 3. Append a new UI event to the UI event log. let newUiEvent = HideLoadingSpinner // for example let newUiEventLog = newUiEvent :: model.uiEventLog let newModel = { model with domainEventLog = newDomainEventLog uiReadModel = newUiReadModel uiEventLog = newUiEventLog } // 4. Return the new model and trigger a side effect to persist the domain event. newModel, Cmd.ofMsg (Command.ShowCart) | PlaceOrder -> // Simulate a call to a service that handles placing orders. let placeOrderCmd = Cmd.ofAsync (fun () -> // In a real app, this would be an API call async { do! Async.Sleep 500 // Simulate latency let orderId = Guid.NewGuid().ToString() return OrderPlaced orderId } ) (fun domainEvent -> // Handle the resulting domain event. // This will dispatch a new command to re-run the update loop. Command.PlaceOrder ) { model with uiReadModel = { model.uiReadModel with isLoading = true } }, placeOrderCmd | ShowCart -> let newUiReadModel = { model.uiReadModel with isCartModalOpen = true } let newUiEventLog = OpenCartModal :: model.uiEventLog { model with uiReadModel = newUiReadModel; uiEventLog = newUiEventLog }, Cmd.noneUse code with caution.
Note: The command handling logic shown is simplified. In a real-world scenario, you would have more robust mechanisms for replaying and updating state.
3. The view and other components
The view function will take the uiReadModel and render the UI.
The dispatch function would be used to send Commands.
Subscriptions could be used to monitor external changes, like WebSocket messages, that result in new DomainEvents that need to be processed.
Advantages of this approach
Comprehensive history: You have a complete, immutable history of both UI interactions and business facts, allowing for powerful auditing, analytics, and debugging.
Predictable UI state: The UI ReadModel is always derived from a known stream of events, making UI behavior deterministic.
Robustness: Your core business logic is encapsulated in the production and handling of DomainEvents, which are independent of the UI.
Simplified debugging: The uiEventLog provides a built-in time-travel debugging feature, allowing you to replay user actions and see the exact UI state at any point. - Architectural setup
- In reply toIvanTheGeek⬆:I@IvanTheGeek
Is there a better term for the update function
- I@IvanTheGeek
When aligning Elmish with Event Modeling, there are several more descriptive terms for the update function that better reflect its specific responsibilities in a CQRS/Event Sourcing-style architecture. The best term depends on the scope of the function and the level of domain detail you want to emphasize.
Here are some strong alternatives:- handleCommand
This is a very clear and functional name that directly aligns with the Command-handling part of CQRS.
Why it's good: It tells you exactly what the function does: it handles a Command (a user's intent to perform an action) and produces a result. This result is a side effect that may include dispatching a DomainEvent or updating the UI state.
Best for: The top-level function that receives user input (Command) from the view and orchestrates the subsequent state changes and side effects. - evolve
This term is borrowed directly from the Event Sourcing community and is used to describe how an aggregate's state evolves over time by processing events.
Why it's good: It emphasizes that the function doesn't just "update" but rather moves the application's state from one version to the next in a sequential, deterministic way. It also fits well with the idea of a ReadModel or Projection being built from a history of events.
Best for: A pure function that takes a list of DomainEvents and folds them into a ReadModel, rebuilding the UI state from scratch. This is a great name for the function that you'd call within init or whenever you need to rehydrate the state. - applyEvent or apply
This is another term common in the Event Sourcing community. It refers to a pure function that takes a single Event and applies its changes to the current state to produce the next state.
Why it's good: It is concise and clearly describes the action of consuming an event to produce a new state. This function is typically used as the folder in a List.fold or Array.fold operation to rehydrate a ReadModel.
Best for: A low-level, pure helper function used within a larger rehydrate or evolve function.
How to use them in practice
You can use a combination of these names to create a clear separation of concerns in your Elmish implementation:
fsharp
// This handles user intent (the Command)
let handleCommand (command: Command) (model: Model) : Model * Cmd =
match command with
| AddItemToCart itemId ->
// Create a new domain event as a side effect
let domainEvent = DomainEvent.ItemAddedToCart itemId
let newModel = evolve model domainEvent // Use evolve for state change
newModel, Cmd.ofMsg (Command.Persist domainEvent)
| _ -> model, Cmd.none
// This pure function processes a single domain event
let applyEvent (event: DomainEvent) (readModel: UiReadModel) : UiReadModel =
match event with
| DomainEvent.ItemAddedToCart itemId ->
{ readModel with cartItems = readModel.cartItems @ [itemId] }
| _ -> readModel// This pure function replays events to build the read model
let evolve (model: Model) (event: DomainEvent) : Model =
let newReadModel = applyEvent event model.uiReadModel
{ model with
uiEventLog = model.uiEventLog @ [UiEvent.UpdateReadModel]
domainEventLog = model.domainEventLog @ [event]
uiReadModel = newReadModel
}// The init function starts by rehydrating from a store
let init (events: DomainEvent list) : Model * Cmd =
let initialModel = List.fold (fun model event -> evolve model event) (Model.initial) events
initialModel, Cmd.none
Use code with caution.By using these more specific terms, your code becomes self-documenting and easier for developers to understand in the context of Event Modeling and CQRS.
- handleCommand
- In reply toIvanTheGeek⬆:I@IvanTheGeek
what term might be better aligned for the dispatch function
- I@IvanTheGeek
send
This is a simple, highly descriptive term that captures the action of initiating a command. In an Event Modeling context, a user interaction in the UI sends a Command to the system for processing.
Why it's good: It's intuitive and immediately clear. The mental model is a direct message being sent from the user's interaction point (the UI) to the application's central nervous system (update).
execute or executeCommand
This term is borrowed from the Command Pattern and CQRS terminology. It signifies that the function is responsible for executing a user's Command, not just dispatching a generic message.
Why it's good: It reinforces that the view is triggering a business-level action. This helps maintain the CQRS separation, as the view is asking for a specific action to be performed, rather than just broadcasting a generic event.
post
This term is analogous to posting a message to a message bus or command queue. It emphasizes that the command is being sent to a separate processing unit (update or a backend service) for handling.
Why it's good: It aligns with the message-oriented nature of event-driven architectures. It implies that the view is not waiting for an immediate response but is simply "posting" a request for the system to process later.
How to use them in practice
In a highly aligned Elmish and Event Modeling setup, you could use these terms in the view function where the dispatch function is being used.
Example view function:
fsharp
let view (model: Model) (send: Command -> unit) =
Html.div [
Html.button [
prop.onClick (fun _ -> send (Command.AddItemToCart "item-id-123"))
prop.text "Add to Cart"
]
Html.button [
prop.onClick (fun _ -> send (Command.PlaceOrder))
prop.text "Place Order"
]
]
Use code with caution.Or, if you want to be more explicit about what is being sent:
fsharp
let view (model: Model) (executeCommand: Command -> unit) =
Html.div [
Html.button [
prop.onClick (fun _ -> executeCommand (Command.AddItemToCart "item-id-123"))
prop.text "Add to Cart"
]
]
Use code with caution.Recommendation
For maximum clarity and simplicity: send is an excellent, universally understood term.
For emphasizing the CQRS/Command Pattern aspect: executeCommand is a strong choice.
For aligning with messaging system terminology: post is a good alternative.
Ultimately, the best term is the one that is most clear to your team and aligns best with the chosen architecture's mental model.
- In reply toIvanTheGeek⬆:I@IvanTheGeek
how could I further make explicit the difference between something that relates ONLY to the UI/UX/View aspect and one that affects domain(business) state(events)
- I@IvanTheGeek
One of the most effective ways to further distinguish between UI/UX state and domain (business) events is by explicitly separating them within your Elmish application's architecture, particularly in the types and the central update function.
This can be achieved by:
Using separate discriminated unions for different event types.
Partitioning your Model into distinct records.
Enforcing different handling functions for each type.- Distinct Msg types
Instead of a single Msg type, define different types for different purposes.
UiMsg: Represents purely transient UI/UX events that do not reflect a significant business fact.
DomainMsg: Represents a command that a user has issued, intending to change the domain state.
ServiceMsg: Represents an event that has occurred in a remote service, often resulting from a DomainMsg and used to update the domain state.
All of these can be unified into a single top-level Msg type that the main Elmish loop processes.
fsharp
// Purely UI/UX related actions, not persisted
type UiMsg =
| OpenModal of modalId: string
| CloseModal
| ShowLoadingSpinner
| HideLoadingSpinner
| SetSearchText of text: string
// User commands to initiate business actions
type DomainMsg =
| AddItemToCart of itemId: string
| PlaceOrder
| UpdateProfile of profile: Profile// Events from external services, results of commands
type ServiceMsg =
| ItemAddedToCartEvent of itemId: string
| OrderPlacedEvent of orderId: string// The top-level message that unites them
type Msg =
| Ui of UiMsg
| Domain of DomainMsg
| Service of ServiceMsg
Use code with caution.- Partitioned Model
Separate your Model into a UiState and a DomainState. The UiState should hold everything that is temporary, while the DomainState holds the persistent, business-relevant data derived from the DomainEvent stream.
fsharp
type UiState = {
modalId: string option
isLoading: bool
searchText: string
}
type DomainState = {
cartItems: string list
orderHistory: Order list
}type Model = {
ui: UiState
domain: DomainState
domainEventLog: DomainEvent list // The event store
}
Use code with caution.- Specialized update functions
Instead of one monolithic update, create specialized, pure functions that handle each Msg type. This forces a clear, compiler-checked distinction.
fsharp
// This function is purely for UX changes and has no side effects.
let handleUiMsg (msg: UiMsg) (uiState: UiState) : UiState =
match msg with
| OpenModal modalId -> { uiState with modalId = Some modalId }
| CloseModal -> { uiState with modalId = None }
| ShowLoadingSpinner -> { uiState with isLoading = true }
| HideLoadingSpinner -> { uiState with isLoading = false }
| SetSearchText text -> { uiState with searchText = text }
// This function processes a business command and returns effects.
let handleDomainMsg (msg: DomainMsg) (domainState: DomainState) : DomainState * Cmd =
match msg with
| AddItemToCart itemId ->
// Generate a side effect to call an API.
// On success, the API would return a ServiceMsg.
let updatedState = { domainState with cartItems = domainState.cartItems @ [itemId] }
updatedState, Cmd.ofMsg (Msg.Service (ServiceMsg.ItemAddedToCartEvent itemId))
| PlaceOrder ->
// Generate side effects for external API calls, storage, etc.
domainState, Cmd.ofMsg (Msg.Service (ServiceMsg.OrderPlacedEvent (Guid.NewGuid().ToString())))
| _ -> domainState, Cmd.none// This function processes an event from a service and updates the domain state.
let handleServiceMsg (msg: ServiceMsg) (domainState: DomainState) : DomainState =
match msg with
| ServiceMsg.ItemAddedToCartEvent itemId ->
{ domainState with cartItems = domainState.cartItems @ [itemId] }
| ServiceMsg.OrderPlacedEvent orderId ->
{ domainState with orderHistory = { orderId = orderId; status = "Placed" } :: domainState.orderHistory }// The top-level update function coordinates everything.
let update (msg: Msg) (model: Model) : Model * Cmd =
match msg with
| Ui uiMsg ->
let newUiState = handleUiMsg uiMsg model.ui
{ model with ui = newUiState }, Cmd.none
| Domain domainMsg ->
let newDomainState, cmd = handleDomainMsg domainMsg model.domain
{ model with domain = newDomainState }, cmd
| Service serviceMsg ->
let newDomainState = handleServiceMsg serviceMsg model.domain
{ model with domain = newDomainState }, Cmd.none
Use code with caution.- Renamed dispatch and send
Rename your dispatch function to reflect what it's doing based on the context.
In the view function, you can pass a send function to execute a Command (a DomainMsg).
Within the update function, you can use dispatch or a more specific notify function to send ServiceMsgs.
This explicit separation using distinct types and functions makes the application's flow and purpose immediately clear to anyone reading the code. There is no ambiguity between a temporary UI concern and a permanent business event.
- Distinct Msg types
- In reply toIvanTheGeek⬆:I@IvanTheGeek
What are some strategies for handling side effects when processing DomainMsg and ServiceMsg in Elmish?
- I@IvanTheGeek
Strategies for handling side effects in Elmish, especially with the explicit distinction between DomainMsg (user intent) and ServiceMsg (external event), require a deliberate separation of concerns. The goal is to keep the core update function pure while delegating side effects to the Cmd mechanism.
- Command-to-Event Pattern
The most direct and aligned approach with Event Modeling is to have the DomainMsg trigger a side effect (Cmd) that, upon completion, produces a ServiceMsg. The update function then processes the ServiceMsg to evolve the state.
Strategy:
handleDomainMsg (or handleCommand): Takes DomainMsg and DomainState, returns the updated DomainState and a Cmd.
The Cmd: Wraps an asynchronous operation (e.g., an HTTP call to a backend service).
The side effect returns a ServiceMsg: On success or failure, the asynchronous operation produces a ServiceMsg that is fed back into the Elmish loop.
handleServiceMsg: Takes ServiceMsg and DomainState, returns the new DomainState. It should not have any side effects.
Example:
fsharp
// This is the message that will be returned from the asynchronous call
type ServiceMsg =
| OrderPlacedSuccess of orderId: string
| OrderPlacedFailure of error: string
// Inside handleDomainMsg
let handleDomainMsg (msg: DomainMsg) (domainState: DomainState) : DomainState * Cmd =
match msg with
| PlaceOrder ->
// Return a state showing "pending" and a command to place the order
let updatedState = { domainState with status = "Pending" }
let placeOrderCmd =
Cmd.ofAsync.either
(fun () -> placeOrderAsync domainState.cart) // Your async function
(fun success -> Msg.Service (OrderPlacedSuccess success)) // Success handler
(fun ex -> Msg.Service (OrderPlacedFailure ex.Message)) // Failure handler
updatedState, placeOrderCmd
| _ -> domainState, Cmd.none// Inside handleServiceMsg
let handleServiceMsg (msg: ServiceMsg) (domainState: DomainState) : DomainState =
match msg with
| OrderPlacedSuccess orderId ->
// Update the state based on the successful event
{ domainState with status = "Confirmed"; lastOrderId = Some orderId }
| OrderPlacedFailure error ->
// Update the state to reflect the error
{ domainState with status = "Failed"; error = Some error }
Use code with caution.- Dependency Injection for Side Effects
To improve testability, you can pass a record of "effect dependencies" or "ports" into your Program and update functions. This allows you to swap out real-world implementations for mocks during testing.
Strategy:
Define a record type for your dependencies (e.g., a Persistence or Http module).
Pass this record to the update function (though this is a deviation from the pure MVU pattern). Alternatively, have Cmds take dependencies as arguments.
When running your program, provide a concrete implementation of the dependencies.
When testing, provide a mock implementation.
Example:
fsharp
// Define a record of external dependencies (your "ports")
type Dependencies = {
storeDomainEvent: DomainEvent -> Async
apiClient: ApiClient
}
// Pass dependencies to your
initfunction
let init (deps: Dependencies) : Model * Cmd =
//...
let cmd = Cmd.ofAsync.perform (fun () -> deps.storeDomainEvent initialDomainEvent) () (fun _ -> Msg.NoOp)
//...// The
updatefunction would produce a Cmd that uses these dependencies
let handleDomainMsg (deps: Dependencies) (msg: DomainMsg) (model: Model) : Model * Cmd =
match msg with
| PlaceOrder ->
let orderPlacedEvent = DomainEvent.OrderPlaced orderId
// The side effect uses the injected dependency
let cmd = Cmd.ofAsync.perform (fun () -> deps.apiClient.postOrder orderId) () (fun _ -> Msg.Service ...)
model, cmd
| _ -> model, Cmd.none
Use code with caution.- Using a Centralized Effect Manager
For more complex applications, you can move the command processing logic outside of the update function entirely, using a custom effect manager.
Strategy:
Define a CmdMsg type that represents a pure data representation of a side effect, making update fully testable.
The update function returns model * CmdMsg list.
A separate, external function or component (the effect manager) converts CmdMsg into the actual Cmds that perform the side effects.
Example:
fsharp
// Pure data representing a side effect
type CmdMsg =
| HttpPost of url: string * body: string
| PersistEvent of event: DomainEvent
// The update function is now purely functional and returns data
let update (msg: Msg) (model: Model) : Model * CmdMsg list =
match msg with
| Domain (AddItemToCart itemId) ->
let newEvent = DomainEvent.ItemAddedToCart itemId
let updatedModel = // ...evolve model with new event
updatedModel, [PersistEvent newEvent]
| _ -> model, []// A separate module executes the CmdMsgs
let cmdMsgToCmd (cmdMsg: CmdMsg) : Cmd =
match cmdMsg with
| HttpPost (url, body) ->
Cmd.ofAsync.either // ...
| PersistEvent event ->
Cmd.ofAsync.perform // ...// The Program combines them
Program.withCmdMsg cmdMsgToCmd //...
Use code with caution.Summary of strategies
Strategy Pros Cons Best for
Command-to-Event Simple, idiomatic Elmish; clear separation of concerns. Can make update function large and complex. Standard applications; when you want to stay close to Elmish core.
Dependency Injection Highly testable; predictable side effects. Deviates from standard Elmish pattern; requires discipline. Enterprise applications; highly testable backends.
Centralized Manager Pure update function; highly testable side effects via CmdMsg. Adds a layer of indirection; more boilerplate. Complex applications with many different side effects; large teams. - Command-to-Event Pattern