Plugin Development Guide
Complete guide to creating, installing, and configuring plugins for BlazorBlueprint
Overview
BlazorBlueprint uses a modular plugin system that allows you to extend functionality without modifying core code. Plugins are self-contained, independently versioned components.
⚠️ Plugins are fully-trusted, in-process code. A plugin is loaded into the host's own
assembly context with unrestricted DI access — there is no sandbox, no signing requirement and no capability
enforcement. It can inject the platform repository, reach every tenant's data, read platform secrets, and run
arbitrary code. The per-plugin assembly contexts isolate dependencies, not execution, and the
Capabilities list on a manifest is an honest declaration for review, not a boundary.
That is the intended trade-off for an operator-curated deployment: you control which DLLs are present and you read the source before shipping them. It is not safe for a marketplace or customer-uploaded plugins — that needs process or container isolation you would have to add. The gating layers (appsettings, platform policy, per-org install, entitlements) are availability controls, not a security boundary.
IPlugin Interface
Every plugin implements IPlugin, which asks for two things: a manifest describing the plugin, and a method that puts its services in the container. Everything else — pages, seed data, sweeps, pipeline wiring — is a separate interface you implement only if your plugin does that thing.
Interface Definition
The interface is deliberately slim. All identity and metadata lives on the Manifest record rather than as separate interface properties, and the optional hooks live on separate interfaces rather than as default implementations here — so a plugin's class declaration tells you what it does before you read a line of its body.
// ============================================================================
// REQUIRED — every plugin implements this, and this is all of it
// ============================================================================
public interface IPlugin
{
// All identity + metadata (id, name, version, icon, capabilities, ...).
// This is the single place an author describes the plugin.
PluginManifest Manifest { get; }
// Convenience shortcut for Manifest.Id (the host keys/routes plugins by this).
string Id => Manifest.Id;
// Convenience shortcut for Manifest.Slug — the id's last dot-segment, lower-cased
// (the host keys /plugin/{slug} URLs, the launcher, nav and gating by this).
string Slug => Manifest.Slug;
// Register the plugin's services into DI. Runs in every plugin-aware host —
// Web, InternalApi and BackgroundWorker. Repositories, application services, settings
// binding, and DI-resolved contribution providers (IPluginSectionNavProvider,
// IChromeProvider, IPublicRouteAllowList) go here.
// Example: services.AddScoped<IMyService, MyService>();
Task RegisterServicesAsync(IServiceCollection services, IConfiguration configuration);
}
// ============================================================================
// OPT-IN — implement one alongside IPlugin only if your plugin does that thing.
// The host probes with `is IPluginXxx` and skips a plugin that does not.
// ============================================================================
// The plugin's own pages and widgets.
public interface IPluginUiContributor
{
// Pages in the org-admin area, under /admin/plugin/{slug}/… (admin nav Plugins group).
IEnumerable<PluginAdminPage> GetAdminPages() => [];
// Pages in the platform-admin area, under /platform/plugin/{slug}/….
IEnumerable<PluginAdminPage> GetPlatformPages() => [];
// Components rendered into MainLayout slots: Header (sticky-friendly, top of the
// page-content scroll), Footer (below the page body), or Floating (outside the
// layout — for position:fixed overlays like a floating action button).
IEnumerable<LayoutComponentRegistration> GetLayoutComponents(IServiceProvider? services = null) => [];
// Route metadata for gating + launcher tile role resolution. Actual page routing
// is driven by each component's @page directive — this is declaration only.
IEnumerable<PluginRoute> GetRoutes() => [];
}
// Data the plugin needs seeded before it can be used. Both are idempotent: the
// platform seed runs at every boot, and the per-org setup runs on install too.
public interface IPluginProvisioner
{
// Seed the plugin's PLATFORM-WIDE defaults (e.g. its single platform ApiModel).
Task SeedPlatformDefaultsAsync(IServiceProvider services, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
// Set the plugin up FOR ONE organisation — per-org seed data, role definitions,
// its settings document. Runs when a new org is provisioned AND when an admin
// installs the plugin into an existing org (hence "for", not "new").
Task<bool> SetupForOrganisationAsync(Organisation organisation, string? adminUserId = null, IServiceProvider? services = null)
=> Task.FromResult(true);
}
// Recurring sweeps. Called in the host that runs them — the BackgroundWorker under
// Full, the Web host under Lite — so AddHostedService here cannot double-fire.
public interface IPluginBackgroundServices
{
Task RegisterBackgroundServicesAsync(IServiceCollection services, IConfiguration configuration);
}
// Once-per-process work after the host starts. Rare, and it must be CHEAP —
// anything per-tenant belongs in SetupForOrganisationAsync or a sweep.
public interface IPluginStartup
{
Task OnStartupAsync(IServiceProvider services, CancellationToken cancellationToken = default);
}
// Web request pipeline — middleware, SignalR hubs, endpoints. Web host only, and it
// lives in BlazorBlueprint.Web.Shared because it takes WebApplication.
// Example: app.MapHub<MyHub>("/hubs/plugin/myplugin");
//
// Your hub class MUST declare which tenant its connections act for, or the host
// refuses it at connect:
//
// [HubOrganisationScoped] // one org, members only
// [HubOrganisationScoped(AllowAnonymous = true)] // one org, public widget
// [HubPlatformScoped] // deployment-wide, no tenant
//
// The host then resolves the organisation once, verifies membership, and sets the
// tenant on every invocation — so IRepository<T> works inside a hub method.
// Never take an organisationId as a hub method parameter; a client can forge it.
// Read HubConnectionTenant.OrganisationId(Context) when you need the id itself.
public interface IWebPluginConfigurator
{
Task ConfigureWebAppAsync(WebApplication app);
}Why separate interfaces rather than one interface of optional methods.
Nine optional hooks had accumulated on IPlugin as default implementations, one
at a time, each individually harmless — which is exactly why nothing stopped them. A default
is free to add and free to ignore, and those are not the same thing: a plugin's class
declaration stopped saying what the plugin did, and every author ended up writing stubs to
say "nothing here". Ten of the eleven implementations of the startup hook were an empty
Task.CompletedTask, most with a comment explaining the emptiness — and nothing
told them apart from the one that was real. The type header answers it now.
Note that the members within IPluginUiContributor and
IPluginProvisioner still have defaults, and that is the line being drawn: those
are facets of one concern — admin pages but no platform pages is normal — rather than
separate concerns. Four honest interfaces beat six one-member ones.
A plugin that implements none of them is a complete plugin, not a degraded one: it still gets its launcher tile (the dashboard route is generated from the slug), it is still installable per organisation, and every host aggregation simply skips it.
Settings classes declare themselves. A class the plugin stores under its own
key in the OrganisationPlugins / PlatformPluginInstall dictionaries
is marked with [PluginSettings], and the active backend registers whatever it
needs — so the plugin still carries no database-driver reference:
[PluginSettings]
public sealed class MyPluginSettings
{
public string SomeOption { get; set; } = "";
}
It replaced a GetPolymorphicSettingsTypes() list, and the reason is worth knowing
because it generalises: a list kept in a different file from the types it names can drift in
exactly one direction, and does it silently. Add a second settings class, forget to add it to
the list, and on MongoDB it falls back to a BsonDocument at read time — nothing
fails at build, nothing fails at boot, and what you see is settings appearing to revert on
save. Declared on the type, there is only one place to write it.
All identity / presentation / declared-capability fields live on the PluginManifest record. Because it's a record with sensible defaults, new fields can be added over time without breaking any existing plugin:
public sealed record PluginManifest
{
public required string Id { get; init; } // "BlazorBlueprint.Plugins.MyPlugin" — keyed/routed by the last dot-segment
public required string Name { get; init; } // display name shown on tiles, nav, plugin management
public string Version { get; init; } = "1.0.0";
public string Description { get; init; } = "";
public string Author { get; init; } = "";
public string Category { get; init; } = "General"; // grouping label, e.g. "Marketing"
// FluentUI icon name (e.g. "Briefcase", "Star") for the launcher tile / nav heading.
// Unknown/blank falls back to a generic puzzle-piece glyph.
public string Icon { get; init; } = "PuzzlePiece";
public bool IsPremium { get; init; } // gated behind a subscription plan when billing is on
public PluginScope Scope { get; init; } = PluginScope.Tenant; // all orgs vs. platform org only
// Host capabilities this plugin uses — an HONEST DECLARATION for operator review,
// NOT an enforced sandbox (plugins are full-trust in-process code). List exactly
// what the plugin touches.
public IReadOnlyList<PluginCapability> Capabilities { get; init; } = [];
// Ids of other plugins this one needs loaded + enabled.
public IReadOnlyList<string> Dependencies { get; init; } = [];
}
public enum PluginCapability
{
TenantData, // reads/writes the current org's data via IRepository<T>
PlatformData, // reads the platform DB / other tenants via IPlatformRepository<T> — review carefully
BackgroundJobs, // registers recurring IHostedService jobs in the worker
Payments, // takes payments through the host IPaymentService
Sms, // sends SMS through the host ISmsService
InboundEmail, // receives inbound email (e.g. a Mailgun inbound-route handler)
OutboundWebhooks, // delivers outbound webhooks to third parties
Ai, // calls AI models through the host IApiRequestDispatcher pipeline
FileStorage, // stores/serves files through the host IFileStorage
PublicRoutes, // serves anonymous public routes (declared via IPublicRouteAllowList)
ChromeSuppression, // can suppress the platform header/nav (via IChromeProvider)
}💡 Tip: There is no base class to inherit — the default interface implementations are the base class. A plugin that only registers services is two members long, and you add hooks as you need them.
Lite and Full: which process runs what
The same plugin assembly is loaded by several hosts, and three of the lifecycle hooks answer different questions about where. Getting this wrong does not fail a build — it produces work that runs twice, or an endpoint that exists on one deployment shape and not the other.
Four rules
1. Services run everywhere. RegisterServicesAsync is
called by every plugin-aware host — Web, InternalApi and BackgroundWorker — on both
topologies. Anything a plugin needs in more than one process is registered there.
2. Sweeps run in exactly one process, and it is not always the worker.
IPluginBackgroundServices.RegisterBackgroundServicesAsync is called by
the BackgroundWorker under Full, and by the Web host under Lite, where there
is no worker container — the switch is
BackgroundServices:RunInProcess. Registering hosted services anywhere
else is how a sweep fires twice.
3. REST controllers run in exactly one process too, on a different switch.
The InternalApi serves them under Full — that host exists to take work off the front
end and sit behind the firewall — and the Web host serves them under Lite. The switch
is Services:InternalApi:Enabled. It is deliberately NOT the same key as
rule 2: "where do sweeps run" and "which host serves HTTP" are separate questions,
and a deployment may one day want them apart.
4. A plugin must never branch on the topology itself. No
if (lite), no injecting IConnectionMultiplexer (Lite has no
Redis), no assuming the InternalApi is reachable. Write the plugin once and let the
host decide; where a plugin genuinely needs the answer — a typed client's base
address, say — read it through PluginApiHosting rather than inventing a
second reader of the flag.
⚠️ The failure mode is a customer's first docker compose up.
A plugin that reaches for something only Full has still builds, still passes its
tests and still runs on the developer machine that has Redis and the API
container. It breaks on Lite, in front of the buyer, and its author never sees
it. The example plugin is written to demonstrate exactly this restraint — read
WeatherPlugin for what it deliberately does not do.
Creating Your First Plugin
Step 1: Create Plugin Project
# Create new Razor Class Library project
dotnet new razorclasslib -n BlazorBlueprint.Plugins.MyPlugin
cd BlazorBlueprint.Plugins.MyPlugin
# Add project references
dotnet add reference ..\..\Core\BlazorBlueprint.Application\BlazorBlueprint.Application.csproj
dotnet add reference ..\..\Core\BlazorBlueprint.Domain\BlazorBlueprint.Domain.csproj
# Only if your pages use the shared UI components (console styles, toasts, breadcrumbs)
dotnet add reference ..\..\Web\BlazorBlueprint.Web.Shared\BlazorBlueprint.Web.Shared.csproj⚠️ Two traps that fail silently, not loudly. Shared Razor components live in
BlazorBlueprint.Web.Shared, never in the host Web project — a plugin cannot reference the
host. And every component namespace your pages use has to be in the plugin's own
_Imports.razor.
Miss either one and the build succeeds: the compiler emits an RZ10012 warning and
renders the unresolved component as inert literal HTML, so the page loads completely unstyled instead of
erroring. If a plugin page renders as raw markup, grep the build output for
RZ10012 first.
Step 1b: Declare the assembly as a plugin
One line in the plugin's .csproj. The id it carries is what prefixes the
plugin's tables and collections, so it is persisted state — pick it once and do not change
it later, because renaming it renames nothing that already exists and the plugin simply
starts reading empty tables.
<ItemGroup>
<AssemblyAttribute Include="BlazorBlueprint.Domain.Attributes.PluginAssemblyAttribute">
<_Parameter1>myplugin</_Parameter1>
</AssemblyAttribute>
</ItemGroup>There is no naming convention: an assembly is a plugin only if it carries this attribute. Nothing is inferred from what it is called, so name your assembly whatever suits you.
The id must equal the last dot-segment of your PluginManifest.Id,
lower-cased — a manifest id of Acme.Plugins.Billing means
billing. The host checks that at boot and refuses to start if the two
disagree, because they are compared against each other on a real path: this id prefixes
the plugin's tables and collections, the manifest slug is what routes and gates it, and
the tenant schema gate matches one against the other. A mismatch makes that gate silently
stop firing for your plugin.
⚠️ The host refuses to boot on a plugin that declares neither, and says
which line to add. That is deliberately louder than what it replaced: such a plugin used
to load perfectly — services, routes, admin pages — while everything that
persists its data skipped it. On a relational backend its entities never
reached the model, so nothing was created. On MongoDB, which has no schema to gate it,
the collection name fell through to the bare type name: an entity called
Payment wrote into the host's collection, and two plugins that
both defined Invoice shared one. Silently, on a running system.
Step 2: Implement the Plugin Class
Implement IPlugin, then add whichever opt-in interfaces your plugin actually needs. The one below has pages, so it takes IPluginUiContributor; it has no seed data, no sweeps and no pipeline wiring, so it says nothing about those.
// Import necessary namespaces
using BlazorBlueprint.Application.Interfaces; // IPlugin, PluginManifest, PluginCapability
using BlazorBlueprint.Domain.Constants; // ApplicationRoleConstants for authorization
using Microsoft.Extensions.DependencyInjection; // IServiceCollection for DI
using Microsoft.Extensions.Configuration; // IConfiguration for settings
namespace BlazorBlueprint.Plugins.MyPlugin;
/// <summary>
/// Main plugin class. IPlugin is required; IPluginUiContributor is here because this
/// plugin has pages of its own. Read the type header as the table of contents.
/// </summary>
public class MyPlugin : IPlugin, IPluginUiContributor
{
// ========================================================================
// MANIFEST (REQUIRED) - all identity + metadata lives here
// ========================================================================
/// <summary>
/// Everything the plugin says about itself: id, display name, version, icon,
/// and the host capabilities it touches (declared for operator review).
/// </summary>
public PluginManifest Manifest { get; } = new()
{
// Unique id. Convention: "BlazorBlueprint.Plugins.{PluginName}".
// The host keys/gates/routes the plugin by the LAST dot-segment
// ("myplugin" here → /plugin/myplugin). Keep it unique.
Id = "BlazorBlueprint.Plugins.MyPlugin",
// Display name shown on launcher tiles, nav, and plugin management.
Name = "My Plugin",
Version = "1.0.0",
Description = "A sample plugin that demonstrates basic functionality",
Author = "Your Name",
Category = "Utility", // grouping label in the UI
// FluentUI icon name for the launcher tile / section-nav heading.
// Unknown/blank falls back to a generic puzzle-piece glyph.
Icon = "Settings",
IsPremium = false, // true = gated behind a subscription plan when billing is on
// Honest declaration of what this plugin touches, for operator review
// (NOT an enforced sandbox). List exactly what you actually use.
Capabilities = [PluginCapability.TenantData],
};
// ========================================================================
// REGISTER SERVICES (REQUIRED METHOD)
// ========================================================================
/// <summary>
/// Registers services your plugin needs in dependency injection.
/// Runs in EVERY host (Web, InternalApi, BackgroundWorker).
/// Services registered here can be injected into your pages and components.
/// </summary>
public async Task RegisterServicesAsync(IServiceCollection services, IConfiguration configuration)
{
// Register your plugin's services here.
// AddScoped means a new instance is created for each HTTP request.
// Other options: AddSingleton (one instance for entire app), AddTransient (new instance every time)
services.AddScoped<IMyService, MyService>();
// Cross-cutting host contributions are registered as DI providers here too,
// so the host discovers them without referencing your plugin. The in-plugin
// section nav (the sidebar on /plugin/myplugin/*) is one — see below.
services.AddSingleton<IPluginSectionNavProvider, MyPluginSectionNavProvider>();
// You can also register:
// - Background services: add IPluginBackgroundServices to the class declaration
// - Options/configuration: services.Configure<MyOptions>(options => { ... });
// - Repositories, other services, etc.
await Task.CompletedTask; // Required for async method
}
// ========================================================================
// AUTHORIZING YOUR PAGES — use the host's attributes, not a hand-written role list
// ========================================================================
//
// @attribute [AuthorizePluginAdmin(MyPluginRoles.Admin)] admin page
// @attribute [AuthorizePluginUser(MyPluginRoles.Admin, ApplicationRoleConstants.MEMBER)]
// @attribute [AuthorizePlatformAdmin] /platform/plugin/* page
// @attribute [AuthorizeOrganisationMember] any active member
// PluginRoles.IsModerator(user, MyPluginRoles.Admin) a check in code
//
// Declare ONE role of your own (MyPluginRoles.Admin) and compose nothing. The two
// administrator tiers — ORGANISATIONADMIN and PLATFORMADMIN — are added by the host.
//
// Why this matters: PluginOrganisationSetupBase grants your admin role to whoever
// INSTALLED the plugin and to nobody else. An organisation admin added later holds
// none of it, and a platform admin inside an organisation they are not a member of
// holds neither it nor ORGANISATIONADMIN. So a page written as
// [Authorize(Roles = MYPLUGINADMIN)] compiles, boots, runs — and refuses the two
// administrators who are most likely to open it, with nothing anywhere reporting a
// fault. The symptom is a card in the settings hub over a page that refuses the
// person who clicked it.
//
// [Authorize(Roles = ...)] needs a compile-time constant, which is why the rule cannot
// be a shared method call at the call site. These attributes do the composition in
// their constructors instead, so your page still passes a const.
// ========================================================================
// ROUTES (OPTIONAL METHOD) - metadata for gating + tile role resolution
// ========================================================================
/// <summary>
/// Declares the routes (pages) your plugin provides. This is METADATA only
/// (gating + launcher tile role resolution) — actual page routing is driven by
/// each component's @page directive. From IPluginUiContributor.
/// </summary>
public IEnumerable<PluginRoute> GetRoutes()
{
// 1. Main plugin landing page (the "app dashboard")
// This is where users go when they click the plugin tile in /apps.
// Plugin's main entry — renders under MainLayout with the host's
// PluginSectionNav sidebar (replaces the platform NavMenu while inside the
// plugin). Settings + admin-only pages live under /admin/plugin/{pluginname}/*.
yield return new PluginRoute
{
Route = "/plugin/myplugin", // Canonical dashboard URL
ComponentType = typeof(Web.Pages.Admin.Index), // The Razor component to render
RequiresAuthentication = true, // Must be logged in
// Who should SEE the launcher tile — NOT who may open the page (that is the
// @attribute on the component). DeterminePluginRequiredRole unions the
// RequiredRole of every route and admin page, so naming only an admin tier here
// makes the tile invisible to ordinary members of every organisation.
// UserRolesFor adds the two administrator tiers for you; never write the bare
// role name, or the two administrators who do not hold it are excluded.
RequiredRole = PluginRoles.UserRolesFor(
MyPluginRoles.Admin, ApplicationRoleConstants.MEMBER)
};
// 2. Public route - accessible to everyone
// Route: /plugin/myplugin/mypage
yield return new PluginRoute
{
Path = "/plugin/myplugin/mypage",
ComponentType = typeof(Web.Pages.MyPage),
RequiresAuthentication = false
};
// 3. Admin route - settings page
// Only ORGANISATIONADMIN users can access this.
// Route: /admin/plugin/myplugin/settings — renders under AdminLayout
// because it has a "deeper" path segment after the plugin name.
yield return new PluginRoute
{
Path = "/admin/plugin/myplugin/settings",
ComponentType = typeof(Web.Pages.Admin.Settings),
RequiresAuthentication = true,
RequiredRole = ApplicationRoleConstants.ORGANISATIONADMIN
};
}
// ========================================================================
// ADMIN PAGES (OPTIONAL METHOD)
// ========================================================================
/// <summary>
/// Defines admin pages that appear in the admin nav's Plugins group. From
/// IPluginUiContributor — drop the interface entirely if you have none.
/// </summary>
public IEnumerable<PluginAdminPage> GetAdminPages()
{
// These admin pages appear in the admin nav's Plugins group, and the host also
// builds a settings hub at the bare /admin/plugin/myplugin from this same list.
// Only list genuinely admin / configuration pages here — user-facing dashboard
// pages live at /plugin/myplugin/* and are reached via the host PluginSectionNav.
yield return new PluginAdminPage
{
Title = "Settings", // Display name
Route = "/admin/plugin/myplugin/settings", // Write the real route out
Icon = "Settings", // FluentUI icon name
RequiredRole = ApplicationRoleConstants.ORGANISATIONADMIN,
Order = 100 // Lower = appears first
};
}
}💡 Route conventions. Paths follow one rule: user-facing pages live under /plugin/{pluginId}/… and admin pages under /admin/plugin/{pluginId}/…, where {pluginId} is the last dotted segment of your plugin id, lower-cased. Write the paths out literally — seeing the real route in the declaration is worth more than a helper that hides it.
Section navigation (the sidebar on /plugin/{name}/*)
The left sidebar shown while a user is inside your plugin is contributed through a DI extension point — IPluginSectionNavProvider — not a method on IPlugin. You register one provider per plugin in RegisterServicesAsync (see the registration line above); the host discovers it by slug and renders it in place of the platform NavMenu, with no per-plugin knowledge of its own.
using BlazorBlueprint.Application.Interfaces;
namespace BlazorBlueprint.Plugins.MyPlugin;
/// <summary>
/// Supplies the rows for this plugin's in-plugin section nav. The host can't
/// reference the plugin assembly, so it discovers this provider via DI and matches
/// it by PluginSlug. This nav is static (no tenant data), so a cached list is fine;
/// a per-org nav can query inside GetItemsAsync instead.
/// </summary>
public sealed class MyPluginSectionNavProvider : IPluginSectionNavProvider
{
private static readonly IReadOnlyList<PluginSectionNavItem> Items = new List<PluginSectionNavItem>
{
// PluginSectionNavItem(Label, Href, Icon, MatchAll = false, RequiredRole = null, Section = null)
// Icons are FluentUI Size20 names. MatchAll = exact-match highlighting (use it
// on the dashboard row so it isn't lit on every nested route).
new("Dashboard", "/plugin/myplugin", "Home", MatchAll: true),
new("My page", "/plugin/myplugin/mypage", "DocumentText"),
};
// Plugin slug as it appears in /plugin/{slug}/* (lower-case).
public string PluginSlug => "myplugin";
// Heading shown above the section nav (null = host falls back to the slug).
public string? Title => "My Plugin";
// Runs when the sidebar renders for the matching slug — keep it cheap.
public Task<IReadOnlyList<PluginSectionNavItem>> GetItemsAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(Items);
}💡 No host edit needed: a new plugin's sidebar appears simply by registering its IPluginSectionNavProvider — the same extension-point pattern as IPublicRouteAllowList and IChromeProvider.
Taking over the chrome? Mind the footer. An IChromeProvider returning
Suppress hides the platform header and nav, but not the platform footer — that is
the separate SuppliesFooterAsync, which defaults to false. Leave it alone and
your page keeps the host footer, with the org's copyright, legal-policy links and cookie-settings
control intact. Return true only when your plugin ships a footer of its own through
LayoutComponentSlot.Footer, or the page renders two.
If you do ship one, it takes on those obligations: render the org's effective legal links (the shared
LegalPolicyLinks.GetAsync resolves and caches them, so your footer and the host's can't
disagree) and a cookie-settings control when consent is enabled. Public pages are where those links
legally have to be.
Step 3: Add to Solution
A plugin lives at Plugins/{Name}/{AssemblyName}/ — the same shape as the
drop-in layout at run time, so there is one path to remember rather than two:
# Add plugin to solution — the path Plugins.props will reference in the next step
dotnet sln add Plugins/MyPlugin/BlazorBlueprint.Plugins.MyPlugin/BlazorBlueprint.Plugins.MyPlugin.csprojStep 4: Add it to the plugin list
One line in Plugins/Plugins.props — not in a host's
.csproj:
<ItemGroup>
<ProjectReference Include="$(MSBuildThisFileDirectory)MyPlugin\BlazorBlueprint.Plugins.MyPlugin\BlazorBlueprint.Plugins.MyPlugin.csproj" />
</ItemGroup>One list, four hosts. A first-party plugin has to be referenced by the Web host,
the InternalApi, the BackgroundWorker and the DbMigrator — and three of those four
omissions fail silently: its controllers 404 under Full, its sweeps never run, and its tables
reach no migration, so the tenant schema guard reports the plugin unavailable for every
organisation on a relational backend. Plugins.props is imported by all four, so
there is no fourth place to forget.
Installation & Configuration
Method 1: Project or package reference (the full-capability path)
Add the plugin to Plugins/Plugins.props, the one list every host imports:
<ItemGroup>
<ProjectReference Include="$(MSBuildThisFileDirectory)MyPlugin\MyPlugin.csproj" />
</ItemGroup>
A PackageReference to a plugin published as a NuGet package works the same way
for the assembly - it lands flat in the host's output either way, which is the only
thing discovery cares about. Both give you what the drop-in folder cannot: static web assets,
dependency conflicts caught at build time rather than at runtime, and a working
debugger.
A package has to ship its settings file deliberately.
CopyToOutputDirectory is a build detail of the packed project and does
not travel in a package, so a plugin packed without the rules below gives a
consumer the DLL and silently no defaults - it loads, boots, registers and runs on its
C# property initialisers, with nothing erroring and nothing logged.
The template provides the fix; a plugin built in this tree gets it automatically. The
*.plugin.json is packed to buildTransitive/ alongside
Plugins/build/PluginPackage.targets, renamed to
buildTransitive/{PackageId}.targets - NuGet auto-imports that exact path and
nothing else - which re-adds the file to the consuming host's build with
Link set, so it lands at the output root where the scan looks rather than
under a reproduction of its NuGet-cache path. The host's own project references also
carry PrivateAssets="all", so they do not become package dependencies a
consumer would go hunting for on a feed.
You do not add the reference again per host. Each host discovers plugins
independently, so a plugin referenced by the web host alone would silently run no background
work, serve no API under Full, and contribute no tables to the committed migrations — which is
exactly why the reference lives in one shared file rather than in four .csproj
files.
Method 2: Folder-based drop-in (adds a plugin without rebuilding the host)
For production or distribution, place plugin assemblies in the application's Plugins/ folder (next to the running Web app).
When running from source, the loader also attempts to find Web/BlazorBlueprint.Web/Plugins/.
Each plugin must live in its own sub-folder (the loader scans for Plugins/<PluginName>/*.dll, top-level DLLs only):
Web/BlazorBlueprint.Web/
└── Plugins/
└── MyPlugin/
├── MyPlugin.dll
├── MyPlugin.deps.json
└── [dependencies]
Every DLL in one sub-folder shares a single AssemblyLoadContext, so a plugin can sit
beside its own private dependencies. A resolving handler probes that folder only after normal
resolution fails, which keeps host-shared assemblies — IPlugin among them — unified
through the default context. It is not version isolation: anything the host
already has resolves from the host, so a plugin built against a different version of a shared
package gets the host's.
⚠️ The sub-folder is mandatory. The loader enumerates directories,
so a DLL dropped loose into Plugins/ is never looked at — silently, with no
error and no log line. It is the first thing most people try.
Plugins are discovered once, at startup — there is no rescan and no hot-add.
A DLL that appears later is invisible until you restart, and that is deliberate rather than a
limitation: registration means RegisterServicesAsync,
ConfigureWebAppAsync and DI wiring, none of which can be replayed against a
container that is already built. A late registration could only ever produce a plugin that
shows in the launcher and fails when opened.
A drop-in plugin gets no static web assets. A plugin's
wwwroot is served at /_content/{Assembly}/ through a manifest
built when the host is compiled, so an assembly that was not referenced at
build time has no entry in it. Its CSS and JS return 404 and its pages render
unstyled, with no error anywhere. A plugin shipping a wwwroot needs
Method 1.
Copy the plugin DLL and its private dependencies - not a whole
dotnet publish output. A publish output carries the plugin's entire
dependency closure, host assemblies included. Anything the host already ships is skipped
on load - it has to resolve from the host, or the process ends up holding two copies of
the same assembly and the plugin's IPlugin stops being the host's - so
copying them achieves nothing but a bigger folder.
On PostgreSQL or SQL Server the plugin's tables do not exist yet, and a
plugin does not arrive as a migration - so the tenant is gated rather than served a plugin
that would fail on its first query. Set Database:AllowPluginSchemaDropIn so
the guard creates the tables on the first request, or run the DbMigrator.
MongoDB needs neither.
In Docker this needs a bind mount. The shipped compose files run the web
container with read_only: true and mount nothing at
/app/Plugins, so there is nowhere to put a DLL and nothing would survive the
next compose up. Add a read-only mount to the web service - and to the
background worker, if the plugin has sweeps:
volumes:
- ./plugins:/app/Plugins:ro
The mount is read-only, so read_only: true on the container stays intact -
the operator writes on the host, beside the compose file. Treat write access to that
directory as equivalent to write access to the application itself: plugins are
fully-trusted in-process code.
Dependency versions: the host's copy always wins
A plugin and the host share one process, and for anything the host ships there is exactly
one copy of the assembly - the host's. That is deliberate. The alternative, two
same-named assemblies in different load contexts, means two different CLR types with one
name: casts fail with the memorable Unable to cast object of type 'X' to type
'X', and any static state duplicates along with them.
Three rules follow for a plugin author:
-
Reference the host's own assemblies with
ExcludeAssets="runtime", so they are never copied into the plugin's output at all:<ProjectReference Include="..\..\Core\BlazorBlueprint.Application\BlazorBlueprint.Application.csproj" ExcludeAssets="runtime" /> -
Do not take a dependency on a different major version of anything the
host ships. You will get the host's, and an incompatible API surfaces as a
MissingMethodExceptionat the point of use rather than at build time. -
Treat the host's
Directory.Packages.propsas the shared version contract. On the reference path NuGet resolves the graph for you and a genuine conflict fails the build (NU1605); on the drop-in path there is no build to catch it.
The host checks the last of those for you, in two halves. A plugin may
declare Manifest.MinHostVersion, which PluginHostVersionRule
enforces - but that is an explicit statement and only as good as the author's memory. So
PluginContractVersionRule also reads the highest version of any
BlazorBlueprint.* assembly the plugin was actually compiled against,
straight out of its own reference table, and refuses to load it on an older host. That needs
no cooperation, so it covers a third-party package and an author who forgot to raise their
declared minimum.
Both skip the plugin rather than failing the boot: a plugin ahead of its host is a known state with an obvious fix, unlike an identity mismatch, which is silent and corrupting and is the one thing the host refuses to start on. Neither answers "too old" - the host is additive, and refusing an older build would mean rebuilding every plugin for every host patch release.
Installing a plugin is not the same as upgrading one
Installing works on every backend with no extra step. Upgrading — shipping a v2 that changes the plugin's own schema — always needs the DbMigrator or a rebuild, and nothing stops you deploying without it. That asymmetry is the sharpest edge in the plugin model, so it is worth knowing before you meet it.
| Backend | Installing | Upgrading a changed schema |
|---|---|---|
| MongoDB | Nothing. Schemaless — collections appear on write, and the plugin's indexes are created when it is installed for an organisation | A new index is not created for organisations that already had v1 until the DbMigrator's reconcile runs. Until then a new unique index is not enforcing anything — quiet, not an error |
| PostgreSQL / SQL Server | Regenerate migrations and deploy, or set Database:AllowPluginSchemaDropIn and the schema guard creates the tables on first request |
A new column needs a rebuild and regenerated migrations — the additive pass creates but cannot ALTER. The guard detects a missing column and gates, so that one fails loudly rather than at query time. A new index is not detected |
The practical rule: run the DbMigrator whenever a plugin's version changes, not only when the host's schema does. It is idempotent, it exits non-zero if any tenant fails so a deploy gate can block on it, and on MongoDB it is the only thing that reconciles indexes for organisations that already had the plugin.
No backend validates indexes at run time, and that is a decision rather than an oversight — it applies to all three, not just Mongo. Only a missing unique index is a correctness problem; a missing ordinary one is merely slow. Index naming is generated and provider-specific, and this codebase deliberately diverges (GIN array indexes on Postgres, skipped entirely on SQL Server), so matching them is fragile — and a false positive would take a healthy organisation offline, which is worse than the gap.
Per-plugin flags
The host reads four independent flags per plugin, each keyed under
Plugins:{full plugin id}. All four default to false in code, so a
plugin that ships no configuration of its own is loaded and visible but installed by hand, per
organisation. The last two override the plugin's own manifest (Scope and
IsPremium).
A code default is not the same as what a DLL actually does. Since a plugin
ships its own {slug}.plugin.json (below), dropping a plugin folder in can set any
of these — the example plugin ships AutoInstall: true and so installs itself into
every organisation, which is exactly what the code default alone would not have done. Read the
plugin's own file, not this table, to know what a given plugin does on first boot; the flags
are still yours to override in a host's appsettings.json, which beats anything the
plugin ships.
A plugin ships its own defaults — you do not have to write this block at all.
Put a {slug}.plugin.json in the plugin's project, marked
CopyToOutputDirectory, and every host that loads the plugin picks it up. That
keeps a plugin's configuration in the plugin, instead of requiring a block in all three
hosts' appsettings.json just to change one cadence.
Precedence: plugin file → host appsettings.json →
appsettings.{Environment}.json → user-secrets → environment variables →
command line, each beating the one before. The plugin's file is inserted at the
front of the configuration sources precisely so it is a default and never an
override — a deployment can always change anything a plugin ships.
Use it for values a deployment would reasonably tune: sweep cadences, retention windows,
limits, toggles. Do not put a policy the plugin is unsafe without in
there — a JSON file can be missed by a publish and the failure is silent. That is why a
non-idempotent provider's RetryCount: 0 stays a code default passed to
AddApiClient, where it cannot go missing.
The shape is identical wherever you write it — the plugin's own file, or a host's
appsettings.json to override it:
"Plugins": {
"BlazorBlueprint.Plugins.MyPlugin": {
"AutoInstall": true, // run SetupForOrganisationAsync for every new org created via
// initial-setup or /Org/Select. false = the plugin is loaded
// and visible in /Admin/PluginManagement, but org admins must
// click Install manually per org.
"Disabled": false, // emergency kill switch. true = RegisterServicesAsync is
// skipped, ConfigureWebAppAsync never runs, services are not in
// DI. Existing orgs' OrganisationPlugins blob is preserved
// but functionally inert. Disable a misbehaving plugin via
// config + restart, no redeploy required.
"PlatformOnly": false, // true = surfaces only on the platform organisation: hidden from
// every tenant org's plugin management, launcher and nav, never
// auto-installed into a tenant, public routes 404 on tenant hosts.
// Overrides the manifest's Scope.
"RequiresSubscription": false // true = premium app, gated behind a subscription plan (only
// enforced when platform billing is on). Overrides the manifest's
// IsPremium; an org needs an entitled plan to open it.
}
}| Configuration | Auto-install on new orgs | Visible in PluginManagement | Manual install button |
|---|---|---|---|
| DLL present, no appsettings entry | No | Yes | Yes |
"AutoInstall": true |
Yes | Yes | Yes (for orgs that pre-existed) |
"Disabled": true |
No | No | No |
Per-organisation install (PluginManagement admin)
- Navigate to Plugin Management
- Plugins listed under "Available" have not yet been installed for the current organisation. Plugins under "Installed" are already active.
- Click Install on an Available plugin to run its
SetupForOrganisationAsyncfor the current org — the plugin's per-org settings, role definitions, and any seeded entities are created. - Plugins flagged
"AutoInstall": trueappear under "Installed" automatically for orgs created after the flag was set; existing pre-flag orgs still need a manual Install click here. - The Disable button on an installed plugin removes the per-org entry from
OrganisationPluginsbut keeps the plugin globally loaded. Use the appsettings"Disabled": trueflag for a global kill switch.
Platform plugin policy — the layer above appsettings
/Platform/PluginManagement carries two database-backed flags per plugin that
override the appsettings values above, so an operator can change them at runtime without a
redeploy:
- Available — a global soft off switch. When off, the plugin disappears from every organisation's launcher and nav, and its
/plugin/{name}/*and/admin/plugin/{name}/*routes return 404 — for every org, including the platform one. Per-org install records and data are preserved untouched. - AutoInstall — the same meaning as the appsettings key, resolved at runtime.
PlatformOnly and RequiresSubscription also appear there, read-only — those are
resolved from appsettings and the manifest.
So there are three layers, and precedence runs database → appsettings → manifest default.
Loaded (the DLL is present and not Disabled — a process-level kill that always wins) → platform
policy (available / auto-install) → per-organisation install. If a plugin has vanished from one org's
launcher, check per-org install; if it has vanished from every org, check Available
before you go looking at config.
Best Practices
1. Plugin Isolation
- Don't modify core code - plugins should be self-contained
- Use interfaces for communication with core services
- Store all settings in the org's
OrganisationPluginsdocument (tenant database, keyed by plugin Id) — derive your settings service fromPluginSettingsServiceBase<TSettings>, which handles the caching, cloning and cache eviction for you
2. Error Handling
- Wrap all plugin operations in try-catch blocks
- Don't let plugin errors crash the application
- Log errors with appropriate log levels
Extension points beyond the lifecycle hooks
These are DI-registered providers rather than methods on IPlugin, for the same reason the
section nav is: the host discovers them without referencing your assembly, so a new plugin never requires a
host edit. Register them in RegisterServicesAsync.
| Provider | What it contributes |
|---|---|
IPluginSectionNavProvider |
The in-plugin sidebar, and which of your routes are guest-facing. |
IPublicRouteAllowList |
Routes reachable without a login. Honoured by both access walls, so an allow-listed route works for anonymous visitors on a private organisation and for signed-in non-members alike. A provider that throws fails closed. |
IChromeProvider |
Suppress the platform header and nav on your routes — plus the separate footer declaration covered in the warning above. |
IIndexSpecProvider |
Your secondary database indexes, declared once in a backend-neutral DSL keyed by entity type. The relational backends render them as DDL; on MongoDB they are created at tenant provisioning. Your plugin never names a backend. |
IPluginHubPathProvider |
SignalR hub path prefixes, aggregated at startup so your hub is exempted from antiforgery and the access gates without the host hard-coding your path. Because that exemption removes the membership and consent walls, your hub class must also carry [HubOrganisationScoped] or [HubPlatformScoped], which is what puts the tenant and membership checks back. |
⚠️ Never index an [Encrypted] field. The ciphertext is non-deterministic,
so the index can never match anything. Index a plain mirror or a hash beside it instead — and remember
that a mirror is readable at the raw-database level, so mirror the minimum you actually need to query.
Static assets
Your JS and CSS belong in your plugin's own wwwroot — each plugin is a Razor
Class Library, so they are served at /_content/{YourAssembly}/…. Never put them in the host's
wwwroot: it couples the host to your plugin and the file goes missing the moment the plugin is
deployed as a drop-in DLL rather than a project reference.
<script type="module" src="/_content/BlazorBlueprint.Plugins.MyPlugin/my-page.js"></script>
<link rel="stylesheet" href="/_content/BlazorBlueprint.Plugins.MyPlugin/my-page.css" />Per-plugin background service settings
Sweeps registered in RegisterBackgroundServicesAsync read their own configuration under your
plugin's key, so an operator tunes them without touching host settings. Only the background-worker process
binds these.
"Plugins": {
"BlazorBlueprint.Plugins.MyPlugin": {
"BackgroundServices": {
"MyPluginRetention": { "Enabled": true, "ProcessingIntervalInSeconds": 21600, "MaxAgeInDays": 365 }
}
}
}⚠️ A sweep must set the tenant scope before touching tenant data. Background services have no ambient tenant. Read the organisation list from the platform repository, set the tenant context per organisation, then query — and wrap each organisation's work so one failure cannot abort the whole run. See Privacy & Retention for when you owe a sweep.
3. Route Conventions
- User-side pages:
/plugin/{pluginname}for the main entry and/plugin/{pluginname}/{route}for every daily-use page. Render underMainLayoutwith the host'sPluginSectionNavsidebar in place of the platformNavMenu. Anonymous-accessible plugin surfaces (e.g./plugin/myplugin/publicpage/{id}) live in the same namespace. - Admin / settings pages:
/admin/plugin/{pluginname}/{route}— settings, setup wizards (when they fit the admin model), and configuration pages. Render underAdminLayoutand are reached from the admin nav. - Cross-section rule: pages under
/plugin/{name}/*must not link to/admin/plugin/{name}/*. Reference admin pages by name in copy ("Admin → Plugins → My Plugin → Plugin settings") instead of an<a href>so the section split stays clean. - Use kebab-case for route names.
4. Service Lifetime
- Use
Scopedfor request-specific services - Use
Singletonfor shared state or cache - Create scopes in background services using
IServiceProvider
5. Data Access Conventions (Org vs Platform)
- Organisation-scoped data: use the normal tenant-scoped repository pattern (e.g.
IRepository<T>) so reads/writes land in the current tenant database. - Switch tenant context in background jobs: when a background service needs to iterate across organisations, it should:
1)fetch organisation metadata from the platform database (e.g.IPlatformRepository<Organisation>),2)setITenantContextSetterto the target organisation, then3)run tenant-scoped queries usingIRepository<T>. - Plugin settings storage: per-org settings live in that organisation's
OrganisationPluginsdocument — in the tenant database, keyed by plugin Id. Presence of a key means the plugin is both installed and enabled for the org. Platform-wide plugin settings are separate, in a single platform-database row.
Putting It All Together
Everything a Plugin Can Do
A full-featured plugin combines the extension points above into a single self-contained app. Across its Manifest and lifecycle hooks, one plugin can provide:
- ✅ In-plugin section navigation via
IPluginSectionNavProvider - ✅ Admin pages with different authorization levels
- ✅ Public and authenticated routes
- ✅ SignalR hubs with real-time communication (wired in
ConfigureWebAppAsync, tenant-scoped with[HubOrganisationScoped]) - ✅ Plugin settings (per-organisation configuration)
- ✅ Background services (
RegisterBackgroundServicesAsync, worker host only) - ✅ Service registration and dependency injection
- ✅ Per-organisation setup and initialization
- ✅ Layout components (header, footer, or a floating overlay)
💡 Start small: only Manifest and RegisterServicesAsync are required — add each of the above only when your plugin actually needs it.
Ready to Build?
Build your own plugins on a documented extension model. The complete source is free to use — including commercially — while your business earns under £100k/year. Past that, a one-time commercial licence (from £499, excl. VAT) applies. Every tier ships the same product; nothing is feature-gated.
🔓 Source-available • Free under £100k/yr revenue • Commercial licence (from £499) above that • Full source code included