When Reflection Makes Sense
It's tempting to use reflection everywhere for flexibility. Load plugins at runtime, discover types automatically, invoke methods by name. It works until your trimmed app crashes because the linker removed the very types you're trying to reflect over.
Reflection lets you inspect and manipulate types without compile-time knowledge. You can load assemblies from disk, query their metadata, create instances, and call methods all through runtime code. This powers plugin architectures, serialization frameworks, and dependency injection containers.
You'll learn to load assemblies safely, discover types with filters, invoke methods efficiently, and handle the trimming/AOT compatibility issues that reflection creates. By the end, you'll know when reflection is the right tool and when source generators work better.
Loading Assemblies at Runtime
Assembly.Load takes a name and searches the app's probing paths. It checks the base directory, then private bin paths, then the GAC for strong-named assemblies. Once loaded, the assembly stays in memory until the app exits. There's no unloading in the default load context.
Assembly.LoadFrom takes a file path and loads that specific DLL. It still probes for dependencies based on the assembly's location. Use this when you have plugins in a known directory that aren't in your normal probing path.
using System.Reflection;
// Load by name (searches probing paths)
var asm1 = Assembly.Load("System.Text.Json");
Console.WriteLine($"Loaded: {asm1.FullName}");
// Load from specific path
var pluginPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory,
"Plugins", "MyPlugin.dll");
if (File.Exists(pluginPath))
{
var asm2 = Assembly.LoadFrom(pluginPath);
Console.WriteLine($"Plugin: {asm2.GetName().Name}");
}
// Load currently executing assembly
var current = Assembly.GetExecutingAssembly();
Console.WriteLine($"Current: {current.Location}");
Assembly.Load is safer for known dependencies that your app expects. Assembly.LoadFrom works for plugins where the path comes from config or user input. Never use Assembly.LoadFile unless you understand that it won't resolve dependencies automatically.
Discovering and Filtering Types
GetTypes returns every type in an assembly including internal ones. GetExportedTypes returns only public types that consumers can use. Filter these results with LINQ to find types implementing specific interfaces or decorated with attributes.
When building plugin systems, you typically scan for types implementing an interface like IPlugin. This lets plugins register themselves without hardcoded registration code. Just drop a DLL in the plugins folder and the app discovers it automatically.
using System.Reflection;
// Define plugin interface
public interface IPlugin
{
string Name { get; }
void Execute();
}
// Discover plugins in assembly
public static List LoadPlugins(Assembly assembly)
{
var plugins = new List();
var pluginTypes = assembly.GetExportedTypes()
.Where(t => typeof(IPlugin).IsAssignableFrom(t))
.Where(t => !t.IsInterface && !t.IsAbstract);
foreach (var type in pluginTypes)
{
if (Activator.CreateInstance(type) is IPlugin plugin)
{
plugins.Add(plugin);
Console.WriteLine($"Loaded plugin: {plugin.Name}");
}
}
return plugins;
}
This pattern scans for non-abstract classes implementing IPlugin, creates instances, and adds them to a list. The plugin DLL just needs to include classes implementing the interface. No registration code needed.
Invoking Methods and Accessing Properties
Once you have a Type, use GetMethod or GetProperty to get metadata objects. Then call Invoke to execute methods or GetValue/SetValue for properties. This works but it's slow because reflection has to verify types and permissions on every call.
For repeated calls, cache the MethodInfo and consider using CreateDelegate to compile a fast delegate once. The delegate invokes the method directly without reflection overhead on subsequent calls.
using System.Reflection;
public class Calculator
{
public int Add(int a, int b) => a + b;
}
// Slow: reflection every time
var calc = new Calculator();
var method = typeof(Calculator).GetMethod("Add");
var result1 = method.Invoke(calc, new object[] { 5, 3 });
Console.WriteLine($"Reflection result: {result1}");
// Fast: compile delegate once
var addDelegate = (Func)
method.CreateDelegate(typeof(Func));
var result2 = addDelegate(calc, 5, 3);
Console.WriteLine($"Delegate result: {result2}");
// Property access
var prop = typeof(Calculator).GetProperty("SomeProperty");
if (prop != null)
{
var value = prop.GetValue(calc);
}
CreateDelegate gives you compile-time performance with runtime discovery. Use it in hot paths where you're invoking the same method thousands of times. The initial delegate compilation costs a few microseconds but subsequent calls are as fast as normal method calls.
Isolating Assemblies with AssemblyLoadContext
AssemblyLoadContext creates an isolated loading scope. Assemblies loaded into one context don't interfere with assemblies in another. This lets you load multiple versions of the same assembly or unload entire plugin sets when done.
Create a custom context by subclassing AssemblyLoadContext. Override Load to control dependency resolution. Call Unload when you're done to free memory. This is critical for long-running apps with plugin systems where plugins can be updated without restarting the app.
using System.Reflection;
using System.Runtime.Loader;
public class PluginLoadContext : AssemblyLoadContext
{
private readonly AssemblyDependencyResolver _resolver;
public PluginLoadContext(string pluginPath) : base(isCollectible: true)
{
_resolver = new AssemblyDependencyResolver(pluginPath);
}
protected override Assembly? Load(AssemblyName assemblyName)
{
var assemblyPath = _resolver.ResolveAssemblyToPath(assemblyName);
if (assemblyPath != null)
{
return LoadFromAssemblyPath(assemblyPath);
}
return null;
}
}
// Usage
var pluginPath = "path/to/plugin.dll";
var context = new PluginLoadContext(pluginPath);
var assembly = context.LoadFromAssemblyPath(pluginPath);
// Use the plugin...
// Unload when done
context.Unload();
The isCollectible flag lets the GC reclaim memory when you unload. Without it, assemblies stay in memory even after Unload. AssemblyDependencyResolver handles finding dependencies relative to the plugin DLL automatically.
Security & Safety Corner
Loading untrusted assemblies risks code execution exploits. Reflection can access private members, bypassing encapsulation. An attacker who controls which assembly you load can run arbitrary code in your process. Always validate assembly sources before loading.
Use MetadataLoadContext for safe inspection. It loads assemblies for metadata reading only without executing any code. This lets you analyze types and methods from untrusted DLLs without the security risks of full loading.
For trimmed or AOT apps, reflection requires DynamicallyAccessedMembers attributes on types you'll reflect over. The trimmer preserves annotated types and members. Without annotations, reflection throws at runtime when it can't find trimmed types. See https://learn.microsoft.com/en-us/dotnet/core/deploying/trimming/ for details.
Try It Yourself
Build a simple plugin system that discovers and executes plugins from a directory. You'll see reflection-based type discovery in action.
Steps:
- dotnet new console -n ReflectionLab
- cd ReflectionLab
- Create Program.cs with the host code
- Create IPlugin.cs with the interface
- Create SamplePlugin.cs with example implementation
- dotnet run
using System.Reflection;
var currentAssembly = Assembly.GetExecutingAssembly();
var pluginTypes = currentAssembly.GetTypes()
.Where(t => typeof(IPlugin).IsAssignableFrom(t))
.Where(t => !t.IsInterface && !t.IsAbstract);
Console.WriteLine("=== Discovered Plugins ===");
foreach (var type in pluginTypes)
{
if (Activator.CreateInstance(type) is IPlugin plugin)
{
Console.WriteLine($"Plugin: {plugin.Name}");
plugin.Execute();
Console.WriteLine();
}
}
public interface IPlugin
{
string Name { get; }
void Execute();
}
public class GreetingPlugin : IPlugin
{
public string Name => "Greeting Plugin";
public void Execute() => Console.WriteLine("Hello from plugin!");
}
public class MathPlugin : IPlugin
{
public string Name => "Math Plugin";
public void Execute() => Console.WriteLine($"2 + 2 = {2 + 2}");
}
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
=== Discovered Plugins ===
Plugin: Greeting Plugin
Hello from plugin!
Plugin: Math Plugin
2 + 2 = 4