In a C# console application of mine (which uses CliFx for the command line processing), I'm setting up dependency injection using Autofac. What I've learned about IoC is that you should have a single composition root with all of your types known and registered there.
I'll be honest, the idea of having a single location aware of every type in the application has always bothered me. It seems at odds with several core principles I've learned to respect over the years. Specifically:
- Separation of Concerns: Normally completely unrelated types are now together in the same place, even if they can never be used together (mutually exclusive).
- Open-Closed Principle: In a console application designed vertically, adding a new command should be a matter of adding new files, not modifying existing ones. However, in addition to new files being added, I now have to go modify the application entry point code to add new type registrations, even if nothing other than the new classes will use those types.
- Monolithic code: As the application grows, this function could become hundreds, maybe thousands of lines long with not just simple type mappings, but actual business rules when the registered service is actually a lambda (factory method logic used to yield an implementation to a registered interface).
I'm going to use a cheesy example here, but hopefully this gets my point across. Let's say I have a command called human
. It has two subcommands, eat
and sleep
. Furthermore, if I use the eat
command, I can choose what the human eats:
Eat Cookies and Cake:
$ human eat --sweets cookies cake
Eat Vegetables:
$ human eat --veggies carrots peas
Human can even eat a combination:
$ human eat --veggies carrots --sweets cake
Before I get into code examples, I want to state that I've greatly simplified the code required to actually work with CliFx. Please consider this pseudocode. There's a lot of details related to getting proper code for this library, but I've omitted it here to simplify things. Also this code probably doesn't compile since I just typed it up in notepad.
The way this is implemented (in my mind) is as follows. I first have my Program
class, which sets up CLI processing:
internal static class Program
{
public static async Task<int> Main() =>
await new CliApplicationBuilder()
.AddCommandsFromThisAssembly()
.Build().RunAsync();
}
Every subcommand class inherits from a base command class which handles common stuff such as:
- Common command line options available to every subcommand (such as
--debug
to control the level of logging output. Every subcommand logs stuff) - Setting up the composition root (or rather, the shared subset of it)
- Initializing program configuration
The base command is something like this:
public abstract class BaseCommand : ICommand
{
[CommandOption]
public bool Preview { get; set; } = false;
[CommandOption]
public bool Debug { get; set; } = false;
[CommandOption]
public string? Config { get; set; };
protected IContainer Container { get; set; }
public virtual void Execute()
{
// Setup composition root. The Debug value is used to control the
// desired log output level. That is set up when the logger is registered
// in the DI container.
Container = CompositionRoot.Setup(Debug);
// Load configuration file & set up the singleton instance registered
// with the DI container.
var config = Container.Resolve<IConfigurationLoader>();
config.Load(Config);
}
}
Since we're running the eat
subcommand, that gets its own EatCommand
class:
class EatCommand : BaseCommand
{
[CommandOption]
public List<string> Sweets { get; set; } = new()
[CommandOption]
public List<string> Veggies { get; set; } = new()
public override void Execute()
{
// Do the common setup stuff
base.Execute();
// Eat-specific registrations. Will never be used by other subcommands
using (var scope = Container.BeginLifetimeScope(b =>
{
b.RegisterType<FarmersMarket>().As<IFarmersMarket>();
b.RegisterType<Bakery>().As<IBakery>();
b.RegisterType<EatSweetsLogic>();
b.RegisterType<EatVeggiesLogic>();
}))
{
if (Sweets.Count > 0)
{
var eatLogic = scope.Resolve<EatSweetsLogic>();
eatLogic.Process(this);
}
if (Veggies.Count > 0)
{
var eatLogic = scope.Resolve<EatVeggiesLogic>();
eatLogic.Process(this);
}
}
}
}
A couple of points:
- When we eat veggies, we obtain them from a farmer's market.
IFarmersMarket
is a dependency ofEatVeggiesLogic
. Sweets will never need this. And importantly, subcommands likeSleepCommand
(for commandhuman sleep
) will never need it. Same goes for sweets + bakery. - The "Logic" classes are just ways to decompose chunks of functionality in the subcommand. I'm not sure if it's accurate to call this a strategy pattern, but it feels similar.
Each subcommand likely has several registrations like this. Ones that are only relevant to that subcommand. So is it wrong to model the "composition root" around the command hierarchy? I think of it this way: Each subcommand is a program entry point. Basically the same as having multiple Program.Main()
methods. At least, I think CliFx and most other CLI processing libraries force this semantic style.
To strictly keep things "in one place" as normal text-book definition of Composition Root seems to require means that all of these "implementation detail" classes like EatSweetsLogic
need to get exposed at a higher level. This means, probably among other things, that if I remove the ability for human to eat sweets, I'm not just deleting source files. I'm going back to my CompositionRoot.cs
and updating a method body. Violation of Open-Closed principle, IMHO, and I have a discomfort with this.
Lastly, I will say that I am probably mixing a few different problems here. I'm perfectly happy to keep the focus on the composition root aspect of this. Another issue I've struggled with is separating the model (the subcommand class and its properties that relate to CLI options) from execution business logic. However, this is an entirely different problem that gets more into the specifics of CliFx itself. If I've mixed issues here, please disregard. My mind is a bit jumbled since there's so much to take in.
Sorry for the long run-down, but I wanted to do a brain dump and share all of my discomforts. I'm stuck in analysis paralysis here and am not sure why this seemingly simple concept leaves me stumped and indecisive.