If you are building a plugin system in C#, getting the loading and unloading mechanics right matters far more than most developers expect. It is not enough to just call Assembly.LoadFrom and hope for the best. Plugin lifecycle management in C# is the discipline of controlling when plugins are discovered, initialized, activated, and torn down -- and doing each of those phases in a way that keeps your host application stable.
In this article, I am going to walk you through all four lifecycle phases with real, working .NET 8/9 code. By the end you will have patterns you can drop into your own codebase, whether you are building a dev tool, a content pipeline, a notification system, or anything else that benefits from runtime extensibility. If you are new to plugin architecture and want foundational context first, check out Plugin Architecture in C# for Improved Software Design.
Plugin lifecycle management in C# is the kind of topic you do not fully appreciate until something goes wrong. Memory leaks from undisposed plugin resources look like general application memory growth. Startup failures from bad plugin initialization are hard to attribute when a dozen plugins are loading simultaneously. Getting the lifecycle right from the start is much cheaper than retrofitting it later.
What Is Plugin Lifecycle Management?
Plugin lifecycle management is the process of controlling a plugin through four distinct phases:
- Discovery -- finding plugin assemblies on disk or in configuration
- Loading -- bringing the assembly into memory via an
AssemblyLoadContext - Activation -- starting the plugin so it begins doing work
- Deactivation / Unloading -- stopping the plugin cleanly and freeing memory
Think of it like a power outlet. The outlet is always there (discovery), the device is plugged in (loading), you flip the switch (activation), and when you are done you unplug it cleanly (unloading). Skip any step and you either waste resources, leak memory, or crash when you least expect it.
Why does lifecycle management matter specifically?
- Resource cleanup -- plugins open file handles, database connections, timers, and background threads. Without a proper shutdown path those leak.
- Hot reload -- replacing a running plugin without restarting the host requires orderly unloading of the old
AssemblyLoadContextbefore loading a new one. - Error isolation -- a bug in plugin initialization should never take down the host. Lifecycle management gives you the seam to catch and contain those failures.
For a broader look at plugin patterns, the Plugin Architecture Design Pattern -- A Beginner's Guide to Modularity article covers the conceptual grounding well.
Understanding plugin lifecycle management deeply pays dividends when debugging subtle production issues. Memory pressure from undisposed resources, startup failures from flawed initialization order, and crashes from assembly version conflicts all trace back to gaps in lifecycle control. Proper plugin lifecycle management also makes your codebase more maintainable: when each phase has a clear owner and a well-defined interface, adding or removing plugins becomes a predictable, low-risk operation.
Plugin Discovery: Finding Plugins at Startup
Before you can load anything, you need to know what plugins exist. There are three common approaches.
Folder scanning -- scan a plugins/ directory for DLL files matching a naming convention. Simple, zero-config, works for most scenarios.
Configuration-based -- list plugin assembly paths explicitly in appsettings.json. Gives operators fine-grained control over what runs.
Convention-based naming -- only load files matching a pattern like *.Plugin.dll. Reduces accidental loading of dependency DLLs that land in the same folder.
The class below combines all three with a config-first fallback to folder scan:
public sealed class PluginDiscovery
{
private readonly string _pluginDirectory;
private readonly IReadOnlyList<string>? _explicitPaths;
private readonly string _searchPattern;
public PluginDiscovery(
string pluginDirectory = "plugins",
IReadOnlyList<string>? explicitPaths = null,
string searchPattern = "*.Plugin.dll")
{
_pluginDirectory = pluginDirectory;
_explicitPaths = explicitPaths;
_searchPattern = searchPattern;
}
public IEnumerable<string> Discover()
{
// Config-based wins when explicit paths are provided
if (_explicitPaths is { Count: > 0 })
return _explicitPaths.Where(File.Exists);
if (!Directory.Exists(_pluginDirectory))
return Enumerable.Empty<string>();
return Directory.GetFiles(_pluginDirectory, _searchPattern, SearchOption.AllDirectories);
}
}
Call this at startup and hand the resulting paths to your loader. Keeping discovery separate from loading makes each step independently testable.
If you are working in a container environment, consider extending discovery to support remote plugin manifests -- fetching assembly locations from a registry service rather than scanning the local file system. Plugin lifecycle management in distributed scenarios adds a network reliability concern to the discovery phase, which is another reason to keep discovery isolated from loading.
Plugin Initialization with IPluginLifecycle
Every plugin needs a way to tell the host "I am ready" and "I am done." A shared interface is the cleanest contract for this:
public interface IPluginLifecycle
{
string PluginId { get; }
string DisplayName { get; }
Task InitializeAsync(IServiceProvider hostServices, CancellationToken cancellationToken = default);
Task ShutdownAsync(CancellationToken cancellationToken = default);
}
InitializeAsync receives the host's IServiceProvider so the plugin can resolve shared services (logging, configuration, etc.) without the host needing to know what each plugin needs. Async initialization matters because real plugins do real work here -- opening database connections, starting file watchers, registering background timers.
Here is a sample email-channel plugin that illustrates async initialization:
public sealed class EmailNotificationPlugin : IPluginLifecycle
{
private ILogger<EmailNotificationPlugin>? _logger;
private SmtpClient? _smtpClient;
public string PluginId => "notifications.email";
public string DisplayName => "Email Notification Channel";
public async Task InitializeAsync(
IServiceProvider hostServices,
CancellationToken cancellationToken = default)
{
_logger = hostServices.GetRequiredService<ILogger<EmailNotificationPlugin>>();
var config = hostServices.GetRequiredService<IConfiguration>();
_smtpClient = new SmtpClient(config["Smtp:Host"])
{
Port = int.Parse(config["Smtp:Port"] ?? "587"),
EnableSsl = true
};
// Verify connectivity before reporting ready
await _smtpClient.SendMailAsync(
new MailMessage("[email protected]", "[email protected]", "ping", "ping"),
cancellationToken);
_logger.LogInformation("Email plugin initialized");
}
public Task ShutdownAsync(CancellationToken cancellationToken = default)
{
_smtpClient?.Dispose();
_logger?.LogInformation("Email plugin shut down");
return Task.CompletedTask;
}
public async Task SendAsync(string to, string subject, string body)
{
if (_smtpClient is null) throw new InvalidOperationException("Plugin not initialized");
await _smtpClient.SendMailAsync(new MailMessage("[email protected]", to, subject, body));
}
}
Notice that the plugin owns its own resource lifecycle. The host does not need to know about SMTP at all.
Activation and the Plugin Host
Loading a plugin into memory is not the same as activating it. You might want to load several plugins at startup but only activate some of them based on configuration or user action.
The PluginHost<TContract> below separates loaded plugins from active ones and lets you enable or disable individual plugins at runtime without unloading the assembly:
public sealed class PluginHost<TContract> where TContract : IPluginLifecycle
{
private readonly ILogger<PluginHost<TContract>> _logger;
private readonly Dictionary<string, TContract> _loaded = new();
private readonly HashSet<string> _active = new();
public PluginHost(ILogger<PluginHost<TContract>> logger)
{
_logger = logger;
}
public void Register(TContract plugin)
{
_loaded[plugin.PluginId] = plugin;
_logger.LogDebug("Registered plugin {PluginId}", plugin.PluginId);
}
public async Task ActivateAsync(
string pluginId,
IServiceProvider services,
CancellationToken ct = default)
{
if (!_loaded.TryGetValue(pluginId, out var plugin))
throw new KeyNotFoundException($"Plugin '{pluginId}' is not registered.");
if (_active.Contains(pluginId))
{
_logger.LogWarning("Plugin {PluginId} is already active", pluginId);
return;
}
await plugin.InitializeAsync(services, ct);
_active.Add(pluginId);
_logger.LogInformation("Activated plugin {PluginId}", pluginId);
}
public async Task DeactivateAsync(string pluginId, CancellationToken ct = default)
{
if (!_active.Remove(pluginId)) return;
if (_loaded.TryGetValue(pluginId, out var plugin))
{
await plugin.ShutdownAsync(ct);
_logger.LogInformation("Deactivated plugin {PluginId}", pluginId);
}
}
public IEnumerable<TContract> ActivePlugins =>
_active.Select(id => _loaded[id]);
}
This design lets you treat activation as a runtime toggle. You can surface this through an admin API or a configuration flag without any assembly loading/unloading involved.
Error Isolation: Preventing Plugin Failures from Crashing the Host
Plugin code runs inside your process. Any unhandled exception in a plugin will propagate up to the host unless you explicitly stop it. The simplest and most effective pattern is a safe invocation wrapper:
public static class PluginInvoker
{
public static async Task InvokeSafelyAsync(
IPluginLifecycle plugin,
Func<Task> action,
ILogger logger)
{
try
{
await action();
}
catch (OperationCanceledException)
{
// Propagate cancellation -- this is expected behavior
throw;
}
catch (Exception ex)
{
logger.LogError(
ex,
"Plugin {PluginId} threw an unhandled exception and has been isolated",
plugin.PluginId);
// Do NOT rethrow -- isolate this plugin's failure from the host
}
}
}
Use this anywhere you invoke plugin methods from the host. For higher-stakes scenarios you can build a circuit breaker on top: track consecutive failures per plugin and automatically deactivate a plugin that fails too many times in a row rather than letting it drag system performance down.
When iterating over all active plugins to deliver an event or notification, always isolate each call individually:
foreach (var plugin in host.ActivePlugins)
{
await PluginInvoker.InvokeSafelyAsync(
plugin,
() => plugin.SendAsync(notification),
logger);
}
This way a broken SMS plugin does not prevent the email and Slack plugins from delivering.
Thinking of plugin lifecycle management as a state machine helps design error isolation correctly. Each plugin transitions through defined states: discovered → loaded → initialized → active → deactivating → unloaded. Modeling these transitions explicitly -- even as a simple enum on a PluginEntry record -- makes it straightforward to validate that a plugin cannot be activated before initialization or unloaded while still active. It also simplifies testing because you can assert state after each operation.
Hot Reload: Updating Plugins Without Restarting
Hot reload in the plugin context means replacing a plugin's code at runtime -- without stopping the host application. .NET makes this possible through AssemblyLoadContext: load a new context for the new DLL, call Unload() on the old context to initiate teardown, and re-register the new plugin instance. Importantly, Unload() does not immediately free memory -- it requests collection, and the GC must be able to reclaim the context. Any remaining strong reference to a plugin type, a cached delegate, or a static field inside the plugin will prevent unloading.
The PluginHotReloadManager below watches a plugin directory and triggers reload when a DLL changes:
public sealed class PluginHotReloadManager : IAsyncDisposable
{
private readonly string _pluginDirectory;
private readonly IServiceProvider _services;
private readonly ILogger<PluginHotReloadManager> _logger;
private readonly FileSystemWatcher _watcher;
private readonly Dictionary<string, (AssemblyLoadContext Context, IPluginLifecycle Plugin)> _contexts = new();
public PluginHotReloadManager(
string pluginDirectory,
IServiceProvider services,
ILogger<PluginHotReloadManager> logger)
{
_pluginDirectory = pluginDirectory;
_services = services;
_logger = logger;
_watcher = new FileSystemWatcher(pluginDirectory, "*.Plugin.dll")
{
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.FileName,
EnableRaisingEvents = true
};
_watcher.Changed += OnPluginFileChanged;
_watcher.Created += OnPluginFileChanged;
}
private void OnPluginFileChanged(object sender, FileSystemEventArgs e)
{
// Offload to thread pool -- FileSystemWatcher callbacks must not block
_ = Task.Run(() => ReloadPluginAsync(e.FullPath));
}
private async Task ReloadPluginAsync(string dllPath)
{
// Small delay to let the file write complete
await Task.Delay(500);
_logger.LogInformation("Hot-reloading plugin from {Path}", dllPath);
var pluginId = Path.GetFileNameWithoutExtension(dllPath);
// Shutdown and unload the old context if it exists
if (_contexts.TryGetValue(pluginId, out var existing))
{
await existing.Plugin.ShutdownAsync();
existing.Context.Unload();
_contexts.Remove(pluginId);
_logger.LogInformation("Unloaded old context for {PluginId}", pluginId);
}
// Load the new assembly in a fresh, collectible context
var context = new AssemblyLoadContext(pluginId, isCollectible: true);
var assembly = context.LoadFromAssemblyPath(dllPath);
var pluginType = assembly.GetTypes()
.FirstOrDefault(t => typeof(IPluginLifecycle).IsAssignableFrom(t) && !t.IsAbstract);
if (pluginType is null)
{
_logger.LogWarning("No IPluginLifecycle implementation found in {Path}", dllPath);
context.Unload();
return;
}
var plugin = (IPluginLifecycle)Activator.CreateInstance(pluginType)!;
await plugin.InitializeAsync(_services);
_contexts[pluginId] = (context, plugin);
_logger.LogInformation("Hot-reloaded plugin {PluginId}", pluginId);
}
public async ValueTask DisposeAsync()
{
_watcher.EnableRaisingEvents = false;
_watcher.Dispose();
foreach (var (id, entry) in _contexts)
{
await entry.Plugin.ShutdownAsync();
entry.Context.Unload();
_logger.LogInformation("Unloaded plugin context {PluginId} on dispose", id);
}
_contexts.Clear();
}
}
A few important limitations to be aware of with hot reload:
- Unload is not guaranteed --
context.Unload()initiates collection but the GC must actually be able to reclaim the context. Static event handlers, background threads, timers, cachedTypereferences, and static singletons inside a plugin will all prevent unloading. Use aWeakReferenceto verify unload during development. - In-flight requests -- any plugin call that started on the old instance needs to complete before you unload. In practice this means either a quiesce window or using a ref-counted wrapper that defers unloading.
- File locks -- the replacement DLL file may still be locked by the previous write; the 500ms delay above is a rough heuristic. Production implementations should include retry logic with backoff.
- State migration -- if the old plugin has in-memory state (a queue, a cache), the new version starts empty. Design plugins to be stateless or to reload state from an external source.
- Shared types -- the host contract (
IPluginLifecycle) must live in a separate, shared assembly that both the host and all plugin load contexts can load without duplication.
For more on dynamic loading patterns, the Blazor Plugin Architecture -- How To Manage Dynamic Loading & Lifecycle article goes deep on the Blazor-specific angle.
Graceful Shutdown and Resource Cleanup
The host application shutting down is just another lifecycle event, and you need to handle it explicitly. The cleanest approach in ASP.NET Core is to implement IHostedService so you get a proper StopAsync callback:
public sealed class PluginLifecycleService : IHostedService
{
private readonly PluginHost<IPluginLifecycle> _host;
private readonly IServiceProvider _services;
private readonly ILogger<PluginLifecycleService> _logger;
public PluginLifecycleService(
PluginHost<IPluginLifecycle> host,
IServiceProvider services,
ILogger<PluginLifecycleService> logger)
{
_host = host;
_services = services;
_logger = logger;
}
public async Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Starting plugin lifecycle service");
var discovery = new PluginDiscovery(pluginDirectory: "plugins");
foreach (var dllPath in discovery.Discover())
{
var context = new AssemblyLoadContext(
Path.GetFileNameWithoutExtension(dllPath), isCollectible: true);
var assembly = context.LoadFromAssemblyPath(dllPath);
foreach (var type in assembly.GetTypes()
.Where(t => typeof(IPluginLifecycle).IsAssignableFrom(t) && !t.IsAbstract))
{
var plugin = (IPluginLifecycle)Activator.CreateInstance(type)!;
_host.Register(plugin);
await _host.ActivateAsync(plugin.PluginId, _services, cancellationToken);
}
}
}
public async Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Stopping all plugins");
foreach (var plugin in _host.ActivePlugins.ToList())
{
await _host.DeactivateAsync(plugin.PluginId, cancellationToken);
}
}
}
Register it in Program.cs:
builder.Services.AddSingleton<PluginHost<IPluginLifecycle>>();
builder.Services.AddHostedService<PluginLifecycleService>();
.NET's generic host guarantees that StopAsync is called on all hosted services in reverse registration order before the process exits, which is exactly the ordering you want for an orderly plugin teardown.
Complete Example: A Lifecycle-Managed Plugin Host
Let me put it all together with a concrete scenario: a notification system that supports pluggable delivery channels -- Email, Slack, and SMS. Each channel is a plugin. The host discovers them, activates them, uses them to deliver notifications, and shuts them down cleanly.
// The shared contract -- lives in a separate Contracts project
public interface INotificationPlugin : IPluginLifecycle
{
Task SendNotificationAsync(string recipient, string message, CancellationToken ct = default);
}
// The notification service -- knows nothing about specific channels
public sealed class NotificationService
{
private readonly PluginHost<INotificationPlugin> _host;
private readonly ILogger<NotificationService> _logger;
public NotificationService(
PluginHost<INotificationPlugin> host,
ILogger<NotificationService> logger)
{
_host = host;
_logger = logger;
}
public async Task BroadcastAsync(string recipient, string message, CancellationToken ct = default)
{
var tasks = _host.ActivePlugins.Select(plugin =>
PluginInvoker.InvokeSafelyAsync(
plugin,
() => plugin.SendNotificationAsync(recipient, message, ct),
_logger));
await Task.WhenAll(tasks);
}
}
// A concrete Slack plugin
public sealed class SlackNotificationPlugin : INotificationPlugin
{
private ILogger<SlackNotificationPlugin>? _logger;
private HttpClient? _httpClient;
private string? _webhookUrl;
public string PluginId => "notifications.slack";
public string DisplayName => "Slack Notification Channel";
public async Task InitializeAsync(IServiceProvider services, CancellationToken ct = default)
{
_logger = services.GetRequiredService<ILogger<SlackNotificationPlugin>>();
var config = services.GetRequiredService<IConfiguration>();
_webhookUrl = config["Slack:WebhookUrl"]
?? throw new InvalidOperationException("Slack:WebhookUrl is required");
_httpClient = services.GetRequiredService<IHttpClientFactory>().CreateClient("slack");
_logger.LogInformation("Slack plugin initialized");
await Task.CompletedTask;
}
public Task ShutdownAsync(CancellationToken ct = default)
{
_httpClient?.Dispose();
return Task.CompletedTask;
}
public async Task SendNotificationAsync(string recipient, string message, CancellationToken ct = default)
{
var payload = JsonSerializer.Serialize(new { text = $"@{recipient}: {message}" });
using var content = new StringContent(payload, Encoding.UTF8, "application/json");
var response = await _httpClient!.PostAsync(_webhookUrl, content, ct);
response.EnsureSuccessStatusCode();
}
}
Wire everything up in Program.cs:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient("slack");
builder.Services.AddSingleton<PluginHost<INotificationPlugin>>();
builder.Services.AddSingleton<NotificationService>();
builder.Services.AddHostedService<PluginLifecycleService<INotificationPlugin>>();
var app = builder.Build();
app.MapPost("/notify", async (NotificationService svc, NotificationRequest req) =>
{
await svc.BroadcastAsync(req.Recipient, req.Message);
return Results.Ok();
});
app.Run();
This architecture means adding a new delivery channel is as simple as dropping a new .Plugin.dll into the plugins folder and restarting -- or with the hot reload manager from earlier, without restarting at all. For a real-world reference implementation using a DI-native approach, see Plugin Architecture with Needlr in .NET: Building Modular Applications.
Frequently Asked Questions
Should I use IHostedService to manage plugin lifecycle in ASP.NET Core?
Yes, this is the recommended approach. IHostedService gives you StartAsync and StopAsync hooks that integrate cleanly with the generic host's startup and shutdown sequences. The host ensures StopAsync is called before the process exits, which means your plugins will always get a chance to clean up. Avoid registering plugin initialization directly in Program.cs startup code because you lose the graceful shutdown guarantee.
How do I pass data between plugins in a plugin architecture?
The cleanest approach is to use the host's IServiceProvider as the message bus. Register a shared service (e.g., an IEventBus or a IPluginMessageBroker) in the host's DI container and inject it into each plugin's InitializeAsync. Plugins never talk to each other directly -- they publish and subscribe through the shared service. This keeps plugins decoupled and avoids the tight coupling that comes from plugin A holding a reference to plugin B's assembly type.
What is the safest way to update a plugin in production without downtime?
The safest pattern is a versioned swap: load the new plugin version into a fresh AssemblyLoadContext and run both the old and new versions in parallel for a brief quiesce window. Route new requests to the new version while the old version drains any in-flight work, then unload the old context. This is more complex than a simple reload but eliminates the risk of dropping requests mid-flight. For most applications a simple "stop, swap, start" with a short maintenance window is sufficient and much simpler to reason about.
Can plugins access the host application's dependency injection container?
Yes, intentionally -- that is exactly what the IServiceProvider parameter in InitializeAsync is for. Passing the host's IServiceProvider into the plugin gives it access to any service the host has registered (logging, configuration, HTTP clients, database contexts, etc.) without the plugin needing to know how those services are constructed. The key discipline is to keep this one-directional: the host provides services to plugins, but plugins should not register services back into the host container.
How do I handle a plugin that takes too long to initialize?
Pass a CancellationToken with a timeout into InitializeAsync and cancel plugins that exceed the deadline. You can use CancellationTokenSource.CancelAfter(TimeSpan.FromSeconds(30)) per plugin. If a plugin's InitializeAsync throws OperationCanceledException, log the failure, mark the plugin as non-activated, and continue loading the rest. Never let one slow plugin block the entire host startup. I also recommend configuring a per-plugin timeout in appsettings.json so operators can tune it without a code change.
Wrapping Up
Plugin lifecycle management in C# is one of those topics that looks simple -- "just load a DLL" -- until you hit the first memory leak, the first failed hot reload, or the first cascade crash from an unhandled plugin exception. The patterns I covered here give you the building blocks to handle all of those cases cleanly.
To recap the lifecycle:
- Discovery --
PluginDiscoverywith folder scan and config fallback - Initialization --
IPluginLifecyclewith async init and shutdown - Activation --
PluginHost<TContract>separating loaded from active - Error isolation --
PluginInvoker.InvokeSafelyAsyncat every call site - Hot reload --
PluginHotReloadManagerwithAssemblyLoadContextandFileSystemWatcher - Shutdown --
IHostedServicewiring forStopAsyncguarantees
If you are building an ASP.NET Core application and want a more opinionated look at how plugin architecture fits into that host model, Plugin Architecture in ASP.NET Core -- How To Master It is a good next read.
These patterns compose well. Start with IPluginLifecycle and PluginHost, add error isolation from day one, and layer in hot reload only when you actually need it. Building incrementally is much easier than retrofitting lifecycle management into an existing plugin system that grew organically.
Good plugin lifecycle management requires discipline across your entire team. Document the contract interface clearly, include the lifecycle phases in your architecture decision records, and write tests that exercise each phase in isolation. Teams that treat plugin lifecycle management as a first-class architectural concern from day one spend far less time debugging mysterious production failures later.

