My general solution for these scenarios is to build all business logic in a purely functional setting and then provide a thin service layer with the necessary functionality for synchronizing and propagating changes. Here's an example of a pure interface for your KernelData
type:
type KernelData = { DocumentContent : List<string> }
let emptyKernelData = {DocumentContent = []}
let addDocument c kData = {kData with DocumentContent = c :: kData.DocumentContent}
I would then define a service layer interface wrapping the functionality for modifying 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>
}
The Async
responses enable non-blocking updates. The UpdateResult
type is used to signal whether update operations succeeded or not. In order to build a sound KernelService
object it's important to realize that modification requests need to by synchronized to avoid data loss from parallel updates. For this purpose MailboxProcessor
s come in handy. Here's a buildKernelService
function that constructs a service interface given an 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 := f v
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)
}
Note that there is nothing in the implementation above that is unique to KernelData
so the service interface along with the build function can be generalized to arbitrary types of internal states.
Finally, some examples of programming 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 the most important difference from your original solution is that special command functions are not needed. Using the service layer, any pure function operating on KernelData
(e.g addDocument) can be lifted into a stateful computation using the Modify
function.