How to Compose a .NET Application from Modules with Startup and Shutdown Tasks
As an application grows, its Main method collects service registrations, start-up work and clean-up code from every library it uses. The order of those steps starts to matter, and nothing states it. A failure halfway through start-up leaves resources that nobody releases.
Sphere10.Framework.Application gives that work a structure. Each library contributes a module, start-up and shutdown work become small task classes, and one builder runs everything in a defined order. This article shows how to write modules and tasks, how the order is decided, and who owns the service provider.
1. Write a module
A module overrides only what it needs. This one registers a service, one initializer and one finalizer, and logs from its two hooks.
public sealed class StorageModule : ModuleConfigurationBase {
public override int Priority => 10;
public override void RegisterComponents(IServiceCollection services) {
services.AddSingleton<ActivityLog>();
services.AddInitializer<OpenStorageInitializer>();
services.AddFinalizer<CloseStorageFinalizer>();
}
public override void OnInitialize(IServiceProvider serviceProvider) {
base.OnInitialize(serviceProvider);
serviceProvider.GetRequiredService<ActivityLog>().Add("StorageModule.OnInitialize");
}
public override void OnFinalize(IServiceProvider serviceProvider) {
base.OnFinalize(serviceProvider);
serviceProvider.GetRequiredService<ActivityLog>().Add("StorageModule.OnFinalize");
}
}
Priority orders modules. A higher value registers, initializes and finalizes earlier, and the default is 0. A module that supplies a default implementation should let the host override it, which HasImplementationFor<T>() makes a one-line test.
public override void RegisterComponents(IServiceCollection services) {
if (!services.HasImplementationFor<IReportFormatter>())
services.AddSingleton<IReportFormatter, PlainReportFormatter>();
services.AddInitializer<LoadTemplatesInitializer>();
}
2. The contracts
A module is a class that takes part in the process lifecycle. The core library defines the smallest contract, and the Application library extends it with service registration.
-
A module derives from
ModuleConfigurationBase. It registers services inRegisterComponentsand can run code inOnInitializeandOnFinalize. -
An initializer is a task that runs once when the process starts. A finalizer runs once when it ends. Both are resolved from the service provider, so they receive their dependencies through the constructor.
-
Sphere10Framework.Instanceis the single host for the process. ItsBuild()method returns the builder that collects modules and starts everything.
3. Write the tasks
A task derives from the base class for its natural direction and overrides one method. A synchronous task derives from SyncApplicationInitializerBase.
public sealed class OpenStorageInitializer : SyncApplicationInitializerBase {
private readonly ActivityLog _log;
public OpenStorageInitializer(ActivityLog log) {
_log = log;
}
public override int Priority => 10;
public override void Initialize() => _log.Add("OpenStorageInitializer (priority 10)");
}
A task whose work is asynchronous derives from AsyncApplicationInitializerBase and overrides InitializeAsync.
public override async Task InitializeAsync(CancellationToken cancellationToken = default) {
await Task.Yield();
_log.Add("LoadTemplatesInitializer (priority 20, async)");
}
Each base supplies the other direction, so the host can call either form. Finalizers follow the same pattern with SyncApplicationFinalizerBase and AsyncApplicationFinalizerBase.
4. Start and end the framework
The host lists its modules explicitly. Nothing is discovered by scanning assemblies.
Sphere10Framework.Instance
.Build()
.WithProductInformation(product => {
product.ProductName = "ArticleSample";
product.CompanyName = "Example Company";
product.ProductVersion = new ApplicationVersion { Major = 1 };
})
.AddServices(services => {
services.AddSingleton(new Local<ISettingsProvider>(new CachedSettingsProvider(new DirectoryFileSettingsProvider(Path.Combine(directory, "user")))));
services.AddSingleton(new Global<ISettingsProvider>(new CachedSettingsProvider(new DirectoryFileSettingsProvider(Path.Combine(directory, "shared")))));
services.AddSingleton<IReportFormatter, UpperCaseReportFormatter>();
})
.AddModule<Sphere10.Framework.Application.ModuleConfiguration>()
.AddModule<ReportingModule>()
.AddModule<StorageModule>()
.Start();
AddServices callbacks run before any module registers, which is why the host's UpperCaseReportFormatter wins over the module's default. The Framework's own ModuleConfiguration supplies product information, settings and usage services. The sample points its settings providers at the sample directory so that nothing is written to the user's profile.
After Start() returns, services are resolved from Sphere10Framework.Instance.ServiceProvider. At shutdown the host calls EndFramework() once.
var services = Sphere10Framework.Instance.ServiceProvider;
var log = services.GetRequiredService<ActivityLog>();
var product = services.GetRequiredService<IProductInformationProvider>().ProductInformation;
log.Add(services.GetRequiredService<IReportFormatter>().Format($"running {product.ProductName}"));
Sphere10Framework.Instance.EndFramework();
5. The order of everything
Start() and EndFramework() run fixed sequences.
| Step | Order within the step |
AddServices callbacks |
The order they were added |
| Module hooks | Descending module Priority |
| Sequential initializers | Ascending task Priority, default 100 |
| Parallel initializers | Together, after the sequential ones |
| Finalizers | Registration order |
The sample records each step and checks the result against this table.
StorageModule.OnInitialize
ReportingModule.OnInitialize
OpenStorageInitializer (priority 10)
LoadTemplatesInitializer (priority 20, async)
RUNNING ARTICLESAMPLE
CloseStorageFinalizer
StorageModule.OnFinalize
ReportingModule.OnFinalize
Lifecycle order check: passed
StorageModule was added last and still initializes first, because its priority is 10. An initializer that sets Parallelizable to true runs in a parallel phase after all sequential ones, so keep tasks that depend on each other sequential.
6. Ownership and failure
-
Start()builds the service provider and owns it.EndFramework()runs the finalizers and module hooks and then disposes the provider. -
A host that already has a provider, such as ASP.NET Core, calls
RegisterModules(services)on the builder and laterStart(provider). That provider stays with its host and is not disposed by the framework. -
A failing initializer stops the remaining ones and fails start-up. The framework disposes the provider it built and runs no finalizers, so do not call
EndFramework()after a failed start. -
Every finalizer is attempted even when one fails, and the failures are thrown together at the end.
OnInitialize runs last, because its priority is int.MinValue. Settings facades and product tokens are therefore not available inside another module's OnInitialize. Use an initializer for work that needs them, since initializers run after every module hook. The usage counter that module maintains has an encrypted member, so an entry assembly needs [assembly: AssemblyApplicationSecret("...")], as the sample declares.
7. Get the package and run the sample
Add the core package and Sphere10.Framework.Application.
dotnet add package Sphere10.Framework
dotnet add package Sphere10.Framework.Application
The complete MIT-licensed sample contains the code shown above.
dotnet run -c Release -- ./fresh-data
The output is the list shown above. The Application guide covers process tasks in full, including the plugin tasks that belong to a running desktop or Blazor application.
The Sphere10 Framework overview describes the rest of the toolkit.
Author: Herman Schoenfeld