❀°。Deijin's Blog

Dependency Injection: The Parameter Problem

Dependency Injection is a design pattern that allows you to pass dependencies to a service, without needing to think about these when making use of the services.

// we define some interface that 
// the service fulfills
interface IService {}

// we can have multiple implementations of 
// this interface each implementation has 
// it's own set of other service interfaces
// that it depends on. It doesn't care about 
// the implementation of those dependencies, 
// only the interfaces they provide.
class Service
{
    public Service(IDependency1 dep1, IDependency2 dep2) {}
}

This all works well and good for singleton services, but what about when you have things that aren't single instance, where parameters passed might vary?

class Service
{
    public Service(IDependency1 dep1, IDependency2 dep2, string name) {}
}

The service defined above is not a singleton, there can be more than one of it that exists. It needs these two dependency services which are the same between all instances, but it also needs the "name" parameter, which is different between instances.

What strategies can we employ to tackle this problem?

I will be discussing this in the context of C#, but much of this can be applied to other programming languages.

Strategy 1: Parameter Overrides

This is used by many popular libraries, including Autofac and UnityContainer. The parameter overrides allow you to inject a parameter, without having to explicitly resolve the dependency services to pass to the constructor. The downside is that the parameter name is a string, so you have to be sure not to change the name; if you update parameter names or add/remove parameters, it's not a compile-time error if you forget to update the registration.

Another problem that comes up with these (in unityContainer, I can't speak for others) is that the parameter override values can't be null, else it fails at runtime, often leading to hard to spot bugs.

Infrastructure:

delegate IService ServiceFactory(string name);

interface IService {}

class Service
{
    public Service(IDependency1 dep1, IDependency2 dep2, string name) {}
}

Registration:

container.Register<IService, Service>();
container.Register<ServiceFactory>(
    name => container.Resolve<IService>(new ParameterOverride("name", name))
    );

Invocation:

var instance = serviceFactory("hello");

Strategy 2: Explicit Injection

I first came across this one in Dryioc. You get compile errors if you modify arguments without updating the registration, but the trade off is that you also have to maintain the dependency services in the registration.

Infrastructure:

delegate IService ServiceFactory(string name);

interface IService {}

class Service
{
    public Service(IDependency1 dep1, IDependency2 dep2, string name) {}
}

Registration:

container.Register<ServiceFactory>(
    name => new Service(
        container.Resolve<IDependency1>(),
        container.Resolve<IDependency2>(),
        name
    ));

Invocation:

var instance = serviceFactory("hello");

Strategy 3: Parameter Packages

This strategy is built on-top of strategy 1 or 2, based on preference. It addresses the issue of changing parameters by creating a wrapper class for the parameters, and passing that in their place. The init class is the single source of truth for the parameters. The downside is you have to create an intermediate object.

Two fairly new C# language features come in handy here: "records", which allow really simple definitions for the parameter wrapper class, and "target-typed-new-expressions" which allow you to more consisely construct the wrapper class.

Infrastructure:

record Init(string Name);

delegate IService ServiceFactory(Init init);

interface IService {}

class Service
{
    public Service(IDependency1 dep1, IDependency2 dep2, Init init) {}
}

Registration:

container.Register<IService, Service>();
container.Register<ServiceFactory>(
    init => container.Resolve<IService>(new ParameterOverride("init", init))
    );

Invocation:

var instance = serviceFactory(new("hello"));

Strategy 4: The Initializer Pattern

This takes a whole new approach to the last few methods, instead opting to separate the parameters into a separate initializer method. You get a really simple registration that never needs to be updated, and also allows you to have an async construction. But it comes at a cost: you can't do readonly properties, and it doesn't play as nicely with nullable-reference-types.

Infrastructure:

delegate IService ServiceFactory();

interface IService
{
    IService Init(string name);
}

class Service : IService
{
    public Service(IDependency1 dep1, IDependency2 dep2) {}
    public IService Init(string name) { return this }
}

Registration:

container.Register<IService, Service>();
container.Register<ServiceFactory>(() => container.Resolve<IService>());

Invocation:

var instance = serviceFactory().Init("hello");

Strategy 5: Hidden Initializer

This is a variant of the intializer pattern, but you hide the initializer away so only the registrations need know about it. It means you have an extra place to update if you add parameters, but you don't need to remember to call the initializer, while still being able to have an async construction.

Infrastructure:

delegate IService ServiceFactory(string name);

interface IService {}

class Service : IService
{
    public Service(IDependency1 dep1, IDependency2 dep2) {}
    internal IService Init(string name) {}
}

Registration:

container.Register<Service>();
container.Register<ServiceFactory>(name => container.Resolve<Service>().Init(name));

Invocation:

var instance = serviceFactory("hello");

So what is best?

It depends on what you value. Here is a table to summarize the pros and cons that might help you decide.

Name Parameter Overrides Explicit Injection Parameter Packages Initializer Pattern Hidden Initializer
The API is a simple delegate yes yes no no yes
Number of sources of truth for dependency services 1 2 1-2 1 1
Number of sources of truth for parameters 3 3 1 1-2 3
All errors are compile-time no yes yes yes yes
Can make fields readonly / plays nice with nullable reference types yes yes yes no no
Parameters can be null no* yes yes yes yes
Can be async no no no yes yes

It's also important to consider performance. Calling a regular constructor or reusing an instance will always be faster at runtime. Do the benefits of dependency injection outweigh the costs in your situation?

#programming