Skip to content

Documentation

Warning

Current implementation is under development, consider to avoid using it in production until 1.0.0

Overview

md.di component provides dependency injection container designed for automatically class instantiating and centralization the way objects are constructed.

Implementation provides constructor, property, setter types of dependency injection, service factory, container configuration dynamic processing, persistence and etc.

Architecture overview

Architecture overview

Terminology:

  • service — any class instance initialized by service container
  • service definition — instruction how to initialize service (class instance); contains constructor arguments, list of methods calls, etc. part of container configuration
  • service definition reference — reference to existing definition, used to define dependency for concrete service definition; represents basic object with identifier attribute.
  • service container — dependency injection container implementation instance, contains service instantiating logic
  • container configuration — contains set of definitions, definition aliases, and parameters

Note

Due to following single responsibility principle md.di component does not provides container configuration persistence, processing, etc out of box. Container (md.di.Container) assumes works with completed container configuration. All related features are decoupled into independent components:

  • md.di.configuration
  • md.di.autowire
  • md.di.persistence

Installation

pip install md.di --index-url https://source.md.land/python/

Usage example

For example, typical code without dependency injection container would look like:

#!/usr/bin/env python3
class Greeter:
    def __init__(self, name: str = 'Greeter') -> None:
        self._name = name

    def greet(self, who: str) -> str:
        return f'{self._name} greets {who}'


if __name__ == '__main__':
    # arrange
    greeter = Greeter()

    # act
    print(greeter.greet('demo example'))

The simplest case of dependency injection container usage is contain of configuration is a service without parameters at all, it looks like:

if __name__ == '__main__':
    import md.di

    # arrange
    container_configuration = md.di.Configuration(
        definition_map={
            'Greeter': md.di.Definition(
                class_=Greeter,
                public=True,  # (1)
            ),
        },
    )
    container = md.di.Container(configuration=container_configuration)

    # act
    greeter = container.get(id_='Greeter')  # (2)
    assert isinstance(greeter, Greeter)
    print(greeter.greet('demo example'))
  1. Majority number of services does not require to be retrieved within related container.get method call, otherwise service definition visibility option should be passed explicitly

  2. When code entity loaded, it could be passed as a service identifier as is: container.get(id_=Greeter); otherwise string value could be passed: container.get(id_='Greeter')

Warning

In example above there is no configuration validation, so invalid setup (eg. service circular reference) may cause error at runtime. To enable configuration validation, consider to use to md.di.configuration component.

Service definition

Constructor configuration

To configure service constructor parameter arguments argument value should be provided:

    container_configuration = md.di.Configuration(
        definition_map={
            'Greeter': md.di.Definition(
                class_=Greeter,
                arguments={
                    'name': 'Parametrized greeter',
                },
                public=True,
            ),
        },
    )

Constructor injection

#!/usr/bin/env python3
class Greeter:
    def __init__(self, name: str = 'Greeter') -> None:
        self._name = name

    def greet(self, who: str) -> str:
        return f'{self._name} greets {who}'


if __name__ == '__main__':
    import md.di

    # arrange
    container_configuration = md.di.Configuration(
        definition_map={
            'Greeter': md.di.Definition(
                class_=Greeter,
                public=True,  # (1)
            ),
        },
    )
    container = md.di.Container(configuration=container_configuration)

    # act
    greeter = container.get(id_='Greeter')  # (2)
    assert isinstance(greeter, Greeter)
    print(greeter.greet('demo example'))

If service requires to be build depending on runtime take a look for md.di.configuration / definition argument processing

Method call

Service may require a call to it, to be finally initialized, for example:

  • dependency injection case
  • to solve circular reference issue
  • to keep constructor signature backward compatibility when new dependency or option added in version (that not requires major version increment)
  • service initialization
import md.di

class Greeter:
    def __init__(self, name: str = 'Greeter') -> None:
        self._name = name
        self._greeting = 'Hello'

    def set_greeting(self, greeting: str) -> None:
        self._greeting = greeting

    def greet(self, who: str) -> str:
        return f'{self._name} greets {who}: {self._greeting}'

container_configuration = md.di.Configuration(
  definition_map={
    'Greeter': md.di.Definition(
      class_=Greeter,
      calls=[
        (
            'set_greeting',  # method name
            [],  # *args 
            {'greeting': 'hi'}  # **kwargs
        ),
        # or:
        # ('set_greeting', ['hi'], {})
      ])
  },
)

Setter injection

Property injection

Implementation of property injection is special kind of setter injection, realized with magic __setattr__ method call.

import md.di

container_configuration = md.di.Configuration(
  definition_map={
    'Greeter': md.di.Definition(
      class_=Greeter,
      calls=[
        (
            '__setattr__',  # method name
            [],  # *args 
            {'name': 'property_name', 'value': 'property_value'}  # **kwargs
        ),
        # or:
        # ('__setattr__', ['property_name', 'property_value'], {})
      ])
  },
)

