Skip to content

Composition and Dependency Injection

Ioan Crișan edited this page Oct 23, 2020 · 21 revisions

While it is true that the composition (see also Inversion of Control or Dependency Injection) provides an abstraction level which, in the beginning, may be hard to understand with respect to the way it works, in the end it makes the application easier to keep under control, more extensible, more maintainable, and, very important, easier to unit test.

Aims for composition

  • Be implementation agnostic. This means that any DI/IoC/Composition framework could be used, provided that specific adapters are implemented.
  • Write as little wire-up code as possible, ideally make the composition discover the parts "magically".
  • Use conventions and fluent configuration for defining them.
  • Support by default application services.

The infrastructure

The infrastructure for composition includes:

  • ICompositionContext: contract for composition containers hosting and managing parts.
  • ICompositionContainerBuilder: contract for components constructing composition containers.
    • CompositionContainerBuilderBase: provides a base implementation for builders of composition containers.
  • IConventionsBuilder: contract for components constructing conventions over a fluent API.
    • IPartConventionsBuilder: contract for constructing conventions on parts. Used by the conventions builder.
      • IExportConventionsBuilder: contract for constructing export conventions on parts. Used by the part conventions builder.
      • IImportConventionsBuilder: contract for constructing import conventions on parts. Used by the part conventions builder.
  • IConventionsRegistrar: contract for applying composition conventions on candidate types. Used by convention builders to register the conventions.
  • IExportProvider: marker interface used solely for collecting the exports providers. It is the responsibility of each specific implementation to handle how these export providers are used.
  • IExportFactory, IExport: interfaces used for indicating dependencies to a factory of parts. Also used to provide metadata about a part.

Composition metadata

Composition metadata is information collected by the composition container about the parts, based on the rules specified in the conventions builder. It is provided as an IDictionary<string, object>, but can also be typed, in which case it must be a class providing a constructor accepting a single parameter of the indicated dictionary type. For convenience, Kephas provides the ExportMetadataBase type as a base for metadata classes, or the more specialized AppServiceMetadata for application services.

    public class OperationMetadata : AppServiceMetadata
    {
        public OperationMetadata(IDictionary<string, object> metadata)
            : base(metadata)
        {
            if (metadata == null)
            {
                return;
            }

            this.Operation = (string)metadata.TryGetValue(nameof(Operation), string.Empty);
        }

        public string Operation { get; }
    }

Common cases for using the composition

Implementing composable components

This is the most common case, when a component is included in the composition through exporting it and, optionally, importing other composable parts.

Guidelines:

  • The component should be exported only by the means of conventions, not explicitly.
  • The component should import other parts also only by the means of conventions.

Note: The registration of application services provides a clean and seamless definition of composable components.

Importing part as factory

When an import (property or constructor parameter) should be instantiated multiple times, import it as IExportFactory<IPart>. Using CreateExport().Value, the dependency is instantiated and composed lazily.

Caution: if the part is exported as singleton, then CreateExport().Value will return always the same instance.

Importing multiple parts with the same contract type

Everytime IEnumerable<>, ICollection<>, or IList<> is used as import specification, the composition collects all the services with the indicated contract type and sets the dependency to this enumerated value.

Consuming composition metadata

To be able to access metadata, the dependency must be always indicated as IExportFactory<IPart, Metadata>, either as such or as the item type in one of the IEnumerable<>, ICollection<>, or IList<> interfaces.

    // See the OperationMetadata class above for its definition

    public class Calculator : ICalculator
    {
        private readonly IParser parser;

        public Calculator(ICollection<IExportFactory<IOperation, OperationMetadata>> operationFactories, IParser parser)
        {
            this.parser = parser;
            this.OperationsDictionary = operationFactories.ToDictionary(
                e => e.Metadata.Operation,
                e => e.CreateExport().Value);
        }

        public IDictionary<string, IOperation> OperationsDictionary { get; }
    }

Bootstrapping the application

In this case, a composition container will be created by using one of the BuildWith* methods. The resulted container will be registered in the AmbientServices.

    var ambientServices = new AmbientServices()
                              .WithNLogManager()
                              .BuildWithAutofac(); // this line uses the composition builder for Autofac
    var compositionContainer = ambientServices.CompositionContainer;

With no additional references, Kephas provides the BuildWithLite method, which uses a lightweight DI container. Additionally, by referencing the Kephas.Composition.Autofac or Kephas.Composition.Mef packages one can use either the Autofac DI container or the System.Composition one, by invoking BuildWithAutofac() or BuildWithSystemComposition() extension methods respectively.

