Rico Suter's blog.
 


I’m currently working on a large project where different teams implement assemblies which are consumed by other teams. We use Ninject for dependency injection: Each assembly has a Ninject module class which registers the available interface-to-implemenation bindings. At the moment, the consumers of these assemblies just know what module classes to load and what interfaces to inject into own objects like ASP.NET controllers or own service objects, but nothing about the scope of the injected objects. Recently we had some nasty runtime errors, because a consumer of one of these assemblies injected a per-request scoped object into a singleton-scoped object. This is why I looked for a way to detect injections of wrongly scoped objects (also known as captive dependencies).

The problem

Let’s assume you implement a thread-safe singleton service object (i.e. registered with the InSingletonScope() method) which uses some other service objects via injection. If you inject a per-request scoped or other wrongly scoped object into this singleton object, you may end up with lots of problems: The singleton object lives forever, but the per-request object is automatically disposed by Ninject. The next time the singleton uses the injected, disposed object, unexpected errors may occur…

Another problem is, that a singleton object usually must be thread safe. One implementation of the injected service object may originally be thread safe and singleton scoped, however another - unexpected and new - implementation may be not, which may turn the singleton object into an object which is not thread safe…

The solution

My solution to detect this type problems was to implement a custom Ninject kernel, which checks that the scopes of the injected objects are “compatible” with the parent object’s scope. I worked out the following rules which I implemented in the custom kernel ScopeCheckingStandardKernel:

  • Allowed injections for a singleton scoped object:
    • Singleton scoped
    • (Transient scoped if AllowTransientScopeInSingletonScope is set)
  • Allowed injections for a thread scoped object:
    • Singleton scoped
    • Thread scoped (same thread)
    • (Transient scoped if AllowTransientScopeInThreadScope is set)
  • Allowed injections for a custom scoped object:
    • Singleton scoped
    • (Transient scoped if AllowTransientScopeInCustomScope is set)
  • Allowed injections for a transient scoped object:
    • Singleton scoped
    • Transient scoped

Important: Because ASP.NET controllers are transient scoped and the injection of per-request scoped objects is allowed, the kernel may fail to instantiate these controllers even if there is no problem. This is why the property AllowPerRequestScopeInTransientScopedController is set to true by default.

The implementation of the custom Ninject kernel is shown below. The scope rules are implemented in the IsScopeAllowed() method:

/// <summary>A <see cref="StandardKernel"/> which additionally checks that injected objects are correctly scoped.</summary>
/// <remarks>Read https://blog.rsuter.com/avoid-wrongly-scoped-injections-with-a-custom-ninject-kernel/ for more information.</remarks>
public class ScopeCheckingStandardKernel : StandardKernel
{
    /// <summary>Initializes a new instance of the <see cref="ScopeCheckingStandardKernel"/> class.</summary>
    public ScopeCheckingStandardKernel()
    {
        AllowPerRequestScopeInTransientScopedController = true;
    }

    /// <summary>Gets or sets a value indicating whether a transient scoped object is allowed in a singleton scoped object.</summary>
    public bool AllowTransientScopeInSingletonScope { get; set; }

    /// <summary>Gets or sets a value indicating whether a transient scoped object is allowed in a thread scoped object.</summary>
    public bool AllowTransientScopeInThreadScope { get; set; }

    /// <summary>Gets or sets a value indicating whether a transient scoped object is allowed in a custom scoped object.</summary>
    public bool AllowTransientScopeInCustomScope { get; set; }

    /// <summary>Gets or sets a value indicating whether a per-request scoped object is allowed in a transient scoped controller object (default: true).</summary>
    public bool AllowPerRequestScopeInTransientScopedController { get; set; }

    /// <summary>Resolves instances for the specified request. 
    /// The instances are not actually resolved until a consumer iterates over the enumerator.</summary>
    /// <param name="request">The request to resolve.</param>
    /// <returns>An enumerator of instances that match the request.</returns>
    /// <exception cref="InvalidOperationException">The scope of the injected object is not compatible with the scope of the parent object.</exception>
    public override IEnumerable<object> Resolve(IRequest request)
    {
        var isInjectedIntoParent = request.ActiveBindings.Any();
        if (isInjectedIntoParent)
        {
            var parentBinding = request.ActiveBindings.Last();

            var bindings = GetBindings(request.Service).Where(SatifiesRequest(request));
            if (bindings.Any(binding => IsScopeAllowed(request, binding, parentBinding) == false))
            {
                throw new InvalidOperationException("The scope of the injected object (" + request.Service.FullName + ") " +
                                                    "is not compatible with the scope of the parent object (" + parentBinding.Service.FullName + ").");
            }
        }

        return base.Resolve(request);
    }

    private bool IsScopeAllowed(IRequest request, IBinding binding, IBinding parentBinding)
    {
        var scope = binding.GetScope(CreateContext(request, binding));
        var parentScope = parentBinding.GetScope(CreateContext(request, parentBinding));

        var haveSameScope = scope == parentScope;
        if (haveSameScope)
            return true;

        var isChildSingletonScoped = scope == this;
        if (isChildSingletonScoped)
            return true; 

        var isChildTransientScoped = scope == null;
        var isChildPerRequestScoped = scope != null && (
            scope.GetType().Name == "HttpContext" || 
            scope.GetType().Name == "DisposeNotifyingObject");

        var isParentSingletonScoped = parentScope == this;
        if (isParentSingletonScoped)
            return AllowTransientScopeInSingletonScope && isChildTransientScoped;

        var isParentThreadScoped = parentScope is Thread;
        if (isParentThreadScoped)
            return AllowTransientScopeInThreadScope && isChildTransientScoped;

        var isParentAController = parentBinding.Service.Name.EndsWith("Controller");
        var isParentTransientScoped = parentScope == null;
        if (isParentTransientScoped)
            return AllowPerRequestScopeInTransientScopedController && isParentAController && isChildPerRequestScoped;

        return AllowTransientScopeInCustomScope && isChildTransientScoped;
    }
}

To detect whether you have any scoping problems in your Web application, just update the instantiation of the kernel in your NinjectWebCommon.cs file and retest your application:

...

private static IKernel CreateKernel()
{
    var kernel = new ScopeCheckingStandardKernel();
    try
    {
        ...

A complete project with unit tests and the custom kernel class can be found on GitHub.

Update 11/27/2015: Improved kernel so that injecting singleton is always allowed.

Update 02/19/2016: Improved per request scope detection (OWIN).



Discussion