Skip to content

Dependency Inversion

Dependency injection is a common practice to decrease coupling and increase cohesion. It has three main advantages:

  • Flexibility: It enables to develop loosely coupled components. An application can be extended or changed easily by using the components in a different way.
  • Testability: Services or resources can be overriden and mock objects can be injected easily.
  • Maintainability: Dependency injection helps to manage all dependencies. All components and dependencies defined explicitly in a container.

General design and coding principles

  • dependency_injector framework will be used for singleton, factory, configuration and resource dependency injection capabilities.
  • Dependency containers will be defined for each executable in run layer.
  • Dependencies will be wired to only Rest API endpoints, async tasks and run layer modules. All others will not import dependency injection library and need to define inputs to use interfaces.

Dependency containers

Any dependency container will be created for

  1. Selecting and defining service implementations and their dependencies (e.g. CacheService with RedisCacheService)
  2. Selecting and defining repository implementations and their dependencies for domain entities (postgreSQL, NFS, Mongo)
  3. Selecting and initiating application configuration (e.g. different config.yaml files for prod or development)
  4. Initiating application level variables and triggering global functions (logging configuration, async task registry, request tracker, etc.)

Dependency container

You can find an example container definition below. It defines repositories and their implementations, and assigns the initiation parameters as well.

class RepositoriesContainer(containers.DeclarativeContainer):
    config = providers.Configuration()
    entity_mapper: EntityMapper = providers.Singleton(EntityMapper)

    alias_generator: AliasGenerator = providers.Singleton(
        DbTableAliasGeneratorImpl, entity_mapper
    )

    gateways = providers.DependenciesContainer()
    services = providers.DependenciesContainer()
    study_read_repository: StudyReadRepository = providers.Singleton(
        SqlDbStudyReadRepository,
        entity_mapper=entity_mapper,
        alias_generator=alias_generator,
        database_client=gateways.database_client,
    )
    study_write_repository: StudyWriteRepository = providers.Singleton(
        SqlDbStudyWriteRepository,
        entity_mapper=entity_mapper,
        alias_generator=alias_generator,
        database_client=gateways.database_client,
    )
    user_write_repository: UserWriteRepository = providers.Singleton(
        SqlDbUserWriteRepository,
        entity_mapper=entity_mapper,
        alias_generator=alias_generator,
        database_client=gateways.database_client,
    )

    user_read_repository: UserReadRepository = providers.Singleton(
        SqlDbUserReadRepository,
        entity_mapper=entity_mapper,
        alias_generator=alias_generator,
        database_client=gateways.database_client,
    )

    statistic_read_repository: StatisticReadRepository = providers.Singleton(
        SqlDbStatisticReadRepository,
        entity_mapper=entity_mapper,
        alias_generator=alias_generator,
        database_client=gateways.database_client,
    )

    folder_manager = providers.Singleton(
        StudyFolderManager, config=config.repositories.study_folders
    )

Dependency injection to call Rest API endpoints

Example

The following example shows how a dependency is injected into a Rest API endpoint. Endpoint uses the injected service to call a business logic method in application layer

logger = getLogger(__name__)

router = APIRouter(tags=["Study Validation Overrides"], prefix="/curation/v1")


@router.patch(
    "/{resource_id}/validation-overrides",
    summary="Patch validation overrides for the study",
    description="Adds new override if it is not overrides list ",
    response_model=APIResponse[ValidationOverrideList],
)
@inject
async def patch_validation_overrides_endpoint(
    resource_id: Annotated[str, Depends(get_resource_id)],
    overrides: Annotated[
        list[ValidationOverrideInput],
        Body(
            title="Validation override input.",
            description="Override filters and updates",
        ),
    ],
    user: Annotated[UserOutput, Depends(check_curator_role)],
    validation_override_service: ValidationOverrideService = Depends(
        Provide["services.validation_override_service"]
    ),
):
    if not overrides:
        return APIErrorResponse(error_message="No overrides.")

    logger.info(
        "Override patch request for %s from user %s: %s",
        resource_id,
        user.id_,
        [x.model_dump_json() for x in overrides],
    )
    overrides = await patch_validation_overrides(
        resource_id=resource_id,
        validation_overrides=overrides,
        validation_override_service=validation_override_service,
    )

Service usage in application layer

Dependency wiring is not used in application layer (except async tasks). Any business method or class uses only interfaces.

async def patch_validation_overrides(
    resource_id: str,
    validation_override: ValidationOverrideInput,
    validation_override_service: ValidationOverrideService,
) -> ValidationOverrideList:
    repo = validation_override_service
    overrides_content = await repo.get_validation_overrides(resource_id=resource_id)
    version = overrides_content.validation_version

