3

Compile-Time Lifetime Matching

 2 years ago
source link: https://blog.ploeh.dk/2014/06/03/compile-time-lifetime-matching/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

Compile-Time Lifetime Matching

When using hand-coded object composition, the compiler can help you match service lifetimes.

In my previous post, you learned how easy it is to accidentally misconfigure a DI Container to produce Captive Dependencies, which are dependencies that are being kept around after they should have been released. This can lead to subtle or catastrophic bugs.

This problem is associated with DI Containers, because Container registration APIs let you register services out of order, and with any particular lifestyle you'd like:

var builder = new ContainerBuilder();
builder.RegisterType<ProductService>().SingleInstance();
builder.RegisterType<CommerceContext>().InstancePerDependency();
builder.RegisterType<SqlProductRepository>().As<IProductRepository>()
    .InstancePerDependency();
var container = builder.Build();

In this Autofac example, CommerceContext is registered before SqlProductRepository, even though SqlProductRepository is a 'higher-level' service, but ProductService is registered first, and it's even 'higher-level' than SqlProductRepository. A DI Container doesn't care; it'll figure it out.

The compiler doesn't care if the various lifetime configurations make sense. As you learned in my previous article, this particular configuration combination doesn't make sense, but the compiler can't help you.

Compiler assistance #

The overall message in my Poka-yoke Design article series is that you can often design your types in such a way that they are less forgiving of programming mistakes; this enables the compiler to give you feedback faster than you could otherwise have gotten feedback.

If, instead of using a DI Container, you'd simply hand-code the required object composition (also called Poor Man's DI in my book, but now called Pure DI), the compiler will make it much harder for you to mismatch object lifetimes. Not impossible, but more difficult.

As an example, consider a web-based Composition Root. Here, the particular IHttpControllerActivator interface belongs to ASP.NET Web API, but it could be any Composition Root:

public class SomeCompositionRoot : IHttpControllerActivator
{
    // Singleton-scoped services are declared here...
    private readonly SomeThreadSafeService singleton;
 
    public SomeCompositionRoot()
    {
        // ... and (Singleton-scoped services) are initialised here.
        this.singleton = new SomeThreadSafeService();
    }
 
    public IHttpController Create(
        HttpRequestMessage request,
        HttpControllerDescriptor controllerDescriptor,
        Type controllerType)
    {
        // Per-Request-scoped services are declared and initialized here
        var perRequestService = new SomeThreadUnsafeService();
 
        if(controllerType == typeof(FooController))
        {
            // Transient services are created and directly injected into
            // FooController here:
            return new FooController(
                new SomeServiceThatMustBeTransient(),
                new SomeServiceThatMustBeTransient());
        }
 
        if(controllerType == typeof(BarController))
        {
            // Transient service is created and directly injected into
            // BarController here, but Per-Request-scoped services or
            // Singleton-scoped services can be used too.
            return new BarController(
                this.singleton,
                perRequestService,
                perRequestService,
                new SomeServiceThatMustBeTransient());
        }
 
        throw new ArgumentException("Unexpected type!", "controllerType");
    }
}

Notice the following:

  • There's only going to be a single instance of the SomeCompositionRoot class around, so any object you assign to a readonly field is effectively going to be a Singleton.
  • The Create method is invoked for each request, so if you create objects at the beginning of the Create method, you can reuse them as much as you'd like, but only within that single request. This means that even if you have a service that isn't thread-safe, it's safe to create it at this time. In the example, the BarController depends on two arguments where the Per-Request Service fits, and the instance can be reused. This may seem contrived, but isn't at all if SomeThreadUnsafeService implements more that one (Role) interface.
  • If you need to make a service truly Transient (i.e. it must not be reused at all), you can create it within the constructor of its client. You see an example of this when returning the FooController instance: this example is contrived, but it makes the point: for some unfathomable reason, FooController needs two instances of the same type, but the SomeServiceThatMustBeTransient class must never be shared. It's actually quite rare to have this requirement, but it's easy enough to meet it, if you encounter it.
It's easy to give each service the correct lifetime. Singleton services share the lifetime of the Composition Root, Per-Request services are created each time the Create method is called, and Transient services are created Just-In-Time. All services go out of scope at the correct time, too.

Commerce example #

In the previous article, you saw how easy it is to misconfigure a ProductService, because you'd like it to be a Singleton. When you hand-code the composition, it becomes much easier to spot the mistake. You may start like this:

public class CommerceCompositionRoot : IHttpControllerActivator
{
    private readonly ProductService productService;
 
    public CommerceCompositionRoot()
    {
        this.productService = new ProductService();
    }
 
    public IHttpController Create(
        HttpRequestMessage request,
        HttpControllerDescriptor controllerDescriptor,
        Type controllerType)
    {
        // Implementation follows here...
    }
}

Fortunately, that doesn't even compile, because ProductService doesn't have a parameterless constructor. With a DI Container, you could define ProductService as a Singleton without a compilation error:

var container = new StandardKernel();
container.Bind<ProductService>().ToSelf().InSingletonScope();

If you attempt to do the same with hand-coded composition, it doesn't compile. This is an excellent example of Poka-Yoke Design: design your system in such a way that the compiler can give you as much feedback as possible.

Intellisense will tell you that ProductService has dependencies, so your next step may be this:

public CommerceCompositionRoot()
{
    this.productService = 
        new ProductService(
            new SqlProductRepository(
                new CommerceContext())); // Alarm bell!
}

This will compile, but at this point, an alarm bell should go off. You know that you mustn't share CommerceContext across threads, but you're currently creating a single instance. Now it's much clearer that you're on your way to doing something wrong. In the end, you realise, simply by trial and error, that you can't make any part of the ProductService sub-graph a class field, because the leaf node (CommerceContext) isn't thread-safe.

Armed with that knowledge, the next step is to create the entire object graph in the Create method, because that's the only safe implementation left:

public IHttpController Create(
    HttpRequestMessage request,
    HttpControllerDescriptor controllerDescriptor,
    Type controllerType)
{
    if(controllerType == typeof(HomeController))
    {
        return new HomeController(
            new ProductService(
                new SqlProductRepository(
                    new CommerceContext())));
    }
 
    // Handle other controller types here...
 
    throw new ArgumentException("Unexpected type!", "controllerType");
}

In this example, you create the object graph in a single statement, theoretically giving all services the Transient lifestyle. In practice, there's no difference between the Per Request and the Transient lifestyle as long as there's only a single instance of each service for each object graph.

Concluding remarks #

Some time ago, I wrote an article on when to use a DI Container. In that article, I attempted to explain how going from Pure DI (hand-coded composition) to a DI Container meant loss of compile-time safety, but I may have made an insufficient job of providing enough examples of this effect. The Captive Dependency configuration error, and this article together, describe one such effect: with Pure DI, lifetime matching is compiler-assisted, but if you refactor to use a DI Container, you lose the compiler's help.

Since I wrote the article on when to use a DI Container, I've only strengthened my preference for Pure DI. Unless I'm writing a very complex code base that could benefit from Convention over Configuration, I don't use a DI Container, but since I explicitly architect my systems to be non-complex these days, I haven't used a DI Container in production code for more than 1½ years.


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK