Plugins
Plugins are hot-reloadable components that your application owns and manages via PluginManager. They are not part of the framework's built-in startup: you create a PluginManager in your IApplication, register it with CodeLogic.SetPluginManager(), and load plugins yourself during OnStartAsync.
Unlike libraries (which are fully started before your application runs), plugins are loaded and managed entirely within the application layer. The framework participates only in health reporting and shutdown: StopAsync() unloads plugins before libraries stop, and ResetAsync() disposes the registered manager and forgets it.
Libraries (framework-managed, start first)
↓ fully started
IApplication.OnInitializeAsync / OnStartAsync
→ creates PluginManager
→ CodeLogic.SetPluginManager(manager)
→ manager.LoadAllAsync()
↓
Plugins running (app-managed)
IPlugin Interface
public interface IPlugin : IDisposable
{
PluginManifest Manifest { get; }
PluginState State { get; }
Task OnConfigureAsync(PluginContext context);
Task OnInitializeAsync(PluginContext context);
Task OnStartAsync(PluginContext context);
Task OnUnloadAsync();
Task<HealthStatus> HealthCheckAsync();
}
The 4-phase lifecycle mirrors ILibrary. The stop phase is called OnUnloadAsync instead of OnStopAsync. Dispose is the guaranteed cleanup hook: it runs after every unload and after every failed load, so make it safe to call on a partly initialized instance.
PluginManifest
public sealed class PluginManifest
{
public required string Id { get; init; } // "myapp.dashboard" — unique per manager
public required string Name { get; init; } // "Dashboard Plugin"
public required string Version { get; init; } // "1.0.0"
public string? Description { get; init; }
public string? Author { get; init; }
public string? MinFrameworkVersion { get; init; } // enforced: newer than the running CodeLogic = not loaded
}
PluginContext
Full parity with LibraryContext. Scoped to the plugin's directory:
public sealed class PluginContext
{
public string PluginId { get; }
public string PluginDirectory { get; } // the folder that holds the plugin assembly
public string ConfigDirectory { get; } // same as PluginDirectory
public string LocalizationDirectory { get; } // {PluginDirectory}/localization
public string LogsDirectory { get; } // {PluginDirectory}/logs
public string DataDirectory { get; } // {PluginDirectory}/data
public ILogger Logger { get; }
public IConfigurationManager Configuration { get; }
public ILocalizationManager Localization { get; }
public IEventBus Events { get; } // the shared framework bus
}
PluginState
public enum PluginState
{
Discovered, // found on disk, not loaded
Loaded, // assembly loaded, instance created
Configured, // OnConfigureAsync done, config and localization loaded
Initialized, // OnInitializeAsync done
Started, // OnStartAsync done — operational
Stopped, // unloaded cleanly
Failed // a lifecycle phase threw
}
PluginManager
Your application creates and owns the PluginManager. Register it with CodeLogic.SetPluginManager() so the framework includes plugins in health checks and shuts them down before libraries stop:
public sealed class MyApp : IApplication
{
// Phase 2: create and register the PluginManager
public Task OnInitializeAsync(ApplicationContext context)
{
var manager = new PluginManager(
context.Events,
new PluginOptions
{
PluginsDirectory = CodeLogic.GetOptions().GetPluginsPath(),
WatchForChanges = true, // hot reload when a plugin DLL changes
});
// Register with the framework — participates in health + shutdown
CodeLogic.SetPluginManager(manager);
return Task.CompletedTask;
}
// Phase 3: load plugins once the app is starting
public async Task OnStartAsync(ApplicationContext context)
{
var manager = CodeLogic.GetPluginManager()!;
await manager.LoadAllAsync();
}
// ...OnStopAsync, etc.
}
Direct plugin operations. Loading takes the path of the plugin assembly; the other operations take the manifest ID:
var manager = CodeLogic.GetPluginManager()!;
var path = Path.Combine(CodeLogic.GetOptions().GetPluginsPath(),
"MyApp.Dashboard.Plugin", "MyApp.Dashboard.Plugin.dll");
await manager.LoadPluginAsync(path); // load and start one plugin
await manager.UnloadPluginAsync("myapp.dashboard"); // unload one plugin
await manager.ReloadPluginAsync("myapp.dashboard"); // unload + load from the same path
await manager.UnloadAllAsync(); // unload everything
Concurrency and failures
- Load, unload, reload, register, dispose and hot reload are serialized: one lifecycle operation runs at a time.
GetPlugin,GetAllPlugins,GetLoadedPluginsandGetHealthAsyncnever block and work on a snapshot. - Plugin IDs are unique. Loading a plugin whose ID is already loaded throws
InvalidOperationExceptionbefore the new instance is configured or started; the running plugin is untouched. UseReloadPluginAsyncto replace it. - A failed load cleans up: a plugin whose
OnStartAsyncwas entered getsOnUnloadAsync, the instance is disposed, and its load context is unloaded. The exception is rethrown and also reported throughPluginFailedEventandOnPluginError. - A failing
OnUnloadAsyncdoesn't stop the cleanup: the plugin is still disposed, removed and its context unloaded. The error is reported throughPluginFailedEventandOnPluginError, not thrown. - Exceptions from
OnPluginLoaded/OnPluginUnloadedhandlers are not plugin failures; they are written toConsole.Errorand ignored. - No re-entry. Calling
LoadPluginAsync,UnloadPluginAsync,ReloadPluginAsync,UnloadAllAsync,RegisterInProcessPluginAsyncorDisposeAsyncon the same manager from inside an operation in progress — from a plugin'sOnConfigureAsync,OnInitializeAsync,OnStartAsync,OnUnloadAsyncorDispose, or from anOnPluginErrorhandler — throwsInvalidOperationExceptionimmediately instead of deadlocking. Work those callbacks start (for example withTask.Run) is treated the same while that operation is still running.OnPluginLoadedandOnPluginUnloadedhandlers run after the operation has finished and may call lifecycle methods.
Hot Reload with FileSystemWatcher
With WatchForChanges = true (and EnableHotReload, which is on by default), PluginManager watches the plugins directory and reloads a loaded plugin when its DLL changes:
var options = new PluginOptions
{
PluginsDirectory = CodeLogic.GetOptions().GetPluginsPath(),
EnableHotReload = true,
WatchForChanges = true,
ReloadDebounce = TimeSpan.FromMilliseconds(500),
};
When a plugin's assembly is replaced on disk, PluginManager:
- Calls
OnUnloadAsync()andDispose()on the running plugin - Unloads its
AssemblyLoadContext - Loads the new assembly into a fresh context
- Calls
OnConfigureAsync,OnInitializeAsync,OnStartAsyncon the new instance
Writing a Plugin
public sealed class DashboardPlugin : IPlugin
{
public PluginManifest Manifest { get; } = new()
{
Id = "myapp.dashboard",
Name = "Dashboard Plugin",
Version = "1.0.0"
};
public PluginState State { get; private set; } = PluginState.Loaded;
private DashboardConfig _config = null!;
private IEventSubscription? _sub;
public Task OnConfigureAsync(PluginContext context)
{
context.Configuration.Register<DashboardConfig>();
State = PluginState.Configured;
return Task.CompletedTask;
}
public Task OnInitializeAsync(PluginContext context)
{
_config = context.Configuration.Get<DashboardConfig>();
State = PluginState.Initialized;
return Task.CompletedTask;
}
public Task OnStartAsync(PluginContext context)
{
_sub = context.Events.Subscribe<DeviceStateChangedEvent>(OnDeviceState);
State = PluginState.Started;
return Task.CompletedTask;
}
public Task OnUnloadAsync()
{
_sub?.Dispose();
_sub = null;
State = PluginState.Stopped;
return Task.CompletedTask;
}
public Task<HealthStatus> HealthCheckAsync()
=> Task.FromResult(HealthStatus.Healthy("Dashboard running"));
public void Dispose() => _sub?.Dispose();
private void OnDeviceState(DeviceStateChangedEvent e)
{
// update dashboard state
}
}
Plugin Directory Layout
LoadAllAsync discovers plugins in subdirectories named *.Plugin, each containing an assembly with the same name:
CodeLogic/
Plugins/
MyApp.Dashboard.Plugin/
MyApp.Dashboard.Plugin.dll ← plugin assembly
MyApp.Dashboard.Plugin.deps.json ← its private dependencies
config.json ← plugin config (generated)
localization/
logs/
data/
The plugin assembly must contain a class implementing IPlugin. PluginManager finds it by scanning the assembly's types. LoadPluginAsync accepts any path; the naming convention only matters for discovery.
Isolation Model
Each plugin loads into its own collectible PluginLoadContext. The isolation is about assembly resolution, nothing more:
- Host contracts are shared.
CodeLogicalways resolves to the host's copy, soIPlugin,PluginContext,IEventBusand the framework events have one identity on both sides. Add other assemblies whose types cross the boundary (for example a library's interfaces) toPluginOptions.SharedAssemblies; they also resolve to the host's copy, whatever version the plugin ships. - Plugin dependencies are private. Anything listed in the plugin's
.deps.jsonor present in its folder loads into the plugin's own context, so two plugins can use different versions of the same package. A plugin never receives an assembly from another plugin's context. - Everything else comes from the host. The .NET runtime and assemblies the plugin doesn't ship fall back to the default context.
- Plugin types are not host types. The host can only see a plugin through shared types (
IPlugin, or a shared contract interface).GetPlugin<T>with a type defined in the plugin assembly only works for in-process plugins registered withRegisterInProcessPluginAsync. - It is not a sandbox. Plugins run in the host process with its permissions, share the event bus and the statics of shared assemblies, and can still keep their context alive (for example with an event subscription that
OnUnloadAsyncdoesn't dispose), which prevents the assembly from being collected.
Hot reload works by discarding the old context and creating a fresh one.