Dependency injection to call async tasks

Example

Async tasks are defined in application layer and can be called from application or presentation layer. They are entrypoints to run remote tasks on remote workers, so async tasks can define parameters to be injected by dependency injection mechanism.

The following example shows an example usage of dependency injection in async tasks.

///

import logging
from typing import Union

from dependency_injector.wiring import Provide, inject

from mtbls.application.decorators.async_task import async_task
from mtbls.application.remote_tasks.common.run_validation import (
    run_validation_task,
    run_validation_task_with_modifiers,
)
from mtbls.application.remote_tasks.common.utils import run_coroutine
from mtbls.application.services.interfaces.async_task.async_task_result import (
    AsyncTaskResult,
)
from mtbls.application.services.interfaces.policy_service import PolicyService
from mtbls.application.services.interfaces.study_metadata_service_factory import (
    StudyMetadataServiceFactory,
)
from mtbls.domain.shared.validator.types import ValidationPhase

logger = logging.getLogger(__name__)


@async_task(queue="common")
@inject
def run_validation(  # noqa: PLR0913
    *,
    resource_id: str,
    apply_modifiers: bool = True,
    phases: Union[ValidationPhase, None, list[str]] = None,
    serialize_result: bool = True,
    study_metadata_service_factory: StudyMetadataServiceFactory = Provide[
        "services.study_metadata_service_factory"
    ],
    policy_service: PolicyService = Provide["services.policy_service"],
    **kwargs,
) -> AsyncTaskResult:
    try:
        modifier_result = None
        if apply_modifiers:
            coroutine = run_validation_task_with_modifiers(
                resource_id,
                study_metadata_service_factory=study_metadata_service_factory,
                policy_service=policy_service,
                phases=phases,
                serialize_result=serialize_result,
            )
        else:
            coroutine = run_validation_task(
                resource_id,
                modifier_result=modifier_result,
                study_metadata_service_factory=study_metadata_service_factory,
                policy_service=policy_service,
                phases=phases,
                serialize_result=serialize_result,
            )
        return run_coroutine(coroutine)
    except Exception as ex:
        logger.error("Validation task execution for %s failed.", resource_id)
        logger.exception(ex)
        raise ex
    finally:
        logger.info("Validation task execution for %s ended.", resource_id)

Unit tests and overridden dependency containers

Example

You can override containers any time but the most common case is unit tests.

The following example shows an example how to override a container to use:

  • local database
  • in-memory cache
  • standalone authentication service

It also overrides configuration file to fetch configurations of local database, in-memory cache and standalone authentication service.

from mtbls.application.services.interfaces.http_client import HttpClient
from mtbls.infrastructure.auth.standalone.standalone_authentication_config import (
    StandaloneAuthenticationConfiguration,
)
from mtbls.infrastructure.auth.standalone.standalone_authentication_service import (
    AuthenticationServiceImpl,
)
from mtbls.infrastructure.caching.in_memory.in_memory_cache import InMemoryCacheImpl
from mtbls.infrastructure.system_health_check_service.standalone.standalone_system_health_check_config import (  # noqa E501
    StandaloneSystemHealthCheckConfiguration,
)
from mtbls.infrastructure.system_health_check_service.standalone.standalone_system_health_check_service import (  # noqa E501
    StandaloneSystemHealthCheckService,
)
from mtbls.run.rest_api.submission.containers import Ws3ApplicationContainer
from tests.mtbls.mocks.policy_service.mock_policy_service import MockPolicyService


@pytest.fixture(scope="module")
def submission_api_container(local_env_container) -> Ws3ApplicationContainer:
    container = local_env_container
    standalone_heath_check_config_str = (
        container.config.services.system_health_check.standalone()
    )
    health_check_config = StandaloneSystemHealthCheckConfiguration.model_validate(
        standalone_heath_check_config_str
    )
    container.gateways.http_client.override(Mock(spec=HttpClient))

    container.services.system_health_check_service.override(
        StandaloneSystemHealthCheckService(
            config=health_check_config, http_client=container.gateways.http_client
        )
    )
    # Override Cache
    container.services.cache_service.override(InMemoryCacheImpl())
    # Override Authentication service
    standalone_auth_config_str = container.config.services.authentication.standalone()
    standalone_auth_config = StandaloneAuthenticationConfiguration.model_validate(
        standalone_auth_config_str
    )
    container.services.authentication_service.override(
        AuthenticationServiceImpl(
            config=standalone_auth_config,
            cache_service=container.services.cache_service(),
            user_read_repository=container.repositories.user_read_repository(),
        )
    )

    container.services.policy_service.override(MockPolicyService())

    return container