DEV Community

Nick
Nick

Posted on

Part 10: Plugin System Architecture - Extensibility by Design

Our workflow engine is designed to be more than just a pre-defined set of nodes. We recognize that every business, every API, and every integration is unique. That is why we built Vyshyvanka around a robust, assembly-based plugin architecture that allows you to extend the engine without touching the core codebase.

The Foundation: Assembly Loading

At the heart of our extensibility model is .NET's AssemblyLoadContext. When the engine starts, the PluginLoader scans a designated plugin directory for compiled .dll files. Each plugin gets its own collectible PluginLoadContext, which provides full isolation from the core application — preventing version conflicts between your dependencies and the engine's dependencies.

internal class PluginLoadContext : AssemblyLoadContext
{
    private readonly AssemblyDependencyResolver _resolver;

    public PluginLoadContext(string pluginPath) : base(isCollectible: true)
    {
        _resolver = new AssemblyDependencyResolver(pluginPath);
    }

    protected override Assembly? Load(AssemblyName assemblyName)
    {
        // Try to resolve from plugin directory first
        var assemblyPath = _resolver.ResolveAssemblyToPath(assemblyName);
        if (assemblyPath is not null)
            return LoadFromAssemblyPath(assemblyPath);

        // Fall back to default context for shared assemblies (like Vyshyvanka.Core)
        return null;
    }
}
Enter fullscreen mode Exit fullscreen mode

The isCollectible: true flag means plugins can be unloaded at runtime — enabling hot-reload scenarios and safe removal of installed packages.

Declaring Plugins

Each plugin is identified by a PluginAttribute on the assembly. This tells the engine who you are, what version you are on, and provides discovery metadata:

// PluginInfo.cs
using Vyshyvanka.Core.Interfaces;

[assembly: Plugin(
    "vyshyvanka.plugin.httpclient",
    Name = "Advanced HTTP Client",
    Version = "1.0.0",
    Description = "Advanced HTTP client nodes with retry, polling, and batch request capabilities.",
    Author = "Vyshyvanka")]
Enter fullscreen mode Exit fullscreen mode

If an assembly does not have this attribute, the loader skips it silently — only intentional Vyshyvanka plugins get registered.

Creating Custom Nodes

The power of the system lies in how we define nodes. A node class inherits from BasePluginNode and is decorated with attributes that describe its UI representation and configuration schema:

[NodeDefinition(
    Name = "GraphQL Request",
    Description = "Execute GraphQL queries and mutations against a GraphQL endpoint",
    Icon = "fa-solid fa-diagram-project")]
[NodeInput("input", DisplayName = "Input")]
[NodeOutput("output", DisplayName = "Response", Type = PortType.Object)]
[ConfigurationProperty("endpoint", "string", Description = "GraphQL endpoint URL", IsRequired = true)]
[ConfigurationProperty("query", "string", Description = "GraphQL query or mutation string", IsRequired = true)]
[ConfigurationProperty("variables", "object", Description = "Query variables as key-value pairs")]
[ConfigurationProperty("headers", "object", Description = "Additional HTTP headers")]
[ConfigurationProperty("timeout", "number", Description = "Request timeout in seconds (default: 30)")]
public class GraphQLNode : BasePluginNode
{
    public override string Type => "graphql-request";
    public override NodeCategory Category => NodeCategory.Action;

    public override async Task<NodeOutput> ExecuteAsync(NodeInput input, IExecutionContext context)
    {
        var endpoint = GetRequiredConfigValue<string>(input, "endpoint");
        var query = GetRequiredConfigValue<string>(input, "query");
        var variables = GetConfigValue<Dictionary<string, object>>(input, "variables");
        var timeoutSeconds = GetConfigValue<int?>(input, "timeout") ?? 30;

        // ... execute the GraphQL request ...

        return SuccessOutput(responseData);
    }
}
Enter fullscreen mode Exit fullscreen mode

Notice that [ConfigurationProperty] attributes are placed on the class itself (not on individual properties). Configuration values are accessed at runtime through the GetConfigValue<T>() and GetRequiredConfigValue<T>() helper methods inherited from the base class. This design means the engine and Designer UI can discover the configuration schema via reflection without instantiating the node.

Plugin Validation

Before a plugin is registered, the PluginValidator checks it for correctness:

  • Does it have the required [Plugin] attribute?
  • Do its node types properly implement INode?
  • Are there any dependency conflicts?

If validation fails, the plugin is rejected but the engine continues operating. You get a clear error in the logs telling you exactly what went wrong.

Package Management

Beyond dropping DLLs into a directory, Vyshyvanka has a full NuGet-based package management system. The NuGetPackageManager orchestrates the lifecycle:

  1. PackageSearchService — Searches configured NuGet sources, resolves versions, checks for updates.
  2. PluginLoadingService — Loads plugin assemblies, validates them, and registers their node types with the NodeRegistry.
  3. NuGetPackageManager — Orchestrates install/update/uninstall using the above services.

This means you can install plugins from private NuGet feeds, manage versions centrally, and update plugins without restarting the engine.

Why This Design?

  1. Strong Typing: By leveraging C# attributes, we automatically generate the UI schema for your nodes. When you add a new [ConfigurationProperty], it appears in the Designer without writing frontend code.
  2. Isolation: Each plugin runs in its own AssemblyLoadContext. If a plugin fails to load or crashes during execution, the rest of the engine remains operational. Plugin nodes are executed through IPluginHost with a timeout wrapper.
  3. Discoverability: The NodeRegistry automatically picks up all INode implementations from loaded plugin assemblies, making them available in the Designer immediately.
  4. Hot Unload: Because load contexts are collectible, you can uninstall a plugin at runtime. The GC reclaims the memory once all references are released.

Exploring Real Examples

If you look into our ./plugins/ directory, you can see this in action:

  • Vyshyvanka.Plugin.AdvancedHttp — HTTP retry with exponential backoff, HTTP polling with success/failure conditions, batch requests with concurrency control, and GraphQL.
  • Vyshyvanka.Plugin.GitLab — Full GitLab integration: issues, merge requests, pipelines, releases, files, tags, and webhook triggers.
  • Vyshyvanka.Plugin.Jira — Jira issues, comments, search (JQL), users, and versions.
  • Vyshyvanka.Plugin.Tmplt — A starter template for building your own plugin.

Getting Started with Your Own Plugin

  1. Create a new class library targeting the same .NET version.
  2. Add a reference to Vyshyvanka.Core (the only allowed dependency from core).
  3. Add your PluginInfo.cs with [assembly: Plugin(...)].
  4. Create node classes inheriting BasePluginNode (or implement INode directly).
  5. Decorate with [NodeDefinition] and [ConfigurationProperty] attributes.
  6. Build, and either drop the DLL into the plugins directory or publish as a NuGet package.

Building for extensibility ensures that as your business needs evolve, your automation platform evolves with you.

In the next part, we will discuss Part 11: Testing Strategies - How to guarantee your workflows are rock-solid. Stay tuned!


Check out the project source code here: https://github.com/homolibere/Vyshyvanka

Top comments (0)