Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Resolving keyed subgraphs with cascaded fallback

This is going to be a little crazy, but I believe that if it's possible, it's going to be the most maintainable solution for the task at hand.

Our application uses Autofac for dependency injection.

We use a custom data file format that we need to be able to evolve for technical (performance/storage space optimizations) or domain reasons. The application will always only write the most recent version of the format, but needs to be able to read all previous versions too. The evolution will generally be rather gradual between versions with changes only in a few places, so a lot of the code for reading it will remain the same.

The file format version number is stored as an integer value at the beginning of the file. Reading any version of the file format will always result in the same data structure, called Scenario here.

A class that can read data from a file takes a dependency on IReadDataFile:

public interface IReadDataFile
{
    Scenario From(string fileName);
}

Behind that is a non-trivial object graph for reading the various parts of a scenario. However, the required graph looks a little different for each of the file format versions (illustrative example, not the actual types; the real graphs are much more complex):

Version 1:

ReadDataFileContents : IReadDataFileContents
└> ReadCoreData : IReadCoreData
└> ReadAdditionalData : IReadAdditionalData
   └> NormalizeName : INormalizeName

Version 2:

ReadDataFileContentsV2 : IReadDataFileContents
└> ReadCoreData : IReadCoreData
└> ReadAdditionalDataV2 : IReadAdditionalData
   └> NormalizeNameV2 : INormalizeName
      └> AdditionalNameRegex : IAdditionalNameRegex

Version 3:

ReadDataFileContentsV2 : IReadDataFileContents
└> ReadCoreData : IReadCoreData
└> ReadAdditionalDataV3 : IReadAdditionalData
   └> NormalizeNameV2 : INormalizeName
      └> AdditionalNameRegexV3 : IAdditionalNameRegex

