Managing the actual runtime should generally be left up to the top-level project (TLP from here on, for my sanity's sake). It should be able to decide what it does and does not use. That's essentially the same goal as your DI container itself : giving control to the consumer.
To fit with that goal, the TLP needs to be able to actively choose to start/stop (when stopping is relevant) a background worker. If the TLP can't control that, then you haven't really inverted control to the owner/caller.
However, that doesn't mean that your library/module isn't allowed to provide a neatly encapsulated (not automatically started) background worker to keep your TLP as lightweight as it can. Everything except the decision to start that worker can be provided by your module - but the decision to start should remain under the TLP's control.
To address a few parts of your question:
Some of these modules contain background services that should start as soon as the system is started
The TLP is the one who decides when the system is considered to have completed the initialization phase of its runtime, so it makes sense for the TLP to then also control when the background services start.
The way we've gone about it is to have an
ISystemInitializer service that runs all startup tasks and runs all background services at system startup.
You didn't post any code, nor elaborated on where your
ISystemInitializer is housed, so it's hard to judge exactly what you're doing now. You asked a very abstract question and this answer can only be as concrete as your question was.
I'm going to guess that
ISystemInitializer is housed in the TLP somewhere. If it is, then it's in the right place. However, ...
These have grown to massive sizes and I'm wondering whether we're going about it the right way.
...from the sound of this, your
ISystemInitializer may be in need of some refactoring.
But that doesn't automatically mean you need to change the location of that initialization logic. Your approach may be correct and may simply be in need to being broken down into separate classes/responsibilities to keep things easier to manage, without needing to really change where it takes place.
Based on what you've said, I infer that the approach is roughly correct but just not broken down into easily digestible chunks right now.
I'm wondering whether it makes sense to run startup actions and start services on dependency injection modules and stop them on module unload.
It's not impossible to do so, but I'm apprehensive of this approach. You're skirting close to having your TLP be unaware of exactly what is happening in the module.
That being said, my concern could easily be solved, e.g. by having the TLP explicitly call a simple
MyModule.StartBackgroundServices() method, and have the module itself decide what it needs to start up (i.e. inside the
StartBackgroundServices() method body).
By having that explicit method call, you are still following my initial advice, i.e. that the TLP retains ultimate control over whether something gets started or not.
This example does entail that a module's background services are a package deal, but that may be contextually appropriate in your case. If your TLP needs more specific control, then provide the specific control that it needs.
That would massively reduce the amount of dependencies between the main application and the various modules.
It depends very much how many dependencies we're talking about here. Generally speaking, the expectation is that the TLP is intentionally given control over all dependencies, as per the ideological goal of IoC.
To use a well known quote: with great power comes great responsibility. More on point here: with inversion of control comes the responsibility of having to either handle the dependency graph or informedly delegating that handling to trusted sources, e.g. your modules.
That being said, this doesn't mean that you can't improve things here. For example, if all your background services follow the exact same format, you could significantly simplify this handling logic:
- Have all background services implement a shared
- Have each module register its own background services as a
- Have the TLP fetch all
IBackgroundService services and iteratively start them.
In your case, using NInject, you can rely on multi injection which allows multiple services to be registered under the same service type, and then have an array of all those services injected in the constructor.
For the TLP, that would boil down to nothing more than:
public class SystemInitializer
private readonly IBackgroundService _services;
public SystemInitializer(IBackgroundService services) // <-- NInject will give you this array based on your DI registration
_services = services;
public void StartAll()
foreach(IBackgroundService service in services)
This is just an oversimplified example, but this would work regardless of how many background services you had. It doesn't need to expand when you have develop more background services, so it's OCP-friendly and it keeps the needed dependencies in your TLP to an absolute minimum.
But at the same time, the TLP still has the necessary control to handle specific dependencies when it wants to handle them; e.g. it could filter out any background service that it does not want to run.
Maximized control, minimized juggling of dependencies.