Using the composition container as a service locator

For this purpose, simply use the GetExport/GetExports methods of the composition container. This is however not recommended, because it is hidden in the implementation and is not visible that the component is depending upon the requested service.

One use case when this is necessary is, when bootstrapping, the application bootstrapper is retrieved to start the application.

    var appBootstrapper = compositionContainer.GetExport<IAppBootstrapper>();

Creating scoped contexts

If a component is declared as scoped, it can be accessed only within a scope with the same name as it was declared. To create a scoped context, invoke the CreateScopedContext() of the composition container. This will create a container with the provided scope, and in this scope the shared scoped components may be accessed.

Example
    [ScopedAppServiceContract]
    public interface IGame
    {
    }

    public class GameManager 
    {
        public GameManager(ICompositionContext container)
        {
            this.container = container;
        }

        public void CreateGame()
        {
            // this call will fail, no user scope.
            var failedGame = this.container.GetExport<IGame>();
            
            // this will be ok, same scope
            var scopedContainer = this.container.CreateScopedContext();
            var game = scopedContainer.GetExport<IGame>();
            var anotherGame = scopedContainer.GetExport<IGame>();
            Assert.AreSame(game, anotherGame);

            // this will create a new scope and a new game in this scope.
            var newScopedContainer = this.container.CreateScopedContext();
            var newGame = newScopedContainer.GetExport<IGame>();
            Assert.AreNotSame(game, newGame);
        }
    }

Convention registrars

The convention registrar for application services provided by Kephas is the AttributedAppServiceConventionsRegistrar, which registers the conventions for services. If this does not fulfill the application needs, custom registrars may be defined - they must implement IConventionsRegistrar interface.

An example of a custom conventions registrar is provided below:

    public interface ICalculator
    {
    }

    public class CalculatorConventionsRegistrar : IConventionsRegistrar
    {
        public void RegisterConventions(IConventionsBuilder builder, IEnumerable<TypeInfo> candidateTypes)
        {
            builder
                .ForTypesDerivedFrom(typeof(ICalculator))
                .Export(
                    b => b.AsContractType(typeof(ICalculator))
                          .AddMetadata("type", t => t.Name.StartsWith("Scientific") ? "Scientific" : "Classical"))
                .Shared();
        }
    }

    public class ScientificCalculator : ICalculator { }

    public class StandardCalculator : ICalculator { }

How the composition infrastructure works:

  1. All the convention registrars are collected (simply all the classes implementing IConventionsRegistrar) and then they are invoked to register the conventions.

  2. The composition container is built using the provided conventions.

  3. And last, the composition container registers itself as the service exporting ICompositionContext.

Recommendations:

  • There is no restriction about the number of convention registrars per assembly nor what those registrars should register. However, to keep the things under control, it is recommended to have at most one registrar per assembly, except for the cases when the registrar is designed for a broader reach, like the AppServiceConventionRegistrar which registers application services.
  • For components participating in composition, if possible, import the required services in the constructor. By using this approach it is clearly defined what is required for the component to function properly and also specific checks may be performed at the constructor level regarding imported services. However, if there are a lot of dependencies, the constructor may not be very appropriate due to an ugly signature, therefore in this case it is acceptable to use either property import or a combination of them.
    • This guideline is hard to follow for classes declaring a lot of dependencies. To not make the constructor bloated with services, use in this case properties. This should be, however, an exception.

Part configuration

Composition constructor

If an application service has only one constructor, this constructor is used for composition. If multiple constructors are defined, the constructor annotated with [CompositionConstructor] is used.

Service configuration

When implementing application services, there is no need to use the [Import] or [ImportMany] attributes, the services dependencies are automatically identified based on the service contract and automatically registered for import.

Concrete implementations

Kephas provides by default only composition contracts and base functionality around these contracts. A concrete implementation should take care of the following:

  • The composition container should export itself as a shared service for the ICompositionContainer contact, so that services requiring the composition container get this service injected. Accessing ambient services (like AmbientServices.Instance.CompositionContainer [link]) in implementations is not appropriate for unit testing.
  • Use a composition container builder derived from the one provided as base, to have access to all the features it provides, including the registration of application services.

Managed Extensibility Framework (MEF, System.Composition/Microsoft.Composition)

Kephas provides a dependency injection implementation based on the Managed Extensibility Framework included in the .NET Standard 1.5.

Clone this wiki locally