(I'm only considering entirely separate graphs like this; handling this in a single graph and switching every time there is a version-related difference obviously gets really messy very quickly.)

Now whenever the IReadDataFile.From() method is called to load a file, it needs to get hold of the appropriate subgraph for the file format version. An easy way to achieve this is via an injected factory:

public class ReadDataFile : IReadDataFile
{
    private readonly IGetDataFileVersion getDataFileVersion;
    private readonly Func<int, IReadDataFileContents> createReadDataFileContents;

    public ReadDataFile(
        IGetDataFileVersion getDataFileVersion,
        Func<int, IReadDataFileContents> createReadDataFileContents)
    {
        this.getDataFileVersion = getDataFileVersion;
        this.createReadDataFileContents = createReadDataFileContents;
    }

    public Scenario From(string fileName)
    {
        var version = this.getDataFileVersion.From(fileName);
        var readDataFileContents = this.createReadDataFileContents(version);
        return readDataFileContents.From(fileName);
    }
}

The question is how registration and resolution of those subgraphs will work.

Registering the complete subgraphs as Keyed<T> by hand is very involved and error-prone and does not scale well for additional file format versions (especially as the graphs are a lot more complex than the example).

Instead, I would like the registration for the whole thing as described above to look like this:

builder.RegisterAssemblyTypes(typeof(IReadDataFile).Assembly).AsImplementedInterfaces();

builder.RegisterType<ReadDataFileContents>().As<IReadDataFileContents>();
builder.RegisterType<ReadDataFileContentsV2>().Keyed<IReadDataFileContents>(2);

builder.RegisterType<ReadAdditionalData>().As<IReadAdditionalData>();
builder.RegisterType<ReadAdditionalDataV2>().Keyed<IReadAdditionalData>(2);
builder.RegisterType<ReadAdditionalDataV3>().Keyed<IReadAdditionalData>(3);

builder.RegisterType<NormalizeName>().As<INormalizeName>();
builder.RegisterType<NormalizeNameV2>().Keyed<INormalizeName>(2);

builder.RegisterType<AdditionalNameRegex>().As<IAdditionalNameRegex>();
builder.RegisterType<AdditionalNameRegexV3>().Keyed<IAdditionalNameRegex>(3);

builder.Register<Func<int, IReadDataFileContents>>(c =>
{
    var context = c.Resolve<IComponentContext>();

    return version => // magic happens here
});

That means there are only explicit registrations for the components that vary between the graphs. And by "magic happens here", I mean that for getting away with this minimum in registrations, the resolution will have to do the heavy lifting.

The way I would like this to work is this: For each component (in this subgraph) to be resolved, it is attempted to resolve a registration keyed to the requested file format version. If that attempt fails, another one is made for the next lower version, and so forth; when the resolution for key 2 fails, the default registration is resolved.

A complete example:

  • The createReadDataFileContents factory is called with a version value of 3, so the required graph is the one for file format version 3 given above.
  • An attempt is made to resolve IReadDataFileContents with the key 3. This is unsuccessful; there is no such registration.
  • Now an attempt is made to resolve IReadDataFileContents with the key 2. This succeeds.
  • The constructor requires an IReadCoreData. It is attempted to resolve this with the key 3, then 2; both fail, so the default registration is resolved, which succeeds.
  • The second constructor parameter is IReadAdditionalData; it is attempted to resolve this with the key 3, which succeeds.
  • The constructor requires INormalizeName; resolution with key 3 fails, then the attempt for 2 succeeds.
  • This constructor in turn requires IAdditionalNameRegex; the resolution attempt with key 3 succeeds.

The tricky thing here (and the one I can't figure out how to do) is that the version "countdown" fallback process needs to happen for each individual dependency to be resolved, each time starting from the initial value of version.

Poking around the Autofac API and a bit of googling yielded a few things that looked interesting, but none of them seemed to offer an obvious path to a solution.

  • Module.AttachToComponentRegistration() - I have used this elsewhere to hook into the resolution process using the registration.Preparing; however, that event is only raised when a suitable registration has been found, and there doesn't appear to be an event that is raised before that, nor a way to register a callback in case of resolution failure (which surprises me).
  • IRegistrationSource - This seems to be the way to implement such more general registration/resolution principles, but I can't get my head around what I'd need to do inside that, in case that is actually the place I'm looking for.
  • WithKeyAttribute - We can't use this here, because we need to control the "version" of a dependency that is injected from the outside (also, the actual business code would become dependent on Autofac, which is never good.)
  • ILifetimeScope.ResolveOperationBeginning - This looked very promising, but the event is only raised for resolutions that have already been successful.
  • IIndex<TKey, TValue> - Another thing that looked really good at first, but it contains already constructed instances, which leaves no way to get the version key to the resolution for the lower levels.

A problem to solve on the side would be restricting the whole thing to just the types that are actually relevant for this, but I guess that could be done convention based (namespace etc.) if need be.

Another thought that might help is that after all the registrations are made (which would have to be determined somehow), the "gaps" could be "filled up" - meaning if there is a registration keyed with 3 but none with 2, one will be added that is equal to the default registration. That would allow resolving all dependencies in the subgraph with the same key and do away with the need for that "cascaded fallback" mechanism which may be the most difficult part of the whole thing.

Is there any way this can be achieved with Autofac?

(Also, thanks for reading this epic in the first place!)

like image 625
TeaDrivenDev Avatar asked Dec 10 '25 19:12

TeaDrivenDev


1 Answers

Out of the box, Autofac doesn't really have this level of control. But you can build it if you don't mind a little bit of indirection by adding a factory in the middle.

First, let me post a working C# doc and then I'll explain it. You should be able to paste this into, say, a .csx scriptcs doc and see it go - that's where I wrote it.

using Autofac;
using System.Linq;

// Simple interface just used to prove out the
// dependency chain that gets resolved.
public interface IDependencyChain
{
  IEnumerable<Type> DependencyChain { get; }
}

// File reading interfaces
public interface IReadDataFileContents : IDependencyChain { }
public interface IReadCoreData : IDependencyChain { }
public interface IReadAdditionalData : IDependencyChain { }
public interface INormalizeName : IDependencyChain { }
public interface IAdditionalNameRegex : IDependencyChain { }

// File reading implementations
public class ReadDataFileContents : IReadDataFileContents
{
  private readonly IReadCoreData _coreReader;
  private readonly IReadAdditionalData _additionalReader;
  public ReadDataFileContents(IReadCoreData coreReader, IReadAdditionalData additionalReader)
  {
    this._coreReader = coreReader;
    this._additionalReader = additionalReader;
  }

  public IEnumerable<Type> DependencyChain
  {
    get
    {
      yield return this.GetType();
      foreach(var t in this._coreReader.DependencyChain)
      {
        yield return t;
      }
      foreach(var t in this._additionalReader.DependencyChain)
      {
        yield return t;
      }
    }
  }
}

public class ReadDataFileContentsV2 : ReadDataFileContents
{
  public ReadDataFileContentsV2(IReadCoreData coreReader, IReadAdditionalData additionalReader)
    : base(coreReader, additionalReader)
  {
  }
}

public class ReadCoreData : IReadCoreData
{
  public IEnumerable<Type> DependencyChain
  {
    get
    {
      yield return this.GetType();
    }
  }
}

public class ReadAdditionalData : IReadAdditionalData
{
  private readonly INormalizeName _normalizer;
  public ReadAdditionalData(INormalizeName normalizer)
  {
    this._normalizer = normalizer;
  }

  public IEnumerable<Type> DependencyChain
  {
    get
    {
      yield return this.GetType();
      foreach(var t in this._normalizer.DependencyChain)
      {
        yield return t;
      }
    }
  }
}

public class ReadAdditionalDataV2 : ReadAdditionalData
{
  public ReadAdditionalDataV2(INormalizeName normalizer)
    : base(normalizer)
  {
  }
}

public class ReadAdditionalDataV3 : ReadAdditionalDataV2
{
  public ReadAdditionalDataV3(INormalizeName normalizer)
    : base(normalizer)
  {
  }
}

public class NormalizeName : INormalizeName
{
  public IEnumerable<Type> DependencyChain
  {
    get
    {
      yield return this.GetType();
    }
  }
}

public class NormalizeNameV2 : INormalizeName
{
  public readonly IAdditionalNameRegex _nameRegex;
  public NormalizeNameV2(IAdditionalNameRegex nameRegex)
  {
    this._nameRegex = nameRegex;
  }

  public IEnumerable<Type> DependencyChain
  {
    get
    {
      yield return this.GetType();
      foreach(var t in this._nameRegex.DependencyChain)
      {
        yield return t;
      }
    }
  }
}

public class AdditionalNameRegex : IAdditionalNameRegex
{
  public IEnumerable<Type> DependencyChain
  {
    get
    {
      yield return this.GetType();
    }
  }
}

public class AdditionalNameRegexV3 : AdditionalNameRegex { }

// File definition modules - each one registers just the overrides needed
// for the upgraded version of the file type. ModuleV1 registers the base
// stuff that will be used if things aren't overridden. If any version
// of a file format needs to "revert back" to an old mechanism, like if
// V2 needs NormalizeNameV2 and V3 needs NormalizeName, you'd have to re-register
// the base NormalizeName in the V3 module - override the override.
public class ModuleV1 : Module
{
  protected override void Load(ContainerBuilder builder)
  {
    builder.RegisterType<ReadDataFileContents>().As<IReadDataFileContents>();
    builder.RegisterType<ReadCoreData>().As<IReadCoreData>();
    builder.RegisterType<ReadAdditionalData>().As<IReadAdditionalData>();
    builder.RegisterType<NormalizeName>().As<INormalizeName>();
  }
}

public class ModuleV2 : Module
{
  protected override void Load(ContainerBuilder builder)
  {
    builder.RegisterType<ReadDataFileContentsV2>().As<IReadDataFileContents>();
    builder.RegisterType<ReadAdditionalDataV2>().As<IReadAdditionalData>();
    builder.RegisterType<NormalizeNameV2>().As<INormalizeName>();
    builder.RegisterType<AdditionalNameRegex>().As<IAdditionalNameRegex>();
  }
}

public class ModuleV3 : Module
{
  protected override void Load(ContainerBuilder builder)
  {
    builder.RegisterType<ReadAdditionalDataV3>().As<IReadAdditionalData>();
    builder.RegisterType<AdditionalNameRegexV3>().As<IAdditionalNameRegex>();
  }
}

// Something has to know about how file formats are put together - a
// factory of some sort. Here's the thing that "knows." You could probably
// drive this from config or something else, too, but the idea holds.
public class FileReaderFactory
{
  private readonly ILifetimeScope _scope;
  public FileReaderFactory(ILifetimeScope scope)
  {
    // You can always resolve the current lifetime scope as a parameter.
    this._scope = scope;
  }

  public IReadDataFileContents CreateReader(int version)
  {
    using(var readerScope = this._scope.BeginLifetimeScope(b => RegisterFileFormat(b, version)))
    {
      return readerScope.Resolve<IReadDataFileContents>();
    }
  }

  private static void RegisterFileFormat(ContainerBuilder builder, int version)
  {
    switch(version)
    {
      case 1:
        builder.RegisterModule<ModuleV1>();
        break;
      case 2:
        builder.RegisterModule<ModuleV1>();
        builder.RegisterModule<ModuleV2>();
        break;
      case 3:
      default:
        builder.RegisterModule<ModuleV1>();
        builder.RegisterModule<ModuleV2>();
        builder.RegisterModule<ModuleV3>();
        break;
    }
  }
}


// Only register the factory and other common dependencies - not the file
// format readers. The factory will be responsible for managing the readers.
// Note that since readers do resolve from a child of the current lifetime
// scope, they can use common dependencies that you'd register in the
// container.
var builder = new ContainerBuilder();
builder.RegisterType<FileReaderFactory>();
var container = builder.Build();
using(var scope = container.BeginLifetimeScope())
{
  var factory = scope.Resolve<FileReaderFactory>();

  for(int i = 1; i <=3; i++)
  {
    Console.WriteLine("Version {0}:", i);
    var reader = factory.CreateReader(i);
    foreach(var t in reader.DependencyChain)
    {
      Console.WriteLine("* {0}", t);
    }
  }
}

If you run this, the console output yields the correct tree of file reading dependencies as outlined in your desired results:

Version 1:
* Submission#0+ReadDataFileContents
* Submission#0+ReadCoreData
* Submission#0+ReadAdditionalData
* Submission#0+NormalizeName
Version 2:
* Submission#0+ReadDataFileContentsV2
* Submission#0+ReadCoreData
* Submission#0+ReadAdditionalDataV2
* Submission#0+NormalizeNameV2
* Submission#0+AdditionalNameRegex
Version 3:
* Submission#0+ReadDataFileContentsV2
* Submission#0+ReadCoreData
* Submission#0+ReadAdditionalDataV3
* Submission#0+NormalizeNameV2
* Submission#0+AdditionalNameRegexV3

Here's the idea:

Instead of using keyed services or trying to resolve things out of the main container, use child lifetime scopes to isolate the set of file-version-specific dependencies.

What I have in the code is a series of Autofac modules, one for each file format. In the example the modules build on top of each other - file format V1 needs module V1; file format V2 needs module V1 with module V2 overrides; file format V3 needs module V1 with module V2 overrides and module V3 overrides.

In real life you could make these all self-contained, but if each version just builds off the last this may be easier to maintain - each new version/module would only need the differences.

I then have an intermediate factory class that you'd use to get the appropriate file version reader. The factory is what knows how to associate the file format version with the appropriate set of modules. In a more complex scenario you could potentially drive this off config or attributes or something, but it was easier to illustrate it this way.

When you want a specific file format reader, you resolve the factory and ask it for the reader. The factory takes the current lifetime scope and spawns a child scope, registering the appropriate modules just for that file format, and resolving the reader. In this way, you're using Autofac in a more natural fashion just letting types line up rather than fighting metadata or other mechanisms.

Beware IDisposable dependencies. If you go this route and any of your file reading dependencies are disposable, you'll need to register them as Owned or something so the tiny child lifetime scope inside the factory doesn't instantiate and then immediately dispose of stuff you're going to need.

It may seem weird to fire up just a tiny lifetime scope, but this is also how InstancePerOwned stuff works. There's precedent for it behind the scenes.

Oh, and to bring it all home, if you really wanted to register that Func<int, IReadDataFileContents> method, you could just have it resolve the factory and call the CreateReader method in there.

Hopefully this unblocks you or maybe gives you an idea of somewhere you can take it. I'm not sure that any of the standard out-of-the-box mechanisms Autofac has can more naturally handle it, but this seems to solve the problem.

like image 137
Travis Illig Avatar answered Dec 13 '25 09:12

Travis Illig