Summary
A runtime ReflectionTypeLoadException occurs when loading an F# assembly containing a type that implements an interface from a shared library, specifically when the interface member involves generic types like Option<'T> (e.g., Microsoft.FSharp.Core.FSharpOption).
The root cause is that the AssemblyLoadContext implementation fails to resolve and load the F# Core library (FSharp.Core.dll) containing the generic definitions required by the plugin. The dependency resolver primarily tracks managed DLLs, but generic F# types often require runtime type construction that exposes strict versioning and visibility constraints between the host, the base interface assembly, and the plugin.
Root Cause
The failure stems from a mismatch in type identity and assembly loading contexts.
- Missing Dependency Resolution: The custom
AssemblyDependencyResolverused in thePluginLoadContextresolves paths for the plugin’s direct dependencies (likePluginBase.dll), but it does not automatically resolve or prioritize the FSharp.Core.dll required to constructMicrosoft.FSharp.Core.FSharpOption. - Type Identity Mismatch: When the host calls
p.GetTypes(), the CLR attempts to load the type definition ofSomePlugin. This type implementsIPlugin, which expects a return value ofFSharpOption<string>. - Load Context Isolation: If
FSharp.Coreis loaded in the default context (or a different version in the plugin context), the typeMicrosoft.FSharp.Core.FSharpOptionloaded in the plugin context is considered a different type than the one expected by the interface definition or the runtime binder. This results in theMissingMethodExceptionregarding the getterget_TestString.
Why This Happens in Real Systems
In production environments, dynamic plugin architectures are common (e.g., modular IDEs, ERP systems, game engines).
- Strict Isolation: We use
AssemblyLoadContextto ensure plugins do not conflict with the main application (DLL Hell). However, this isolation requires explicit handling of shared libraries. - F# Type Projections: F# specific types like
Option,List, andMapare compiled into theFSharp.Coreassembly. When these types cross assembly boundaries (Host -> Interface -> Plugin), all three contexts must agree on the exact version and loader ofFSharp.Core. - Dependency Graph Complexity: The
AssemblyDependencyResolverrelies on the.deps.jsonfile generated during build. If this file is missing or incorrect in the plugin output, or if the host forces a specific load order, the F# runtime types are missed.
Real-World Impact
- Plugin Initialization Failure: The plugin cannot be instantiated or inspected via reflection, causing the entire module loading process to crash.
- Limitation on Expressive Types: Developers are forced to strip generic F# types from public interfaces, resulting in “stringly-typed” APIs (using strings instead of
Option<string>) or complex serialization layers to bypass the type system. - Deployment Fragility: The application becomes sensitive to the exact location of
FSharp.Core.dll, leading to deployment artifacts that work on the build machine but fail in production containers or client environments.
Example or Code
The original code relies on the AssemblyDependencyResolver which fails to catch FSharp.Core for the custom context. The fix involves explicitly loading FSharp.Core into the context.
open System
open System.IO
open System.Reflection
open System.Runtime.Loader
type PluginLoadContext(pluginPath: string) =
inherit AssemblyLoadContext()
let resolver = AssemblyDependencyResolver(pluginPath)
// CRITICAL FIX: We must explicitly load FSharp.Core into this context
// to ensure the generic types (Option) are resolved correctly.
let fsharpCorePath = typeof<option>.Assembly.Location
override this.Load(assemblyName: AssemblyName) =
// 1. Explicitly handle FSharp.Core
if assemblyName.Name = "FSharp.Core" then
this.LoadFromAssemblyPath(fsharpCorePath)
else
// 2. Resolve other dependencies via the resolver
let assemblyPath = resolver.ResolveAssemblyToPath(assemblyName)
if assemblyPath null then
this.LoadFromAssemblyPath(assemblyPath)
else
null
let LoadPlugin (path: string) =
let pluginLocation = Path.GetFullPath(path.Replace('\\', Path.DirectorySeparatorChar))
try
let loadContext = PluginLoadContext pluginLocation
let assemblyName = AssemblyName(Path.GetFileNameWithoutExtension pluginLocation)
// Force loading the assembly via the context
let assembly = loadContext.LoadFromAssemblyName(assemblyName)
// Return Some assembly
Some assembly
with ex ->
printfn "Error loading plugin: %s" ex.Message
None
// Execution
match LoadPlugin "Plugins/TestPlugin/TestPlugin.dll" with
| None -> printfn "Plugin failed to load"
| Some p ->
try
// This will now work because FSharp.Core is properly loaded in the context
p.GetTypes() |> Array.iter (fun t -> printfn "%s" t.FullName)
with
| :? ReflectionTypeLoadException as ex ->
printfn "Type Load Exception:"
for loaderEx in ex.LoaderExceptions do
printfn "%s" loaderEx.Message
How Senior Engineers Fix It
To resolve this, a senior engineer performs dependency injection at the loader level:
- Explicit Shared Assembly Loading: Instead of relying solely on the
AssemblyDependencyResolver(which calculates paths based on the.deps.jsonfile), the engineer modifies theAssemblyLoadContext.Loadoverride to explicitly intercept requests for known shared libraries, specificallyFSharp.Core. - Context Bridge: They ensure the
FSharp.Coreassembly currently running in the host process is loaded into the plugin context usingthis.LoadFromAssemblyPath. This guarantees type identity:Optionin the Host ==Optionin the Plugin. - Dependency Resolution Verification: They verify that the
AssemblyDependencyResolveris instantiated with the correctpluginPath(the directory containing theplugin.runtimeconfig.jsonanddeps.json) to correctly resolve NuGet dependencies of the plugin.
Why Juniors Miss It
- “It Compiles, So It Should Run”: Juniors often assume that if the IDE resolves the types and the build succeeds, the runtime will automatically wire up dependencies. They don’t anticipate the isolation imposed by
AssemblyLoadContext. - Ignoring F# Core: In C# development, core libraries like
System.Runtimeare usually handled implicitly or by the runtime host. Juniors may not realize that F# requires explicit handling ofFSharp.Corewhen crossing dynamic assembly boundaries. - Over-reliance on Reflection: They use
Assembly.LoadFromwithout understanding the underlying Load Context rules, leading to “Could not load file or assembly” or “Method not found” errors which are hard to debug without understanding the loader’s internal state.