.NET / SQL / Enterprise Engineering
.NET Plugin Host Architecture for a Windows Desktop Application
Report summary
Architecture synthesis. A production Windows desktop host should use a tiered plugin model rather than one universal plugin boundary. The default should be out-of-process plugins over Windows named pipes , supervised by the host, with a small stable contract assembly that defines manifest semantics,
Key topics
- .NET / SQL / Enterprise Engineering
- .NET
- SQL
- Enterprise Engineering
- C#
- Python
- Runtime
- NuGet
- Semantic Systems
Research provenance
For citation, use the report title and canonical URL. Archival presence does not establish authorship or promote report statements into portfolio evidence.
Source availability: 69 citation markers in the source export have no recoverable source links. Those markers are omitted from this reader; any supplied bibliography and ordinary links remain. Check the original sources before relying on the cited claims.
This page renders the archived Markdown as safe, formatted HTML. It is background research and does not become a portfolio claim without evidence review.
Full report
On this page
Executive recommendation
Architecture synthesis. A production Windows desktop host should use a tiered plugin model rather than one universal plugin boundary. The default should be out-of-process plugins over Windows named pipes, supervised by the host, with a small stable contract assembly that defines manifest semantics, lifecycle, capabilities, operation envelopes, and diagnostics. A narrower exception should permit trusted, managed-only, low-risk plugins to run in-process inside a collectible AssemblyLoadContext when they need very low-latency calls or direct access to host-managed object graphs. A separately installed Windows service or broker should be reserved for capabilities that must outlive the UI process, run with a different identity, perform privileged operations, or serve multiple client processes.
Platform fact. In current .NET, AssemblyLoadContext is the supported mechanism for managed dependency isolation and potential unloadability; AssemblyDependencyResolver is the standard resolver for plugin dependencies and native libraries; Microsoft.Extensions.DependencyInjection is the current built-in DI container model; System.Composition remains available as a composition library; named pipes are a first-class Windows IPC primitive; and Windows App SDK deployment guidance distinguishes packaged, packaged-with-external-location, and unpackaged desktop apps, with special constraints around single-file publishing and writable locations.
Platform fact. An AssemblyLoadContext is not a security sandbox. Microsoft’s secure-coding guidance states that Code Access Security and security transparency are not supported as a security boundary for partially trusted code, and explicitly advises against loading code of unknown origin without alternative measures such as virtualization, AppContainers, or OS users and permissions. Microsoft’s AssemblyLoadContext guidance also states that dynamic dependencies are not “binary isolation”; they are isolated only by name resolution.
Architecture synthesis. The decision-ready default is therefore:
Trust tier A
Built-in features and first-party static modules
-> direct references + host DI
-> no plugin packaging needed
Trust tier B
Trusted managed plugin, no native payload, low crash risk, must be low-latency
-> in-process
-> shared contract assembly in Default ALC
-> implementation and private deps in collectible ALC
-> host-mediated least-authority services only
Trust tier C
Third-party plugin, Python sidecar, native/GPU libs, heavyweight model/tooling, crash-prone or dependency-heavy
-> out-of-process worker
-> Windows named pipes as default local transport
-> host supervisor, restart policy, quotas, quarantine, signed receipts
Trust tier D
Needs separate identity, privileged operations, background longevity, shared machine-wide broker responsibilities
-> separately installed Windows service or broker
-> explicit admin/service lifecycle, separate deployment and observability
Proposal. Treat the process boundary as the default reliability boundary and a different Windows principal/AppContainer/VM as the default security boundary. That distinction should remain explicit in architecture and documentation.
Scope, assumptions, zero-access declaration, and source method
Zero-access declaration. This report does not access, inspect, run, or infer any private product code, binaries, manifests, APIs, logs, screenshots, or internal documents. All examples use neutral names such as Example.Plugin and are proposals only.
Assumption. The host is a modern Windows desktop application, likely C#/.NET and potentially WinUI 3, but the private implementation is unknown. The owner-supplied context is treated only as requirements context, not evidence.
Source method. This report prioritizes Microsoft Learn and related Microsoft documentation for .NET loading, dependency resolution, unloadability, diagnostics, hosting, Windows services, Windows App SDK deployment, MSIX behavior, IPC, security guidance, testing, and publishing. NuGet package pages are used only to confirm the current availability of System.Composition and DI packages. The Semantic Versioning specification and NuGet versioning documentation are used for version-contract guidance.
Current-platform applicability. The platform guidance used here is applicable to current .NET desktop applications that use the .NET runtime’s current loading model, Windows IPC primitives, and Windows App SDK deployment models. The most material current constraints are: AssemblyLoadContext replaces the old AppDomain isolation story in modern .NET; Native AOT does not support dynamic loading; trimming and reflection need deliberate annotation; packaged WinUI 3 apps do not support PublishSingleFile; and packaged apps should not assume their install location is writable for plugin installation.
Obsolete guidance flagged. .NET Framework AppDomain unloading, legacy CAS policy, and old .NET Framework assembly-loading advice remain historically informative but are not the recommended basis for a modern .NET desktop plugin host. Microsoft documents CAS as unsupported or obsolete in modern .NET.
Architecture option matrix and recommended host boundary
Architecture synthesis. The table below scores each required option on a 1–5 scale, where 5 is most favorable for production plugin hosting on that metric. For startup cost, call latency, deployment complexity, and plugin-developer burden, a higher score means lower cost/latency/complexity/burden. The scores are not product facts; they are synthesis grounded in the cited runtime, deployment, IPC, and security guidance.
| Option | Isolation | Security boundary | Unloadability | Dependency isolation | Native dependency support | Startup cost | Call latency | Streaming | Cancellation | Crash containment | Deployment complexity | Diagnostics | Testability | Plugin-dev burden |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Direct in-process references | 1 | 1 | 1 | 1 | 4 | 5 | 5 | 4 | 3 | 1 | 5 | 3 | 4 | 5 |
| Reflection + shared contract assembly | 1 | 1 | 1 | 2 | 4 | 4 | 5 | 4 | 3 | 1 | 4 | 4 | 4 | 4 |
MEF / System.Composition | 1 | 1 | 1 | 2 | 4 | 4 | 5 | 3 | 2 | 1 | 3 | 3 | 3 | 3 |
Collectible AssemblyLoadContext | 2 | 1 | 3 | 4 | 3 | 4 | 4 | 4 | 3 | 2 | 3 | 4 | 4 | 3 |
| Out-of-process .NET worker over named pipes | 4 | 3 | 5 | 5 | 5 | 3 | 4 | 5 | 5 | 5 | 3 | 5 | 5 | 3 |
| Out-of-process worker over local sockets / loopback | 4 | 3 | 5 | 5 | 5 | 3 | 3 | 5 | 5 | 5 | 3 | 5 | 5 | 3 |
| Out-of-process worker over standard input/output | 3 | 2 | 5 | 5 | 5 | 2 | 3 | 3 | 4 | 5 | 4 | 3 | 4 | 4 |
| Windows service / separately installed broker | 5 | 4 | 5 | 5 | 5 | 1 | 2 | 5 | 5 | 5 | 1 | 5 | 5 | 2 |
Architecture synthesis. Direct in-process references score highest for startup cost, latency, and developer ease because they rely on ordinary static composition and host DI. They score lowest for isolation, security, unloadability, and crash containment because everything shares one process, one address space, and one failure domain. They fit only built-in or tightly controlled first-party extensions, not an optional enterprise plugin ecosystem.
Architecture synthesis. Reflection plus a shared contract assembly improves optional discovery and keeps the host contract small, but it still shares the same process and failure domain. It gains a modest dependency-isolation score only if discovery is paired with careful loading discipline. By itself, reflection does not solve version conflicts, native collisions, or trust. It is useful as a discovery mechanism, not as the full isolation model. Trimming guidance also makes broad dynamic reflection a maintainability and deployment risk unless deliberately annotated.
Architecture synthesis. MEF / System.Composition remains available and supported as a composition library, but it is still a composition story rather than an isolation story. It does not create a security boundary, does not itself solve unloadability, and tends to impose attribute/convention coupling that becomes awkward when the plugin boundary must also cover sidecars, capability declarations, and operational receipts. It is appropriate only if the host explicitly wants attribute-based composition for trusted in-proc modules.
Architecture synthesis. Collectible AssemblyLoadContext is the best supported in-process isolation mechanism for modern .NET. It materially improves dependency isolation and can support unload—but only cooperatively. It does not create a security boundary, and unload can be blocked by references, threads, registered waits, and custom AssemblyLoadContext fields that retain plugin objects. Native support is only moderate because native dependency collisions, unmanaged handles, and plugin-specific native search behavior complicate both load and unload. Use it for trusted, managed, low-risk plugins only.
Architecture synthesis. Out-of-process workers over named pipes are the best default for a Windows desktop host. Named pipes are a Windows-native IPC primitive, support duplex communication, and integrate with Windows security. This option scores highly for isolation, unloadability, dependency isolation, native support, crash containment, diagnostics, testability, streaming, and cancellation. It loses some points on startup cost and plugin-developer burden because a child process and IPC protocol are real complexity, but that is usually the right enterprise trade.
Architecture synthesis. Local sockets or loopback are nearly as strong as named pipes for process isolation and streaming. They are often the right strategic choice only when cross-platform parity, existing socket-based stacks, or non-Windows sidecars outweigh the Windows-specific advantages of named pipes. On a Windows-only desktop application, they score slightly worse for security ergonomics and latency because named pipes align better with local IPC and ACL-based access control.
Architecture synthesis. Standard input/output works especially well for short-lived tools and some language-neutral sidecars because ProcessStartInfo makes launch and stream redirection straightforward. But it is weaker for sustained streaming, multiplexing, and diagnostics, and Microsoft explicitly documents deadlock hazards when redirected streams are read synchronously or in the wrong order. It is viable for CLI-style helpers, not the best default for a heavily supervised, long-running plugin fleet.
Architecture synthesis. A Windows service or separately installed broker is the strongest operational boundary here, but it is the most complex deployment and operational model. It is justified only when the capability must run outside the user session, under a different identity, with service recovery semantics, or as a shared machine-wide facility. It should not be the default extensibility shape for ordinary optional desktop plugins.
Recommended host boundary. The recommended reference architecture is:
+--------------------------------------------------------------+
| Example.Host.UI process |
| - Core local features |
| - Plugin catalog + manifest validator |
| - Receipt store + policy engine + quarantine list |
| - Contract assembly loaded in Default ALC |
| - Optional in-proc trusted plugin zone via collectible ALC |
| - IPC supervisor + health monitor + resource attribution |
+----------------------+----------------------+-----------------+
| |
named pipes named pipes
| |
+--------------+----+ +---------+----------------+
| Example.Plugin.Worker | | Example.Plugin.Python |
| (.NET sidecar) | | (Python or other stack) |
| plugin-specific deps | | language-neutral IPC |
| native libs allowed | | bounded contract only |
+-----------------------+ +-------------------------+
|
optional broker RPC
|
+--------------+----------------------------------------+
| Example.Plugin.Broker.Service |
| Windows service / separate principal / privileged ops |
+------------------------------------------------------+
Reliability boundary versus security boundary. A process boundary materially improves reliability because it contains crashes, allows hard kill and restart, isolates dependency graphs, and makes resource attribution tractable. A process boundary becomes a meaningful security boundary only when combined with OS-enforced separation such as separate users, AppContainer, virtualization, restricted ACLs, and narrow IPC permissions. Modern .NET does not provide an in-process partial-trust sandbox for plugin code.
Explicit exception rules. Force a plugin out of process if any of the following apply: it is third-party or untrusted; it includes Python or other non-.NET runtime payloads; it brings native DLLs, GPU runtimes, or heavy inference/tool dependencies; it performs network or remote-connectivity work that may hang; it has a history of crashes or long startup; or it must be resource-limited and restartable without jeopardizing the host. Reserve in-process collectible ALC for trusted managed-only plugins whose value depends on low-latency calls and whose unload path passes automated leak and unload tests. Reserve separate services for elevated, machine-wide, or always-on capabilities.
C# contract proposal, lifecycle state machine, and dependency isolation
Proposal. The host should expose one small, stable contract assembly loaded in the default AssemblyLoadContext, and every in-process plugin should reference only that contract assembly plus its own private dependencies. Shared abstractions must stay in the shared contract assembly so that type identity is stable across load contexts. Microsoft’s AssemblyLoadContext guidance explicitly recommends the shared-dependency pattern and warns that types with the same name in different contexts are different types.
Proposal. The base contract should prefer interfaces for lifecycle, immutable records for manifest and result data, cooperative cancellation via CancellationToken, async disposal via IAsyncDisposable, and message envelopes for out-of-process interoperability. For streaming, use IAsyncEnumerable<T> or a bounded channel abstraction only where streaming is truly needed; for cross-process interoperability, keep the wire contract to stable message envelopes rather than .NET events or arbitrary callbacks. Microsoft documents CancellationToken, IAsyncDisposable, and System.Threading.Channels as the standard primitives for these concerns.
Proposal and sample. The following code is illustrative, not production-ready, but is intended to be compilable in principle and sufficiently concrete for contract drafting.
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Threading;
using System.Threading.Tasks;
namespace Example.Plugin.Abstractions;
/// <summary>
/// Immutable plugin identity and semantic contract version.
/// </summary>
public sealed record PluginIdentity
{
[Display(Name = "plugin")]
public required string PluginId { get; init; }
[Display(Name = "display name")]
public required string DisplayName { get; init; }
[Display(Name = "publisher")]
public required string Publisher { get; init; }
[Display(Name = "plugin version")]
public required string PluginVersion { get; init; }
[Display(Name = "contract version")]
public required string ContractVersion { get; init; }
}
/// <summary>
/// Stable manifest projection consumed by the host after validation.
/// </summary>
public sealed record PluginManifest
{
[Display(Name = "identity")]
public required PluginIdentity Identity { get; init; }
[Display(Name = "entry point")]
public required string EntryPoint { get; init; }
[Display(Name = "runtime")]
public required PluginRuntimeKind Runtime { get; init; }
[Display(Name = "capabilities")]
public required IReadOnlyList<CapabilityDeclaration> Capabilities { get; init; }
[Display(Name = "permissions")]
public required IReadOnlyList<PermissionDeclaration> Permissions { get; init; }
[Display(Name = "settings schema")]
public string? SettingsSchemaJson { get; init; }
[Display(Name = "minimum host version")]
public string? MinimumHostVersion { get; init; }
[Display(Name = "maximum host version")]
public string? MaximumHostVersion { get; init; }
[Display(Name = "supported architectures")]
public required IReadOnlyList<string> SupportedArchitectures { get; init; }
[Display(Name = "supported runtimes")]
public required IReadOnlyList<string> SupportedRuntimeIdentifiers { get; init; }
}
public enum PluginRuntimeKind
{
InProcessManaged = 0,
OutOfProcessDotNet = 1,
OutOfProcessPython = 2,
ExternalService = 3
}
public sealed record CapabilityDeclaration
{
[Display(Name = "name")]
public required string Name { get; init; }
[Display(Name = "version")]
public required string Version { get; init; }
[Display(Name = "description")]
public string? Description { get; init; }
}
public sealed record PermissionDeclaration
{
[Display(Name = "name")]
public required string Name { get; init; }
[Display(Name = "required")]
public required bool Required { get; init; }
[Display(Name = "reason")]
public string? Reason { get; init; }
}
/// <summary>
/// Least-authority services exposed by the host.
/// The plugin must not receive IServiceProvider or internal host objects.
/// </summary>
public interface IPluginHostContext
{
/// <summary>
/// Gets the plugin identity approved by the host.
/// </summary>
PluginIdentity Identity { get; }
/// <summary>
/// Gets the operation deadline clock for deterministic timeout calculation.
/// </summary>
TimeProvider TimeProvider { get; }
/// <summary>
/// Gets a read-only host policy snapshot relevant to the plugin.
/// </summary>
HostPolicySnapshot Policy { get; }
/// <summary>
/// Writes a structured diagnostic record through the host pipeline.
/// </summary>
/// <param name="record">The diagnostic record to emit.</param>
/// <param name="cancellationToken">The cancellation token.</param>
ValueTask WriteDiagnosticAsync(
PluginDiagnosticRecord record,
CancellationToken cancellationToken);
/// <summary>
/// Gets a scoped settings store owned by the plugin.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
ValueTask<IPluginSettingsStore> GetSettingsStoreAsync(
CancellationToken cancellationToken);
/// <summary>
/// Reports progress for the current lifecycle or operation activity.
/// </summary>
/// <param name="update">The progress update.</param>
/// <param name="cancellationToken">The cancellation token.</param>
ValueTask ReportProgressAsync(
PluginProgressUpdate update,
CancellationToken cancellationToken);
}
public sealed record HostPolicySnapshot
{
[Display(Name = "network egress allowed")]
public required bool NetworkEgressAllowed { get; init; }
[Display(Name = "max concurrency")]
public required int MaxConcurrency { get; init; }
[Display(Name = "startup timeout")]
public required TimeSpan StartupTimeout { get; init; }
[Display(Name = "stop timeout")]
public required TimeSpan StopTimeout { get; init; }
[Display(Name = "operation timeout")]
public required TimeSpan DefaultOperationTimeout { get; init; }
}
public interface IPluginSettingsStore
{
/// <summary>
/// Reads the current plugin settings blob.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
ValueTask<string?> ReadJsonAsync(CancellationToken cancellationToken);
/// <summary>
/// Replaces the current plugin settings blob atomically.
/// </summary>
/// <param name="json">The new JSON settings payload.</param>
/// <param name="cancellationToken">The cancellation token.</param>
ValueTask WriteJsonAsync(string json, CancellationToken cancellationToken);
}
/// <summary>
/// Lifecycle contract for an in-process plugin.
/// </summary>
public interface IPlugin : IAsyncDisposable
{
/// <summary>
/// Performs fast static validation before configuration or startup.
/// No background work should begin here.
/// </summary>
/// <param name="context">The least-authority host context.</param>
/// <param name="cancellationToken">The cancellation token.</param>
ValueTask<PluginValidationResult> ValidateAsync(
IPluginHostContext context,
CancellationToken cancellationToken);
/// <summary>
/// Applies configuration and validates plugin-owned settings.
/// </summary>
/// <param name="context">The least-authority host context.</param>
/// <param name="settingsJson">The JSON settings payload owned by the plugin.</param>
/// <param name="cancellationToken">The cancellation token.</param>
ValueTask<PluginConfigureResult> ConfigureAsync(
IPluginHostContext context,
string? settingsJson,
CancellationToken cancellationToken);
/// <summary>
/// Allocates resources needed before the plugin is ready.
/// No externally visible work should begin until StartAsync succeeds.
/// </summary>
/// <param name="context">The least-authority host context.</param>
/// <param name="cancellationToken">The cancellation token.</param>
ValueTask<PluginInitializeResult> InitializeAsync(
IPluginHostContext context,
CancellationToken cancellationToken);
/// <summary>
/// Starts externally visible work.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
ValueTask<PluginStartResult> StartAsync(CancellationToken cancellationToken);
/// <summary>
/// Returns the current health state and recent diagnostics summary.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
ValueTask<PluginHealthReport> GetHealthAsync(CancellationToken cancellationToken);
/// <summary>
/// Temporarily suspends new work if supported.
/// </summary>
/// <param name="reason">The suspend reason.</param>
/// <param name="cancellationToken">The cancellation token.</param>
ValueTask<PluginSuspendResult> SuspendAsync(
string reason,
CancellationToken cancellationToken);
/// <summary>
/// Resumes work after a successful suspend.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
ValueTask<PluginResumeResult> ResumeAsync(CancellationToken cancellationToken);
/// <summary>
/// Stops externally visible work and releases active operations cooperatively.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
ValueTask<PluginStopResult> StopAsync(CancellationToken cancellationToken);
}
public enum PluginHealthStatus
{
Unknown = 0,
Ready = 1,
Degraded = 2,
Failed = 3
}
public sealed record PluginDiagnosticRecord
{
[Display(Name = "timestamp")]
public required DateTimeOffset TimestampUtc { get; init; }
[Display(Name = "severity")]
public required string Severity { get; init; }
[Display(Name = "code")]
public required string Code { get; init; }
[Display(Name = "message")]
public required string Message { get; init; }
[Display(Name = "data")]
public IReadOnlyDictionary<string, string>? Data { get; init; }
}
public sealed record PluginProgressUpdate
{
[Display(Name = "activity")]
public required string Activity { get; init; }
[Display(Name = "percent")]
public int? Percent { get; init; }
[Display(Name = "message")]
public string? Message { get; init; }
}
public sealed record PluginHealthReport
{
[Display(Name = "status")]
public required PluginHealthStatus Status { get; init; }
[Display(Name = "summary")]
public required string Summary { get; init; }
[Display(Name = "diagnostics")]
public required IReadOnlyList<PluginDiagnosticRecord> Diagnostics { get; init; }
}
public abstract record ContractResult
{
[Display(Name = "success")]
public required bool Success { get; init; }
[Display(Name = "code")]
public string? Code { get; init; }
[Display(Name = "message")]
public string? Message { get; init; }
}
public sealed record PluginValidationResult : ContractResult;
public sealed record PluginConfigureResult : ContractResult;
public sealed record PluginInitializeResult : ContractResult;
public sealed record PluginStartResult : ContractResult;
public sealed record PluginSuspendResult : ContractResult;
public sealed record PluginResumeResult : ContractResult;
public sealed record PluginStopResult : ContractResult;
/// <summary>
/// Typed operation envelope suitable for in-proc and out-of-proc projection.
/// </summary>
/// <typeparam name="TPayload">The payload type.</typeparam>
public sealed record OperationRequest<TPayload>
{
[Display(Name = "operation")]
public required string Operation { get; init; }
[Display(Name = "request")]
public required string RequestId { get; init; }
[Display(Name = "payload")]
public required TPayload Payload { get; init; }
[Display(Name = "deadline")]
public DateTimeOffset? DeadlineUtc { get; init; }
}
/// <summary>
/// Typed operation result envelope.
/// </summary>
/// <typeparam name="TPayload">The payload type.</typeparam>
public sealed record OperationResult<TPayload> : ContractResult
{
[Display(Name = "request")]
public required string RequestId { get; init; }
[Display(Name = "payload")]
public TPayload? Payload { get; init; }
}
/// <summary>
/// Optional unload tracking record held by the host, not the plugin.
/// </summary>
public sealed record PluginUnloadTracking
{
[Display(Name = "plugin")]
public required string PluginId { get; init; }
[Display(Name = "unload requested")]
public required DateTimeOffset UnloadRequestedUtc { get; init; }
[Display(Name = "unloaded")]
public DateTimeOffset? UnloadedUtc { get; init; }
[Display(Name = "completed")]
public required bool Completed { get; init; }
}
Proposal. For out-of-process plugins, project the same contract into a neutral wire envelope such as JSON messages with operation names, request IDs, deadline UTC, structured errors, capability claims, and diagnostic events. Avoid exposing arbitrary host callbacks, raw host service providers, or broad object references across the boundary. Use a capability-based host context where each service is explicit and revocable. This minimizes authority leakage and avoids type identity and object-lifetime coupling across boundaries.
Lifecycle state machine. The legal states and transitions should be:
| State | Allowed transitions |
|---|---|
| Discovered | Rejected, Compatible, Disabled |
| Rejected | Removed |
| Compatible | Installed, Disabled |
| Installed | Configured, Disabled, Updating, Removed |
| Configured | Initializing, Disabled, Updating, Removed |
| Initializing | Ready, Degraded, Failed, Disabled, Stopping |
| Ready | Degraded, Stopping, Failed, Updating, Disabled |
| Degraded | Ready, Stopping, Failed, Quarantined, Disabled |
| Disabled | Installed, Removed |
| Stopping | Installed, Failed, Quarantined, Removed |
| Failed | Installed, Quarantined, Disabled, Removed |
| Quarantined | Disabled, Updating, Removed |
| Updating | Rollback, Installed, Removed |
| Rollback | Installed, Disabled, Removed |
| Removed | none |
Proposal. Timeout and crash transitions are mandatory: initialization timeout forces Initializing -> Failed and may escalate to Quarantined; shutdown timeout forces Stopping -> Failed for in-proc and Stopping -> Installed only after verified stop for out-of-proc; worker crash transitions Ready/Degraded/Initializing -> Failed, increments failure counters, and may escalate to Quarantined; bad update transitions Updating -> Rollback -> Installed with the last-known-good receipt retained.
Loading and dependency algorithm. The host-side algorithm should be:
function InstallPlugin(packagePath):
manifest = parseManifest(packagePath)
validateSchema(manifest)
canonicalRoot = canonicalizeAndValidateInstallRoot(manifest)
verifyPublisherIdentityAndReceipt(manifest)
verifyHashes(packagePath, manifest)
verifyArchitecture(manifest, hostArchitecture)
verifyRuntimeCompatibility(manifest, hostRuntime)
verifyContractVersionRange(manifest, hostContractVersion)
stagePath = createContentAddressedStagingDirectory()
extractPackageAtomically(packagePath, stagePath)
fsyncAndSeal(stagePath)
commitInstallReceipt(stagePath, manifest)
moveStageIntoCatalog(stagePath)
return Installed
function ActivatePlugin(installPath):
manifest = loadCommittedManifest(installPath)
if manifest.Runtime == InProcessManaged:
alc = new CollectibleAlc(mainAssemblyPath)
resolver = new AssemblyDependencyResolver(mainAssemblyPath)
configure alc.Load(name):
if name == SharedContractAssemblyName:
return SharedContractAssemblyFromDefaultContext
path = resolver.ResolveAssemblyToPath(name)
if path != null: return alc.LoadFromAssemblyPath(path)
return null
configure alc.LoadUnmanagedDll(name):
path = resolver.ResolveUnmanagedDllToPath(name)
if path != null: return loadNative(path)
return IntPtr.Zero
create instance via reflected entry point
run ValidateAsync within timeout
run ConfigureAsync within timeout
run InitializeAsync within timeout
if health gate passes:
run StartAsync within timeout
mark Ready or Degraded
else:
StopAsync if needed
mark Failed
else if manifest.Runtime in {OutOfProcessDotNet, OutOfProcessPython}:
launch child process from committed path
establish named pipe channel with constrained ACL
perform handshake:
verify plugin identity
verify contract version
verify capability declaration echo
verify startup receipt hash
send Validate/Configure/Initialize/Start sequence with deadlines
if health gate passes:
mark Ready or Degraded
else:
terminate child if cooperative stop fails
mark Failed
else if manifest.Runtime == ExternalService:
verify service registration + version + identity + ACL
mark Installed/Ready only after handshake
function DeactivatePlugin(handle):
request cooperative stop with deadline
wait for inflight operations to drain
dispose transport/resources
if in-proc:
drop strong references
call alc.Unload()
poll weak reference + GC until completion or timeout
record leaks if unload incomplete
if out-of-proc and process alive after deadline:
kill process
archive stop diagnostics
return final state
function RemovePlugin(installPath):
ensure plugin disabled and not loaded
tombstone catalog entry
delete owned data according to policy
delete binaries from committed path
delete receipts only after successful removal
Platform fact. This algorithm matches the documented model: AssemblyDependencyResolver resolves both managed and unmanaged dependencies from the component’s dependency graph; AssemblyLoadContext can override managed and unmanaged load behavior; a collectible AssemblyLoadContext unloads only after references, threads, and handles no longer pin it; and Native AOT is incompatible with the host-side dynamic loading model.
Dependency and native-library isolation guidance. A host should never rely on default-context probing for private plugin dependencies. Each plugin’s main assembly path should seed its own AssemblyDependencyResolver, and the host should deliberately share only the stable contract assembly and any intentionally shared framework dependencies from the default context. This minimizes type identity mismatches and version cross-talk. Microsoft’s guidance explicitly notes that each AssemblyLoadContext can hold only one version per simple name, supports sharing via returned Assembly references, and warns that same-name types across contexts are not the same type.
Failure modes to design around. The most important dependency/isolation hazards are: default load-context leakage; LoadFrom-style path ambiguity; type identity mismatches; unmanaged library search surprises; and unload blocked by references, background threads, registered waits, or custom AssemblyLoadContext fields that hold plugin objects. Microsoft documents all of these patterns.
Reliability, observability, recovery, and failure catalog
Architecture synthesis. The host must assume that some plugins will hang, misbehave, leak resources, produce malformed outputs, or enter restart loops. The containment model should therefore include timeouts, circuit breakers, quarantine, safe mode, last-known-good rollback, and deterministic receipts for install/update/remove. In-proc plugins can only be stopped cooperatively and then disabled; out-of-proc workers can additionally be killed and restarted under policy.
Proposal. Recommended operational rules:
- Startup: validate -> configure -> initialize -> health gate -> start, each with its own deadline.
- Health policy: repeated degraded or failed health within a rolling window triggers disable or quarantine.
- Restart policy: exponential backoff with a maximum attempts budget; after that, quarantine.
- Safe mode: launch the host with all optional plugins disabled after repeated global startup failures.
- Rollback: updates are staged side-by-side; activation switches only after successful validation and health gate.
- Data ownership: plugin-owned settings and state live in a plugin-specific store keyed by plugin identity and versioned by plugin-defined migration steps.
- Minimum host guarantee: every core host feature that does not require a plugin remains operational when all plugins are disabled or broken.
Platform fact. Structured logging through ILogger is the standard performant logging abstraction in .NET, and .NET diagnostic tools and counters support health and performance investigation. The Process API can attribute child-process CPU time, memory, threads, handles, and exit information to out-of-proc plugins.
Proposal. Logging must be plugin-scoped and data-minimized. Every log event should carry at least: plugin ID, plugin version, publisher, installation receipt ID, lifecycle state, process ID if any, request ID, operation name, severity, and coarse resource counters. Do not log raw prompts, credentials, private files, or unrelated host logs by default. The plugin host should support redaction hooks, field allowlists, and per-capability sensitivity labels before records enter central telemetry. This follows Microsoft’s structured logging guidance while narrowing exposure to only plugin-relevant records.
Failure and containment catalog.
| Failure mode | Detection | Containment | Recovery |
|---|---|---|---|
| Constructor failure / activation exception | Activation exception surfaced during instance creation | Mark Failed; do not retry immediately | Require operator action or updated package |
| Missing managed dependency | Resolver returns null or load exception | Reject load before lifecycle starts | Fix package or compatibility metadata |
| Conflicting managed version | Type mismatch or wrong shared/default-context binding | Disable package; keep host alive | Adjust shared contract policy or repackage |
| Native-load failure | ResolveUnmanagedDllToPath fails, P/Invoke load fails | Prefer out-of-proc for native-heavy plugin; in-proc fail fast | Repackage with correct RID/arch assets |
| Hung initialization | Deadline exceeded in initialize/start | For out-of-proc: kill worker; for in-proc: disable and require restart if needed | Backoff + quarantine after repeats |
| Unobserved/background exception | Process exit, health failure, logged unhandled exception | Quarantine after threshold; isolate plugin scope | Restart if out-of-proc and policy allows |
| Background thread leak | ALC weak reference never dies after unload | Disable plugin version; block update/remove until host restart if necessary | Fix plugin; add unload regression test |
| Callback after stop | Host receives message after stop token / session closed | Drop late messages; count as protocol violation | Quarantine after repeated violations |
| Process crash | Child exit code / broken pipe | Keep host alive, mark plugin failed | Restart with backoff or quarantine |
| Malformed response | Protocol deserialization or schema validation error | Kill/quarantine offending sidecar | Require fixed plugin build |
| Resource exhaustion | Child CPU/memory/handles exceed policy | Suspend, stop, or kill plugin only | Backoff + operator diagnostics |
| Update failure | Hash/signature mismatch or health-gate failure after switch attempt | Roll back to last-known-good | Keep old version active; preserve receipts |
Platform fact. These controls are directly supported by the documented primitives: timeouts and cancellation through .NET cancellation tokens; child-process start/stop/exit monitoring through Process; asynchronous stream handling for stdio-based workers; and diagnostic counters/ports for deeper investigation.
Packaging, Windows deployment constraints, developer SDK, automated tests, and phased adoption
Platform fact. MSIX gives packaged desktop apps a clean install/uninstall model, package identity, and automatic updates, but packaged apps do not have a normal “write into my install directory” story for plugin binaries. Windows documents that packaged apps redirect writes in AppData to private per-user app locations and that writes to the MSIX package/container itself fail. Windows App SDK deployment guidance also distinguishes packaged apps, packaged apps with external location, and unpackaged apps, and notes that PublishSingleFile support for WinUI 3 requires unpackaged + self-contained; packaged apps do not support that model.
Architecture synthesis. Therefore, a packaged desktop host should install optional plugins into a host-managed writable location outside the immutable package payload, typically a per-user plugin directory under AppData or another explicit external location under host control. The host itself may be MSIX-packaged, but optional plugin payloads should not be modeled as in-place mutations of the package. In enterprise scenarios, MSIX modification packages are a valid distribution shape for organization-managed add-ons, but they are not the best general-purpose lifecycle for fast-moving optional plugins that need independent quarantine, receipts, and side-by-side rollback.
Platform fact. Single-file publishing can explicitly exclude files from embedding, trimming is incompatible with some reflection-heavy patterns, and Native AOT does not support dynamic loading. A plugin host should therefore avoid Native AOT and should treat trimming very carefully. A plugin worker may use self-contained or even Native AOT deployment only if its own runtime model is static and AOT-compatible.
Platform fact. Runtime identifiers determine native and architecture-specific asset selection, and publishing with a RID includes native dependencies as needed. This makes RID and architecture declarations mandatory manifest fields for plugin validation, especially when plugins carry native payloads.
Proposal. The plugin developer SDK should ship as a neutral package set:
Example.Plugin.Abstractionsfor the stable contract.Example.Plugin.TestHostfor local harness execution.Example.Plugin.Analyzersto forbid host-internals references, reflection over forbidden namespaces, thread leaks after stop, broad P/Invoke without declarations, and direct service-provider access.Example.Plugin.Templatesfor a managed in-proc plugin, a .NET sidecar worker, and a Python sidecar skeleton.Example.Plugin.Manifest.Schemafor manifest validation and example receipts.
Proposal. Submission or admission tests should include, at minimum: manifest schema validation; semantic contract version compatibility; architecture/RID validation; lifecycle timeout handling; cancellation compliance; structured log conformance; malformed settings rejection; repeated start/stop idempotency; in-proc unload success where applicable; protocol fuzzing for out-of-proc workers; and policy enforcement for declared capabilities and permissions. Use SemVer for the public contract versioning story, but remember that NuGet package versions and assembly versions have different roles in .NET.
Proposal. The automated-test architecture should have these layers:
- Unit tests for manifest parser, policy engine, version-range matcher, path canonicalization, and lifecycle state machine.
- Contract tests run against every plugin package using the same abstract lifecycle suite.
- Integration tests for install, start, health, stop, update, rollback, disable, uninstall.
- Process-isolation tests for child crash, broken pipe, malformed protocol, resource ceilings, restart backoff.
- Unload tests for collectible ALC plugins using weak-reference verification and forced-GC loops.
- Compatibility tests for contract version N/N+1, host downgrade semantics, plugin downgrade, publisher mismatch.
- Chaos tests for delayed responses, partial writes, corrupt manifests, dependency absence, and stop-time races.
- Package tests for MSIX-packaged host scenarios, unpackaged scenarios, writable-location behavior, and side-by-side update safety.
Proposal. Adversarial fixture plugins should include:
Example.Plugin.Good.MinimalExample.Plugin.Bad.MissingDependencyExample.Plugin.Bad.NativeLoadFailureExample.Plugin.Bad.HungInitializeExample.Plugin.Bad.BackgroundThreadLeakExample.Plugin.Bad.CallbackAfterStopExample.Plugin.Bad.VersionMismatchExample.Plugin.Bad.MalformedOutputExample.Plugin.Bad.CrashLoopExample.Plugin.Bad.ResourceHogExample.Plugin.Bad.PermissionMismatch
Platform fact. Current .NET testing guidance supports automation through dotnet test, and MSTest’s current analyzers explicitly encourage cooperative cancellation and flowing the test cancellation token to async operations. FakeTimeProvider is the supported deterministic-time primitive for testing time-sensitive behavior.
Proposal. Deterministic tests should cover version matching, state transitions, cancellation propagation, timeout calculations, retry policies, quarantine thresholds, and receipt hashing. Windows-specific integration infrastructure is still required for named-pipe ACLs, Process resource attribution, Windows service behavior, packaged-app writable-path behavior, and MSIX/update interactions. AssemblyLoadContext unload tests are conceptually cross-platform but should be part of the Windows CI lane as well because the host target is Windows desktop.
Phased adoption plan. A safe staged rollout is:
Phase one. First-party static modules only. Use direct references and host DI. Establish manifest schema, receipts, and lifecycle state machine before opening extensibility.
Phase two. Trusted first-party managed plugins in collectible ALC. Add unload tests, strict shared-contract discipline, and disable/quarantine controls. Do not onboard third parties yet.
Phase three. First-party and closely held partner sidecars out of process over named pipes. Add supervisor, health gates, restart policy, and per-plugin diagnostics/resource attribution. This is the point where Python sidecars become acceptable.
Phase four. Signed partner plugins with curated admission. Default all third-party plugins to out-of-proc. Require capability declarations, publisher identity, receipts, revocation/quarantine, and package compatibility tests. Strong names alone are insufficient for security or trust.
Phase five. Optional service brokers for privileged or always-on capabilities only after revocation, operator controls, and observability are mature. Broad third-party in-proc extensibility should remain either disallowed or highly exceptional.
Open implementation questions, limitations, and sources
Unknown. The private product’s actual deployment mode—packaged, unpackaged, or packaged with external location—is not known, and that materially affects plugin install paths, update UX, and single-file options.
Unknown. The actual trust model for plugin publishers is not known. Whether enterprise policy requires Authenticode/MSIX signing, certificate pinning, allowlists, revocation feeds, or an internal package repository remains an implementation decision. Microsoft guidance supports package signing and publisher identity, but the concrete trust chain is product-specific.
Unknown. The required degree of cross-language portability is not known. A Windows-only product should favor named pipes for local sidecars, but an explicit cross-platform future might justify sockets as the strategic IPC abstraction despite weaker Windows-local ergonomics.
Limitation. Some scoring in the comparison matrix is necessarily architectural synthesis rather than explicit vendor scoring. The cited sources establish the underlying runtime, IPC, deployment, unloadability, and security facts; the numeric prioritization is a design judgment derived from them.
Limitation. This report intentionally avoids product-specific namespaces, package formats, version numbers, and implementation claims. All class names, package names, and process diagrams are proposals.
Sources ledger. The table below uses citation links in place of raw URLs so each source remains directly navigable while complying with response-format constraints.
| Source link | Title | Publisher | Version or date | Access date | Claim supported | Platform applicability | Limitations |
|---|---|---|---|---|---|---|---|
| Create a .NET Core application with plugins | Create a .NET Core application with plugins | Microsoft Learn | 2026-02-04 | 2026-07-12 | Custom AssemblyLoadContext + AssemblyDependencyResolver is the supported plugin-loading pattern | Modern .NET desktop host | Tutorial-level, not a production blueprint |
About AssemblyLoadContext | About System.Runtime.Loader.AssemblyLoadContext | Microsoft Learn | 2026-03-11 | 2026-07-12 | ALC versioning rules, shared dependencies, isolation limits, type identity caveats | Modern .NET desktop host | Conceptual guidance, no full operational lifecycle |
| How to use and debug assembly unloadability in .NET | How to use and debug assembly unloadability in .NET | Microsoft Learn | 2026-03-19 | 2026-07-12 | Collectible ALC unload is cooperative; threads, references, handles, waits can block unload | In-proc collectible plugin zone | Focused on unload, not on plugin UX or packaging |
| Secure coding guidelines for .NET | Secure coding guidelines for .NET | Microsoft Learn | 2021-09-15 | 2026-07-12 | No in-proc partial-trust security boundary; use OS users/AppContainers/virtualization instead | All modern .NET deployments | High-level security guidance |
| CAS APIs obsolete | Breaking change: Most code access security APIs are obsolete | Microsoft Learn | .NET 5 compatibility note | 2026-07-12 | CAS is unsupported legacy technology in modern .NET | All modern .NET deployments | Compatibility guidance, not plugin-specific |
| Dependency injection overview | Dependency injection in .NET | Microsoft Learn | 2026-01-28 | 2026-07-12 | Built-in DI container is current supported composition model | Host internals and built-ins | Not an isolation mechanism by itself |
| MEF docs | Managed Extensibility Framework / System.Composition | Microsoft Learn / NuGet | 2026-03-04 / current package page | 2026-07-12 | MEF remains available as composition technology | Trusted in-proc composition | Not a security or process-isolation story |
| Named pipes API | NamedPipeServerStream / Named pipes IPC | Microsoft Learn | current API / 2026-05-07 | 2026-07-12 | Named pipes are duplex IPC and align with Windows security | Windows local IPC | Not a complete RPC protocol design |
| gRPC IPC over named pipes | Inter-process communication with gRPC and Named pipes | Microsoft Learn | 2025-08-08 / current | 2026-07-12 | .NET supports local IPC over named pipes; ACL-based security is available | Out-of-proc .NET worker on Windows | Requires .NET-supported transport stack |
| Process I/O docs | RedirectStandardOutput / BeginOutputReadLine | Microsoft Learn | current API | 2026-07-12 | Stdio redirection can deadlock if handled incorrectly; async reads are required for safety | CLI-style or sidecar stdio workers | Focused on stream behavior, not higher-level protocol semantics |
| Windows service docs | Create Windows Service using BackgroundService | Microsoft Learn | 2025-10-22 / 2026-04-27 | 2026-07-12 | Separate Windows services are the right model for long-running service-style capabilities | Separate broker/service tier | Does not address desktop plugin UX directly |
| Native library loading | Native library loading / unmanaged loading algorithm | Microsoft Learn | 2023-09-09 / 2021-10-27 | 2026-07-12 | Native probing and unmanaged load behavior must be deliberate | Native-heavy plugins | Lower-level runtime behavior, not packaging policy |
| Single-file deployment | Create a single file for application deployment | Microsoft Learn | 2026-03-25 | 2026-07-12 | Files can be excluded from single-file bundling; RID-specific deployment applies | Unpackaged or generic .NET deployment | Not all Windows desktop packaging modes support it |
| Trimming guidance | Known trimming incompatibilities / Fixing trim warnings | Microsoft Learn | 2023-11-10 / 2025-11-19 / 2026-01-08 | 2026-07-12 | Dynamic reflection-heavy plugin hosts require trimming caution | Plugin discovery and host deployment | Guidance is broad, not plugin-host specific |
| Native AOT overview | Native AOT deployment overview | Microsoft Learn | 2026-01-08 | 2026-07-12 | Native AOT prohibits dynamic loading; unsuitable for a dynamic plugin host | Host deployment decisions | AOT workers may still be possible if static |
| Windows App SDK deployment overview | Windows App SDK deployment overview / Packaging overview | Microsoft Learn | 2026-05-29 / 2026-06-20 | 2026-07-12 | Packaged/unpackaged choices affect single-file, servicing, and plugin install paths | WinUI 3 / Windows desktop host | Windows App SDK specific |
| Packaged desktop behavior | Understanding how packaged desktop apps run on Windows / PSF filesystem write permission | Microsoft Learn | 2025-09-09 / 2023-01-18 | 2026-07-12 | Packaged apps should not assume mutable install locations; AppData writes are redirected | MSIX-packaged host | More about package behavior than plugin design |
| MSIX overview and modification packages | What is MSIX? / Modification packages | Microsoft Learn | 2026-04-15 / 2022-04-01 | 2026-07-12 | MSIX provides clean updates; modification packages can represent add-ons | Enterprise packaging strategy | Modification packages are not the whole plugin lifecycle |
| MSIX signing guidance | Sign your MSIX package / Create a certificate for package signing | Microsoft Learn | 2026-04-19 / 2025-04-22 / 2026-04-15 | 2026-07-12 | Publisher identity and package signing are available trust anchors | Plugin package/repository policy | Trust pipeline details remain product-specific |
| Strong naming guidance | Strong-named assemblies / Enhanced strong naming | Microsoft Learn | 2025-10-20 / 2022-08-23 | 2026-07-12 | Strong names identify assemblies but are not a security boundary or publisher-trust substitute | All managed plugin packages | Often misunderstood as trust model |
| Logging and diagnostics | Logging in C# and .NET / dotnet-counters / diagnostic port / Process API | Microsoft Learn | 2026-02-04 / 2025-10-02 / 2025-11-22 / current API | 2026-07-12 | Structured logs and child-process metrics are available for attribution and diagnostics | Host observability and operations | Many tools are operator-level, not embedded-product APIs |
| Testing guidance | FakeTimeProvider / dotnet test / MSTest cooperative cancellation | Microsoft Learn | 2026-03-02 / 2026-01-20 / 2026-02-04 / 2026-04-02 / current | 2026-07-12 | Deterministic clocks and cooperative cancellation should be standard in plugin test harnesses | Automated test strategy | Framework-agnostic patterns still need local harness design |
| Versioning references | Semantic Versioning 2.0.0 / NuGet package versioning / .NET library versioning | SemVer / Microsoft Learn | current / 2025-01-21 / 2025-01-29 / 2026-04-08 | 2026-07-12 | Public contract versioning should use SemVer-aware policy, with NuGet and assembly versions understood separately | Contract and package compatibility | Requires product-specific version-range policy |
Final recommendation. The architecture choice is clear: use named-pipe sidecars as the default plugin boundary, permit collectible ALC only for trusted managed low-risk plugins, and reserve Windows services for privileged or always-on broker functions. That is the best current-platform balance of extensibility, unloadability, dependency isolation, diagnostics, and enterprise reliability on Windows desktop .NET.