If you've ever thought "I want plugins, but something more structured than just loading a DLL," you're thinking about exactly what VS Code solved. The VS Code extension system is one of the most well-designed extensibility platforms in modern tooling -- and the structural concepts behind it translate well into C# for applications that need a real extensibility layer.
One important distinction upfront: VS Code extensions run out-of-process with strong isolation boundaries enforced by Node.js process separation. The in-process .NET implementation we build here is architecturally different -- plugins share the host's address space, which means a misbehaving plugin can affect the host. The patterns below address this tradeoff explicitly, but it is worth understanding before you commit to an in-process design.
This guide walks through building a VS Code-style extension system in C# from scratch. You'll get concrete interfaces, classes, and wiring that you can adapt to your own application -- whether that's a developer tool, a CMS, a CI/CD platform, or anything else that benefits from third-party extensibility. The extension system C# pattern we'll implement covers the full lifecycle: discovery, manifests, lazy activation, contribution points, and a scoped API surface.
Building a VS Code-style extension system in C# is the subject of this guide, and we'll walk through each layer from scratch. If you're newer to plugin architectures in general, start with Plugin Architecture Design Pattern -- A Beginner's Guide to Modularity before diving in here. This article assumes you already know you need structured extensibility -- we're going to build it.
What Makes VS Code's Extension Model So Powerful?
Most "plugin systems" amount to: load this DLL and call an interface. That works for simple cases. VS Code's model solves a different problem -- it manages a lifecycle, lazy activation, scoped API access, and structured contribution points. That's what separates a plugin system from an extension platform.
Here are the four key concepts from VS Code's model that we'll adapt to C#:
- Extension manifest -- a structured description of what an extension provides and when it activates (VS Code uses
package.json; we'll use a C# record backed byextension.json) - Contribution points -- named hooks in the host where extensions can register capabilities (commands, data sources, menu items, and more)
- Activation events -- lazy activation triggers that defer loading until the extension is actually needed
- Extension API surface -- a scoped context that gives extensions controlled access to host services without handing them the entire dependency container
If you've already worked with Plugin Architecture in C# for Improved Software Design, you'll recognize some of these patterns -- but here we're layering in the lifecycle and manifest system that makes VS Code's extension system feel like a real platform.
We'll build a simplified version of this entire extension system C# model in .NET 8/9 using records, pattern matching, and generic constraints throughout.
Extension Manifests in C#
In VS Code, every extension ships a package.json that declares its identity, capabilities, and activation triggers. We'll do the same thing with a strongly-typed ExtensionManifest record deserialized from a per-extension extension.json file.
// ExtensionManifest.cs -- strongly-typed manifest replacing VS Code's package.json
public sealed record ExtensionManifest
{
public required string Id { get; init; }
public required string Name { get; init; }
public required string Version { get; init; }
public string Description { get; init; } = string.Empty;
// Events that trigger this extension to activate (e.g., "onStartup", "onCommand:myCmd")
public string[] ActivationEvents { get; init; } = [];
// Named contribution points this extension hooks into
public Dictionary<string, JsonElement> Contributes { get; init; } = [];
}
Loading the manifest before touching any assemblies is a critical design choice. The manifest is your gating document -- if it's missing or malformed, the extension doesn't participate in the extension system C# runtime at all.
// ExtensionLoader.cs
public static class ExtensionLoader
{
private static readonly JsonSerializerOptions _options = new()
{
PropertyNameCaseInsensitive = true
};
public static ExtensionManifest? LoadManifest(string extensionDirectory)
{
var manifestPath = Path.Combine(extensionDirectory, "extension.json");
if (!File.Exists(manifestPath))
return null;
var json = File.ReadAllText(manifestPath);
return JsonSerializer.Deserialize<ExtensionManifest>(json, _options);
}
}
This approach means you can enumerate what every installed extension provides -- its contributed commands, data sources, or menu items -- without ever loading an assembly. Discovery is cheap; activation is deferred.
Defining Extension Points (Contribution Points)
The real power of VS Code's model is contribution points. Instead of extensions directly reaching into the host, they register capabilities against named hooks. The host decides when and how to invoke them.
The core abstraction is a generic extension point interface:
// IExtensionPoint.cs
public interface IExtensionPoint<T>
{
string Name { get; }
void Register(string extensionId, T contribution);
IReadOnlyList<(string ExtensionId, T Contribution)> GetAll();
}
// ExtensionPointRegistry.cs -- manages all named extension points in the host
public sealed class ExtensionPointRegistry
{
private readonly Dictionary<string, object> _points = new();
public void Register<T>(IExtensionPoint<T> point)
=> _points[point.Name] = point;
public IExtensionPoint<T> Resolve<T>(string name)
{
if (_points.TryGetValue(name, out var point) && point is IExtensionPoint<T> typed)
return typed;
throw new InvalidOperationException(
$"No extension point '{name}' registered for type '{typeof(T).Name}'.");
}
public bool TryResolve<T>(string name, out IExtensionPoint<T>? point)
{
if (_points.TryGetValue(name, out var raw) && raw is IExtensionPoint<T> typed)
{
point = typed;
return true;
}
point = null;
return false;
}
}
A concrete extension point -- pluggable commands, for instance -- looks like this:
// CommandExtensionPoint.cs
public sealed class CommandExtensionPoint : IExtensionPoint<Func<string[], Task>>
{
private readonly List<(string ExtensionId, Func<string[], Task> Handler)> _registrations = [];
public string Name => "commands";
public void Register(string extensionId, Func<string[], Task> contribution)
=> _registrations.Add((extensionId, contribution));
public IReadOnlyList<(string ExtensionId, Func<string[], Task> Contribution)> GetAll()
=> _registrations.AsReadOnly();
}
You can create extension points for any contribution type: IDataSource, IMenuContribution, IStatusBarItem, IAnalyzer. Each one is just a named, typed registration bucket. The host calls Resolve<T>("commands") to get all registered command handlers; it doesn't care which extension contributed them.
This is the same structural pattern that Plugin Architecture in ASP.NET Core -- How To Master It applies in web contexts -- contribution points make the extensible application .NET model composable across any host environment.
Lazy Activation with Activation Events
One of the things that keeps VS Code fast is that extensions don't all load at startup. They activate on demand, based on activation events declared in the manifest. The same approach is essential to any well-designed extension system C# platform -- loading everything eagerly is a startup tax that grows with your extension ecosystem.
The activation model works like this: an extension declares events in its manifest such as onCommand:myCommand, onStartup, or onFileOpen. The host fires events as user actions occur, and any extension that declared the matching event gets loaded and initialized.
// IActivationCondition.cs
public interface IActivationCondition
{
bool ShouldActivate(string activationEvent);
}
// ManifestActivationCondition.cs
public sealed class ManifestActivationCondition : IActivationCondition
{
private readonly ExtensionManifest _manifest;
public ManifestActivationCondition(ExtensionManifest manifest)
=> _manifest = manifest;
public bool ShouldActivate(string activationEvent)
=> _manifest.ActivationEvents.Contains("*") ||
_manifest.ActivationEvents.Contains(activationEvent);
}
// IExtension.cs -- entry point contract for all extensions
public interface IExtension
{
Task ActivateAsync(IExtensionContext context);
Task DeactivateAsync();
}
// ExtensionActivator.cs -- activates extensions on demand based on events
public sealed class ExtensionActivator
{
private readonly ExtensionPointRegistry _registry;
private readonly Dictionary<string, bool> _activated = new();
public ExtensionActivator(ExtensionPointRegistry registry)
=> _registry = registry;
public async Task ActivateAsync(
string extensionId,
IExtension extension,
IActivationCondition condition,
string activationEvent,
IExtensionContext context)
{
if (_activated.GetValueOrDefault(extensionId))
return;
if (!condition.ShouldActivate(activationEvent))
return;
await extension.ActivateAsync(context);
_activated[extensionId] = true;
}
}
The * wildcard in ActivationEvents mirrors VS Code's "*" activation -- it means "activate immediately at startup." Use it sparingly. Extensions that truly need to be available from the first moment should declare it explicitly; everything else should lazy-load.
The Host API Surface: What Extensions Can Access
This is the piece most extension systems get wrong. If you hand extensions a direct reference to your IServiceProvider, they can resolve anything in the container. That's a security problem and a coupling nightmare -- it means your extension API is implicitly "everything."
VS Code solves this by giving each extension a scoped context that exposes only what the host deliberately shares. Here's that pattern adapted to our plugin extension point C# model:
// IExtensionContext.cs -- the only surface extensions see of the host
public interface IExtensionContext
{
string ExtensionId { get; }
IExtensionLogger Logger { get; }
IExtensionConfiguration Configuration { get; }
ICommandRegistry Commands { get; }
IExtensionStorage Storage { get; }
}
// ExtensionContext.cs -- scoped implementation built by the host
public sealed class ExtensionContext : IExtensionContext
{
public ExtensionContext(
string extensionId,
ILogger hostLogger,
IConfiguration hostConfiguration,
ICommandRegistry commands,
IExtensionStorage storage)
{
ExtensionId = extensionId;
Commands = commands;
Storage = storage;
// Scope the logger and configuration to this extension's id
Logger = new ScopedExtensionLogger(extensionId, hostLogger);
Configuration = new ScopedExtensionConfiguration(extensionId, hostConfiguration);
}
public string ExtensionId { get; }
public IExtensionLogger Logger { get; }
public IExtensionConfiguration Configuration { get; }
public ICommandRegistry Commands { get; }
public IExtensionStorage Storage { get; }
}
Extensions call context.Commands.Register(...), context.Logger.LogInformation(...), and context.Storage.Get(...). They never see the host's service container, database connection, or any other internal. The host owns the surface; extensions work within it.
This scoped context approach complements the dependency injection patterns in Plugin Architecture with Needlr in .NET: Building Modular Applications -- controlled API exposure applies regardless of which DI framework backs the host.
Extension Registry and Marketplace Pattern
A complete extension system in C# needs a discovery layer. The registry scans a directory for installed extensions, loads their manifests, resolves version conflicts, and prepares entries for activation.
// ExtensionRegistry.cs
public sealed class ExtensionRegistry
{
private readonly string _extensionsRoot;
private readonly Dictionary<string, (ExtensionManifest Manifest, string Directory)> _entries = new();
public ExtensionRegistry(string extensionsRoot)
=> _extensionsRoot = extensionsRoot;
public void Discover()
{
if (!Directory.Exists(_extensionsRoot))
return;
foreach (var dir in Directory.GetDirectories(_extensionsRoot))
{
var manifest = ExtensionLoader.LoadManifest(dir);
if (manifest is null)
continue;
// Version conflict resolution: keep the higher version number
if (_entries.TryGetValue(manifest.Id, out var existing))
{
var existingVersion = Version.Parse(existing.Manifest.Version);
var incomingVersion = Version.Parse(manifest.Version);
if (incomingVersion <= existingVersion)
continue;
}
_entries[manifest.Id] = (manifest, dir);
}
}
public IReadOnlyDictionary<string, (ExtensionManifest Manifest, string Directory)> GetAll()
=> _entries.AsReadOnly();
}
Version conflict resolution is intentionally simple here -- higher version wins. Note that System.Version (Version.Parse) handles numeric four-part versions only; it does not support SemVer pre-release tags or build metadata. If your extension versions use full SemVer (e.g. 1.2.0-beta.1), use a dedicated SemVer parser rather than System.Version. A production registry might also expose resolution strategies as a first-class concern, particularly if extensions declare dependencies on other extensions. For a marketplace-style model, you'd add a remote metadata endpoint and a download-and-install step before calling Discover().
Putting It Together: A Complete Extension System
Here's how all the pieces wire up inside an ExtensionHost -- the central coordinator for the extension system C# runtime:
// ExtensionHost.cs
public sealed class ExtensionHost
{
private readonly ExtensionRegistry _registry;
private readonly ExtensionPointRegistry _pointRegistry;
private readonly ExtensionActivator _activator;
private readonly IServiceProvider _services;
// Cache loaded extension instances to avoid reloading assemblies
private readonly Dictionary<string, IExtension> _loaded = new();
public ExtensionHost(
ExtensionRegistry registry,
ExtensionPointRegistry pointRegistry,
IServiceProvider services)
{
_registry = registry;
_pointRegistry = pointRegistry;
_activator = new ExtensionActivator(pointRegistry);
_services = services;
}
public async Task StartupAsync()
{
_registry.Discover();
foreach (var (id, (manifest, directory)) in _registry.GetAll())
{
var extension = LoadExtension(id, directory);
if (extension is null)
continue;
var context = BuildContext(id);
var condition = new ManifestActivationCondition(manifest);
// Only activate extensions that declare onStartup
await _activator.ActivateAsync(id, extension, condition, "onStartup", context);
}
}
public async Task FireEventAsync(string activationEvent)
{
foreach (var (id, (manifest, directory)) in _registry.GetAll())
{
var extension = LoadExtension(id, directory);
if (extension is null)
continue;
var context = BuildContext(id);
var condition = new ManifestActivationCondition(manifest);
await _activator.ActivateAsync(id, extension, condition, activationEvent, context);
}
}
private IExtension? LoadExtension(string id, string directory)
{
if (_loaded.TryGetValue(id, out var cached))
return cached;
var assemblyPath = Path.Combine(directory, $"{id}.dll");
if (!File.Exists(assemblyPath))
return null;
// Simplified loading via Assembly.LoadFrom for clarity.
// In production, replace this with an AssemblyLoadContext (isCollectible: true)
// so extensions are isolated and can be unloaded independently.
var assembly = Assembly.LoadFrom(assemblyPath);
var extensionType = assembly
.GetExportedTypes()
.FirstOrDefault(t => typeof(IExtension).IsAssignableFrom(t) && !t.IsAbstract);
if (extensionType is null || Activator.CreateInstance(extensionType) is not IExtension ext)
return null;
_loaded[id] = ext;
return ext;
}
private IExtensionContext BuildContext(string extensionId)
{
var logger = _services.GetRequiredService<ILogger<ExtensionHost>>();
var config = _services.GetRequiredService<IConfiguration>();
var commands = _services.GetRequiredService<ICommandRegistry>();
var storage = _services.GetRequiredService<IExtensionStorage>();
return new ExtensionContext(extensionId, logger, config, commands, storage);
}
}
The end-to-end flow mirrors VS Code: startup discovers manifests and activates any onStartup extensions; all others remain dormant. When the user triggers a command, FireEventAsync("onCommand:myCommand") activates the matching extension and its contribution immediately becomes available through the registered extension point. This is the heart of building a VS Code-style extension system in C# -- structure that scales with your extension ecosystem without sacrificing startup performance.
For Blazor-specific lifecycle considerations -- particularly around dynamic assembly loading and component teardown -- Plugin Architecture in Blazor -- A How To Guide and Blazor Plugin Architecture -- How To Manage Dynamic Loading & Lifecycle cover the nuances you'd need to layer on top of this foundation.
How This Compares to VS Code's Real Extension API
VS Code's actual implementation goes significantly further than what we've built:
- Extensions run in isolated Node.js worker processes communicating over IPC
- The VSIX packaging format includes transpilation, bundling, and signature verification
- The Language Server Protocol is a first-class extension surface
- The renderer process is sandboxed separately from the extension host process
What we've built captures the structural essence of that model -- manifests, contribution points, lazy activation, and scoped API access -- without the process isolation overhead. For most .NET applications, that's exactly the right trade-off when building a VS Code-style extension system in C#.
When this pattern is overkill:
- Small internal applications where a simple
List<IPlugin>is sufficient - Apps where all extensions are first-party and ship with the product
- Scenarios where you don't need versioned, independently deployed extensions
When this pattern is the right call:
- IDEs and developer tools (the original VS Code use case)
- CMS platforms where third parties extend the content model
- CI/CD pipeline runners where pipeline steps are contributed by plugins
- Data processing platforms where sources, transforms, and sinks are extension points
The investment in manifests, contribution points, and activation events pays off when you have independent teams or third parties contributing extensions you don't fully control.
Frequently Asked Questions
How is an extension system different from a basic plugin architecture?
A basic plugin architecture loads assemblies that implement an interface and calls them. An extension system in C# adds a lifecycle layer on top: manifests that describe what a plugin contributes and when it activates, contribution points that structure exactly where plugins hook into the host, activation events that control deferred loading, and a scoped API surface that bounds what plugins can access. The difference is between "implement this interface" and "participate in this platform."
Does building a VS Code-style extension system require any specific NuGet packages?
No -- the core extension system C# pattern uses only BCL types (System.Text.Json, System.Reflection, System.IO). No special NuGet packages are required to implement manifests, registries, and the activation model. If you want each extension to have its own DI container, Microsoft.Extensions.DependencyInjection is the natural choice. For more sophisticated assembly isolation, System.Runtime.Loader.AssemblyLoadContext from the BCL is the right tool.
How do I handle extension dependencies in a C# extension system?
Extension dependencies are the hardest part of any extensible application .NET design. The simplest model: declare dependencies in the manifest and have the registry validate all required extensions are present before activation. For shared library conflicts -- two extensions requiring different versions of the same assembly -- use AssemblyLoadContext to give each extension its own isolated load context. This prevents version clashes at the cost of additional memory for duplicated types.
Can I sandbox extensions to prevent them from accessing sensitive host APIs?
You can't fully sandbox managed code within the same CLR process -- there's no security boundary at that level. Practical mitigation: the scoped IExtensionContext pattern limits what extensions can access through the public API surface, making it impossible to resolve internal host services. For true isolation, run extensions in a separate process and communicate over a defined channel such as named pipes or gRPC. This adds latency but gives you a real process boundary.
What is the best way to distribute extensions for a C# application?
A folder-scan registry is the simplest distribution model -- drop an extension.json and a DLL into an extensions directory and restart. For marketplace-style distribution, expose a remote registry endpoint that returns available extension metadata with download URLs, and have the host pull and install on demand before the next Discover() call. NuGet is also a viable distribution format -- extensions can be packaged as NuGet packages, installed into a local feed, and discovered by the folder-scan registry without any custom tooling.
Wrapping Up
Building a VS Code-style extension system in C# is not trivial -- but it's not mysterious once you break it into the four core concepts: manifests, contribution points, activation events, and a scoped API surface. Each piece solves a specific failure mode of naive plugin loading.
The ExtensionManifest gives you a versioned, structured description of what an extension does before you load it. The ExtensionPointRegistry gives you named contribution hooks that extensions register against. The ExtensionActivator gives you lazy loading that keeps startup fast as your extension ecosystem grows. And IExtensionContext gives you a deliberate boundary between the host and its extensions.
Start with the manifest and contribution points -- the rest of the system can grow incrementally as your extensibility requirements evolve. If your extensible application also handles AI workloads, Semantic Kernel Plugin Best Practices and Patterns for C# Developers is worth reading next -- the plugin extension point C# mental model maps cleanly between VS Code-style platforms and Semantic Kernel's function registration model.

