Bsft.Modules
A module is an independently developed slice of an application that registers its own services and has its own startup and shutdown. The loader resolves the declared dependencies, sorts them topologically, and starts them in an order that respects the graph.
Contract
The contracts (IModule, IModuleCatalog, IModuleHost, ModuleDescriptor,
ModuleInfo) live in Bsft.Modules.Abstractions, their implementations
(ModuleCatalog, ModuleHost, ModuleLoader) in Bsft.Modules.Services, and
ModuleDependencyException in Bsft.Modules.Exceptions; the wiring
(AddBsftModules, UseBsftModules, ModulesConfiguration) stays in
Bsft.Modules. A module implementation therefore imports
Bsft.Modules.Abstractions.
public interface IModule
{
string Name { get; }
string Version { get; }
string[] DependsOn => [];
void ConfigureServices(IServiceCollection services, IConfiguration config);
Task OnStartAsync(IServiceProvider services, CancellationToken ct = default);
Task OnStopAsync(CancellationToken ct = default);
}
DependsOn is a default interface member, so a module without dependencies
simply omits it. The names in it are module Name values, not assembly names.
Setup
services.AddBsftModules(builder.Configuration, modules => modules
.Add<OrdersModule>()
.Add<InventoryModule>());
// ...
app.UseBsftModules();
AddBsftModules takes the IConfiguration positionally, because it hands it to
each module's ConfigureServices. Everything the modules register happens
during this call — it is not deferred.
UseBsftModules() starts the module host, and additionally maps
GET /api/modules when the app builder is also an IEndpointRouteBuilder (which
it is in a normal ASP.NET Core host). The endpoint requires an authenticated
caller: it answers with the inventory of an installation and the version of every
part of it.
Sources
ModulesConfiguration offers three ways to populate the catalog:
| Method | Meaning |
|---|---|
Add<TModule>() |
A module compiled into the application. |
FromConfigurationFile(path) |
A JSON manifest listing modules and their assemblies. |
FromDirectory(path) |
Scan a directory of assemblies for IModule implementations. |
The JSON shape is { "modules": [ { "name", "assembly", "enabled", "dependsOn" } ] },
and it is the contract rather than the classes that read it, which are internal.
where enabled defaults to true. The manifest carries no version, so the
catalog records "0.0.0" and the endpoint reports what the loaded module says
about itself instead.
dependsOn is an addition to what a module declares in code, not a replacement:
a deployment may know of an ordering constraint the module's author could not,
and a module's own declarations are not a deployment's to drop.
The directory scan skips a file that is not a managed assembly at all — a native
library sitting in a plugin folder is not an error. An assembly whose types
cannot be read is a different matter and throws ModuleDependencyException
naming the file, as does an assembly a manifest names that cannot be loaded.
Ordering rules
The loader is strict, and each rule exists to turn a silent misconfiguration into a startup failure:
- Only modules with
Enabled == trueare loaded. - A module type must have a parameterless constructor; it is instantiated before the container exists, so it cannot take dependencies.
- Names are compared case-insensitively. A collision throws
ModuleDependencyException. - A dependency that is not in the catalog throws
ModuleDependencyException. - The sort is Kahn's algorithm, dependencies first. A cycle throws
ModuleDependencyExceptionnaming the members of the cycle.
ConfigureServices runs in sorted order, OnStartAsync in the same order, and
OnStopAsync in reverse — so a module never shuts down while something that
depends on it is still running.
GET /api/modules projects from the catalog rather than the loaded list, so
disabled modules are visible as disabled instead of vanishing. The projection is
built once, when the host is: the set of modules cannot change afterwards.
Localization convention
A module's display name and description are resolved by convention from the
localization keys modules.{Name}.displayName and modules.{Name}.description.
Seeding them is the module's own job — see localization.md.
Example
using Bsft.Localization.DependencyInjection;
using Bsft.Modules;
public class InventoryModule : IModule
{
public string Name => "Inventory";
public string Version => "1.0.0";
public string[] DependsOn => ["Orders"];
public void ConfigureServices(IServiceCollection services, IConfiguration config)
{
services.AddSingleton<StockReservationHandler>();
services.AddBsftTranslations(seed => seed
.ForLocale("en", en => en
.Add("modules.Inventory.displayName", "Inventory")
.Add("modules.Inventory.description", "Stock and inventory management")));
}
public Task OnStartAsync(IServiceProvider services, CancellationToken ct = default)
{
services.GetRequiredService<StockReservationHandler>().Subscribe();
return Task.CompletedTask;
}
public Task OnStopAsync(CancellationToken ct = default) => Task.CompletedTask;
}
OnStartAsync is the right place to take out event-aggregator subscriptions:
the container is built, and the subscription lives as long as the module does.
See also
- event-aggregator.md — the usual way modules talk without referencing each other.
- the getting-started guide — where module registration sits in the host.