My common solution for these scenarios is to build the entire business logic in a purely functional setting, and then provide a thin layer of service with the necessary functionality to synchronize and propagate the changes. Here is a clean interface example for your KernelData type:
type KernelData = { DocumentContent : List<string> } let emptyKernelData = {DocumentContent = []} let addDocument c kData = {kData with DocumentContent = c :: kData.DocumentContent}
Then I would define a service level interface that wraps the functionality for changing and subscribing to changes:
type UpdateResult = | Ok | Error of string /// Service interface type KernelService = { /// Gets the current kernel state. Current : unit -> KernelData /// Subscribes to state changes. Subscribe : (KernelData -> unit) -> IDisposable /// Modifies the current kernel state. Modify : (KernelData -> KernelData) -> Async<UpdateResult> }
Async answers let you block updates. The UpdateResult type UpdateResult used to determine whether update operations succeeded or not. To create a KernelService sound object, it is important to understand that modification requests must be synchronized to avoid data loss from concurrent updates. MailboxProcessor in handy for this. Here is a buildKernelService function that builds the service interface specified by the initial KernelData object.
// Builds a service given an initial kernel data value. let builKernelService (def: KernelData) = // Keeps track of the current kernel data state. let current = ref def // Keeps track of update events. let changes = new Event<KernelData>() // Serves incoming requests for getting the current state. let currentProc : MailboxProcessor<AsyncReplyChannel<KernelData>> = MailboxProcessor.Start <| fun inbox -> let rec loop () = async { let! chn = inbox.Receive () chn.Reply current.Value return! loop () } loop () // Serves incoming 'modify requests'. let modifyProc : MailboxProcessor<(KernelData -> KernelData) * AsyncReplyChannel<UpdateResult>> = MailboxProcessor.Start <| fun inbox -> let rec loop () = async { let! f, chn = inbox.Receive () let v = current.Value try current := fv changes.Trigger current.Value chn.Reply UpdateResult.Ok with | e -> chn.Reply (UpdateResult.Error e.Message) return! loop () } loop () { Current = fun () -> currentProc.PostAndReply id Subscribe = changes.Publish.Subscribe Modify = fun f -> modifyProc.PostAndAsyncReply (fun chn -> f, chn) }
Please note that in the implementation above there is nothing that is unique to KernelData , therefore the service interface together with the build function can be generalized to arbitrary types of internal states.
Finally, some programming examples with KernelService objects:
// Build service object. let service = builKernelService emptyKernelData // Print current value. let curr = printfn "Current state: %A" service.Current // Subscribe let dispose = service.Subscribe (printfn "New State: %A") // Non blocking update adding a document service.Modify <| addDocument "New Document 1" // Non blocking update removing all existing documents. service.Modify (fun _ -> emptyKernelData) // Blocking update operation adding a document. async { let! res = service.Modify (addDocument "New Document 2") printfn "Update Result: %A" res return () } |> Async.RunSynchronously // Blocking update operation eventually failing. async { let! res = service.Modify (fun kernelState -> System.Threading.Thread.Sleep 10000 failwith "Something terrible happened" ) printfn "Update Result: %A" res return () } |> Async.RunSynchronously
Besides the more technical details, I believe that the most important difference from your original solution is that special command functions are not needed. Using the service level, any pure function that works with KernelData (for example, addDocument) can be raised to state calculation using the Modify function.