Disclaimer:
Although I accept the gospel of immutable state and higher order functions, my real-world experience is still 95% object-oriented. I'd love to change that, but whatchagonnado. So my brain is very much wired to OO.
Question:
I have this situation very frequently: a piece of business functionality implemented as a small "core" plus multiple "plugins", working together to present a seemingly solid surface to the user. I found that this "microkernel" architecture works extremely well in a lot of circumstances. Plus, very conveniently, it nicely combines with a DI container, which can be used for plugin discovery.
So, how do I do this in a functional way?
I do not think that the basic idea in this technique is inherently object-oriented, because I've just described it without using any OO terms or concepts. However, I can't quite wrap my head around the functional way to go about it. Sure, I can represent plugins as functions (or buckets of functions), but the difficult part comes when plugins need to have their own data as part of the big picture, and the shape of data is different form plugin to plugin.
Below is a small F# snippet that is more or less literal translation of C# code that I would write when implementing this pattern from scratch.
Note the weak points: losing type information in CreateData
, necessary upcast in PersistData
.
I flinch at casts (whether up or down) every time, but I've learned to accept them as a necessary evil in C#. However, my past experience suggests that the functional approach often offers unexpected, yet beatiful and elegant solutions to this kind of problems. It is such solution that I am after.
type IDataFragment = interface end
type PersistedData = string // Some format used to store data in persistent storage
type PluginID = string // Some form of identity for plugins that would survive app restart/rebuild/upgrade
type IPlugin = interface
abstract member UniqueID: PluginID
abstract member CreateData: unit -> IDataFragment
// NOTE: Persistence is conflated with primary function for simplicity.
// Regularly, persistence would be handled by a separate component.
abstract member PersistData: IDataFragment -> PersistedData option
abstract member LoadData: PersistedData -> IDataFragment
end
type DataFragment = { Provider: IPlugin; Fragment: IDataFragment }
type WholeData = DataFragment list
// persist: WholeData -> PersistedData
let persist wholeData =
let persistFragmt { Provider = provider; Fragment = fmt } =
Option.map (sprintf "%s: %s" provider.UniqueID) (provider.PersistData fmt)
let fragments = wholeData |> Seq.map persistFragmt |> Seq.filter Option.isSome |> Seq.map Option.get
String.concat "\n" fragments // Not a real serialization format, simplified for example
// load: PersistedData -> WholeData
let load persistedData = // Discover plugins and parse the above format, omitted
// Reference implementation of a plugin
module OnePlugin =
type private MyData( d: string ) =
interface IDataFragment
member x.ActualData = d
let create() =
{new IPlugin with
member x.UniqueID = "one plugin"
member x.CreateData() = MyData( "whatever" ) :> _
member x.LoadData d = MyData( d ) :> _
member x.PersistData d =
match d with
| :? MyData as typedD -> Some typedD.ActualData
| _ -> None
}
Some updates and clarifications
- I do not need to be educated in functional programming "in general" (or at least that's what I like to think :-). I do realize how interfaces are related to functions, I do know what higher-order functions are, and how function composition works. I even understand
monadswarm fluffy things (as well as some other mumbo-jumbo from category theory). - I realize that I don't need to use interfaces in F#, because functions are generally better. But both interfaces in my example are actually justified:
IPlugin
serves to bind togetherUniqueID
andCreateData
; if not interface, I would use a record of similar shape. AndIDataFragment
serves to limit the types of data fragments, otherwise I would have to useobj
for them, which would give me even less type safety. (and I can't even imagine how I would go about it in Haskell, short of using Dynamic)