Service visibility

Every service defined is private by default. When a service is private, it cannot be accessed directly from the container using container.get().

As a best practice, it should only be created private services and fetch services using dependency injection instead of using container.get().

Service sharing

Dependency injection container assumes to reuse service instances since it created, e.g. when few services depends on other, since it instance instantiated it will be injected into first services.

This practice requires to design services clean and stateless, and also thread-safe for multithreading applications.

TODO EXAMPLE

By default, each service is shared. When service is required to be instantiated each time on demand, then related service definition shared option should be set to True, e.g.

TODO USE-CASES

import md.di

definition = md.di.Definition(
    factory=lambda: open('/dev/stderr'),
    shared=False,
)

Service factory

  • function
  • service method invocation

Synthetic service

Synthetic service is a service added in runtime after container instance initialized, such services may have no definition.

class Example:
    pass


if __name__ == '__main__':
    import md.di

    # arrange
    container_configuration = md.di.Configuration()
    container = md.di.Container(configuration=container_configuration)

    # act
    synthetic_service = Example()
    assert not container.has(id_=Example) 

    container.set(id_=Example, instance=synthetic_service)

    synthetic_service_ = container.get(id_=Example)
    assert synthetic_service is synthetic_service_ 

Note

Contract psr.container.ContainerInterface has no set method, but only md.di.Container; in some cases related instance check may be required, eg.

def inject_instances(container: psr.container.ContainerInterface) -> None:
    if not isinstance(container, md.di.Container):
        raise NotImplementedError(f'Unable to set synthetic service into `{type(container)!s}`')

    # call `container.set()` method ...

Argument default value

Definition defines ARGUMENT_DEFAULT_VALUE value as a sentinel, and used to explicitly set definition argument value to default value in related target method signature, for example:

class Greeter:
    def __init__(self, name: str = 'Greeter') -> None:
        self._name = name
        self._greeting = 'Hello'

    def set_greeting(self, greeting: str = 'Hey') -> None:
        self._greeting = greeting

import md.di

md.di.Configuration(
    definition_map={
        'Greeter': md.di.Definition(
            class_=Greeter,
            arguments={
                'name': md.di.Definition.ARGUMENT_DEFAULT_VALUE,
            },
            calls=[
                ('set_greeting', (), {'greeting', md.di.Definition.ARGUMENT_DEFAULT_VALUE})
            ]
        ),
    }
)

Explicitly set this value to definition argument value is optional, and mostly used for dynamic processing purposes.

Container omits this parameter on service factory or service call method invocation.

Container configuration

Container configuration represents simple data structure containing maps of parameters, service definition and service definition alias.

container configuration parts class diagram

Note

Container configuration designed to be passed into container before container could be used, so at that moment configuration is expected to be processed and valid.

Warning

md.di component has no configuration validation out from box, so invalid setup (eg. service circular reference) may cause error at runtime. To enable configuration validation, consider to use to md.di.configuration component.

Container parameters

Service may require to be configured with parameter, which may depends, for example on application environment.

or external resource:

export CONNECTION_DEFAULT_DSN=postgresql://user:password@localhost:5432/default
import os
import md.di
import sqlalchemy

container_configuration = md.di.Configuration(
    definition_map={
        'sqlalchemy.Engine:default': md.di.Definition(
            factory=sqlalchemy.create_engine,
            arguments={
                'url': '{connection.default.dsn}'
            }
        )
    },
    parameter_map={
        'connection.default.dsn': os.environ.get('CONNECTION_DEFAULT_DSN')
    }
)

Service alias

import md.di

container_configuration = md.di.Configuration(
    definition_map={
        'Greeter': md.di.Definition(class_=Greeter)
    },
    definition_alias_map={
        'Greeter_alias': 'Greeter'
    },
)

Default contract implementation

For components following Dependency Inversion Principle, dependency references to an abstraction, not concrete implementation. For such design configuration, alias feature is the solution for such design.

Typical example is usage logger contract, for example:

#!/usr/bin/env python3
import psr.log

import md.log
import md.di


class Example:
    def __init__(self, logger: psr.log.LoggerInterface) -> None:
        self._logger = logger

        def act(self) -> None:
            self._logger.debug('...')


if __name__ == '__main__':
    # arrange
    container_configuration = md.di.Configuration(
        definition_map={
            'Example': md.di.Definition(class_=Example, public=True),
            'md.log.Logger': md.di.Definition(class_=md.log.Logger, arguments=...),
        },
        definition_alias_map={
            'psr.log.LoggerInterface': 'md.log.Logger'
        },
    )
    container = md.di.Container(configuration=container_configuration)

    # act
    example_service = container.get(id_=Example)
    assert isinstance(example_service, Example)

    example_service.act()